mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 05:56:38 +08:00
999428cf9a
为 GitHub latest 来源增加五分钟 scanner、按来源间隔检查、精确 revision 自动发布与租约恢复。\n记录退避和投递统计,并为 PostgreSQL 与 SQLite 幂等创建内部排程。
890 lines
41 KiB
Go
890 lines
41 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package pages
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/db"
|
|
"github.com/Rain-kl/Wavelet/internal/integration/githubrelease"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/hibiken/asynq"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type fakeGitHubReleaseClient struct {
|
|
resolve func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error)
|
|
download func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error)
|
|
}
|
|
|
|
func (client *fakeGitHubReleaseClient) Resolve(
|
|
ctx context.Context,
|
|
request githubrelease.ResolveRequest,
|
|
) (githubrelease.ResolveResult, error) {
|
|
return client.resolve(ctx, request)
|
|
}
|
|
|
|
func (client *fakeGitHubReleaseClient) Download(
|
|
ctx context.Context,
|
|
request githubrelease.DownloadRequest,
|
|
) (*githubrelease.DownloadResult, error) {
|
|
return client.download(ctx, request)
|
|
}
|
|
|
|
func useFakeGitHubReleaseClient(t *testing.T, client githubReleaseAPI) {
|
|
t.Helper()
|
|
previous := newGitHubReleaseClient
|
|
newGitHubReleaseClient = func() githubReleaseAPI { return client }
|
|
t.Cleanup(func() { newGitHubReleaseClient = previous })
|
|
}
|
|
|
|
func mustConfigureGitHubSourceWithoutDispatch(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
projectID uint,
|
|
input SourceUpdateInput,
|
|
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
|
t.Helper()
|
|
if err := validateGitHubSourceInput(input); err != nil {
|
|
t.Fatalf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
|
}
|
|
if err := db.DB(ctx).Transaction(func(tx *gorm.DB) error {
|
|
_, err := updateGitHubSourceTx(tx, projectID, input)
|
|
return err
|
|
}); err != nil {
|
|
t.Fatalf("updateGitHubSourceTx(project=%d) error = %v, want nil", projectID, err)
|
|
}
|
|
return mustLoadPagesSource(t, ctx, projectID)
|
|
}
|
|
|
|
func mustLoadPagesSource(
|
|
t *testing.T,
|
|
ctx context.Context,
|
|
projectID uint,
|
|
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
|
|
t.Helper()
|
|
var source model.PagesProjectSource
|
|
if err := db.DB(ctx).Where("project_id = ?", projectID).First(&source).Error; err != nil {
|
|
t.Fatalf("load source for project %d error = %v, want nil", projectID, err)
|
|
}
|
|
var runtime model.PagesProjectSourceRuntime
|
|
if err := db.DB(ctx).Where("source_id = ?", source.ID).First(&runtime).Error; err != nil {
|
|
t.Fatalf("load runtime for source %d error = %v, want nil", source.ID, err)
|
|
}
|
|
return &source, &runtime
|
|
}
|
|
|
|
func TestGitHubSourceValidationNormalizationAndProviderSwitch(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
setupPagesSourceDispatchTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-config")
|
|
input := SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/OpenFlare/site.git",
|
|
}
|
|
result, err := UpdateSourceAs(ctx, project.ID, input, "user:42")
|
|
if err != nil {
|
|
t.Fatalf("UpdateSourceAs(GitHub) error = %v, want nil", err)
|
|
}
|
|
if result.CheckTask == nil || result.CheckTask.Action != sourceActionCheck || result.Warning != "" {
|
|
t.Errorf("UpdateSourceAs(GitHub) result = %+v, want initial check receipt without warning", result)
|
|
}
|
|
execution, err := model.GetTaskExecutionByTaskID(ctx, result.CheckTask.TaskID)
|
|
if err != nil {
|
|
t.Fatalf("GetTaskExecutionByTaskID(%q) error = %v, want nil", result.CheckTask.TaskID, err)
|
|
}
|
|
var actionPayload SourceActionPayload
|
|
if err := json.Unmarshal([]byte(execution.Payload), &actionPayload); err != nil {
|
|
t.Fatalf("json.Unmarshal(initial check payload) error = %v, want nil", err)
|
|
}
|
|
if actionPayload.Actor != "user:42" || actionPayload.Action != sourceActionCheck ||
|
|
actionPayload.TargetRevision != "" || actionPayload.ConfirmedRevision != "" {
|
|
t.Errorf("initial check payload = %+v, want real actor and credential-free check", actionPayload)
|
|
}
|
|
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if got, want := source.GitHubRepository, "OpenFlare/site"; got != want {
|
|
t.Errorf("GitHubRepository = %q, want %q", got, want)
|
|
}
|
|
if got, want := source.ReleaseSelector, githubReleaseSelectorLatest; got != want {
|
|
t.Errorf("ReleaseSelector = %q, want %q", got, want)
|
|
}
|
|
if got, want := source.AssetName, defaultGitHubAssetName; got != want {
|
|
t.Errorf("AssetName = %q, want %q", got, want)
|
|
}
|
|
if got, want := source.CheckIntervalMinutes, defaultCheckInterval; got != want {
|
|
t.Errorf("CheckIntervalMinutes = %d, want %d", got, want)
|
|
}
|
|
if got, want := source.SourceIdentity, "dbbd25307aaa3b88bc25353476940a049428655bd8421ac63045fdcb5fb23c9d"; got != want {
|
|
t.Errorf("SourceIdentity = %q, want %q", got, want)
|
|
}
|
|
if runtime.NextCheckAt == nil {
|
|
t.Error("GitHub latest NextCheckAt = nil, want scheduled value")
|
|
}
|
|
|
|
var taskCount int64
|
|
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&taskCount).Error; err != nil {
|
|
t.Fatalf("count initial checks error = %v, want nil", err)
|
|
}
|
|
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
|
t.Fatalf("UpdateSourceAs(GitHub no-op) error = %v, want nil", err)
|
|
}
|
|
var noOpTaskCount int64
|
|
if err := db.DB(ctx).Model(&model.TaskExecution{}).Count(&noOpTaskCount).Error; err != nil {
|
|
t.Fatalf("count no-op checks error = %v, want nil", err)
|
|
}
|
|
if noOpTaskCount != taskCount {
|
|
t.Errorf("no-op initial check count = %d, want unchanged %d", noOpTaskCount, taskCount)
|
|
}
|
|
|
|
secret := "provider-switch-secret"
|
|
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeRemoteURL,
|
|
RemoteURLSet: true,
|
|
RemoteURL: "https://artifacts.example.com/site.zip?token=" + secret,
|
|
RemoteNetworkPolicy: RemoteNetworkPolicyPublic,
|
|
}); err != nil {
|
|
t.Fatalf("UpdateSource(GitHub to Remote) error = %v, want nil", err)
|
|
}
|
|
remote, _ := mustLoadPagesSource(t, ctx, project.ID)
|
|
if remote.GitHubRepository != "" || remote.ReleaseSelector != "" || remote.AssetName != "" ||
|
|
remote.AutoUpdateEnabled || remote.CheckIntervalMinutes != 0 {
|
|
t.Errorf("Remote switched source retained GitHub fields: %+v", remote)
|
|
}
|
|
if _, err := UpdateSourceAs(ctx, project.ID, input, "user:42"); err != nil {
|
|
t.Fatalf("UpdateSourceAs(Remote to GitHub) error = %v, want nil", err)
|
|
}
|
|
github, _ := mustLoadPagesSource(t, ctx, project.ID)
|
|
if github.RemoteURL != "" || github.RemoteNetworkPolicy != "" {
|
|
t.Errorf("GitHub switched source retained Remote fields: URL=%q policy=%q", github.RemoteURL, github.RemoteNetworkPolicy)
|
|
}
|
|
}
|
|
|
|
func TestGitHubSourceSaveSurvivesInitialCheckDispatchFailure(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-dispatch-warning")
|
|
result, err := UpdateSourceAs(ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
}, "user:9")
|
|
if err != nil {
|
|
t.Fatalf("UpdateSourceAs(dispatch failure) error = %v, want saved source with warning", err)
|
|
}
|
|
if result.CheckTask != nil || result.Warning != errPagesSourceInitialCheckWarning {
|
|
t.Errorf("UpdateSourceAs(dispatch failure) result = %+v, want warning and nil check task", result)
|
|
}
|
|
source, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if source.GitHubRepository != "a/b" || runtime.SyncStatus != pagesSourceStatusFailed ||
|
|
runtime.LastError != errPagesSourceInitialCheckWarning {
|
|
t.Errorf("saved source/runtime = repo:%q status:%q error:%q", source.GitHubRepository, runtime.SyncStatus, runtime.LastError)
|
|
}
|
|
}
|
|
|
|
func TestGitHubSourceRejectsUnsafeOrModeIncompatibleFields(t *testing.T) {
|
|
tests := []SourceUpdateInput{
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "http://github.com/a/b"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a%20b/repo"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/extra"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com//a/b"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b/"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b?"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b#"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\n.zip"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dist\u202e.zip"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", AssetName: "dir/dist.zip"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", CheckIntervalMinutes: 60},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1", AutoUpdateEnabled: true},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: " v1"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\n"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "v1\u2028draft"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: `v1\draft`},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release//v1"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/.draft"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "tag", ReleaseTag: "release/v1.lock"},
|
|
{SourceType: PagesSourceTypeGitHubRelease, RepositoryURL: "https://github.com/a/b", ReleaseSelector: "latest", ReleaseTag: "v1"},
|
|
}
|
|
for _, input := range tests {
|
|
if err := validateGitHubSourceInput(input); err == nil {
|
|
t.Errorf("validateGitHubSourceInput(%+v) error = nil, want non-nil", input)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGitHubSourceAcceptsLegalAssetAndTagCharacters(t *testing.T) {
|
|
tests := []SourceUpdateInput{
|
|
{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
AssetName: "dist?channel=stable&part#1.zip",
|
|
},
|
|
{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
AssetName: " dist.zip ",
|
|
},
|
|
{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
ReleaseSelector: "tag",
|
|
ReleaseTag: "release/v1#stable&build=1",
|
|
AssetName: "dist.zip",
|
|
},
|
|
{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b.git",
|
|
ReleaseSelector: "tag",
|
|
ReleaseTag: "@",
|
|
AssetName: "dist.zip",
|
|
},
|
|
{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
ReleaseSelector: "tag",
|
|
ReleaseTag: "release/v1.LOCK",
|
|
AssetName: "dist.zip",
|
|
},
|
|
}
|
|
for _, input := range tests {
|
|
if err := validateGitHubSourceInput(input); err != nil {
|
|
t.Errorf("validateGitHubSourceInput(%+v) error = %v, want nil", input, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestInitialCheckFailureUsesExactConfigFence(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-initial-fence")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
staleVersion := source.ConfigVersion
|
|
if err := db.DB(ctx).Model(source).Update("config_version", staleVersion+1).Error; err != nil {
|
|
t.Fatalf("increment source config version error = %v, want nil", err)
|
|
}
|
|
markInitialCheckDispatchFailed(ctx, source.ID, staleVersion)
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastError != "" {
|
|
t.Errorf("stale initial failure runtime = status:%q error:%q, want unchanged idle", runtime.SyncStatus, runtime.LastError)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCheckUsesETagAndDetectsSameReleaseReplacement(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-check")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
appliedRevision := strings.Repeat("a", 64)
|
|
appliedDetail := `{"provider":"github","release_id":"100","asset_id":"1","tag":"release/v1","asset_name":"dist.zip"}`
|
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
|
"etag": `"old-etag"`,
|
|
"last_applied_revision": appliedRevision,
|
|
"last_applied_detail": appliedDetail,
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed GitHub runtime error = %v, want nil", err)
|
|
}
|
|
updatedAt := time.Date(2026, 7, 19, 10, 0, 0, 0, time.UTC)
|
|
var gotETag string
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
gotETag = request.ETag
|
|
return githubrelease.ResolveResult{
|
|
ETag: `"new-etag"`,
|
|
Release: githubrelease.Release{ID: "100", Tag: "release/v1"},
|
|
Asset: githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt},
|
|
}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called during check, want resolve only")
|
|
return nil, nil
|
|
},
|
|
})
|
|
snapshot, outcome, err := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
|
if err != nil || outcome != sourceLeaseAcquired {
|
|
t.Fatalf("acquire check lease = (%+v, %q, %v), want acquired", snapshot, outcome, err)
|
|
}
|
|
result, err := checkGitHubSource(ctx, snapshot)
|
|
if err != nil {
|
|
t.Fatalf("checkGitHubSource() error = %v, want nil", err)
|
|
}
|
|
if result.Stale {
|
|
t.Error("checkGitHubSource() stale = true, want false")
|
|
}
|
|
if got, want := gotETag, `"old-etag"`; got != want {
|
|
t.Errorf("Resolve ETag = %q, want %q", got, want)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusAttention || runtime.LastSeenRevision == "" {
|
|
t.Errorf("replacement runtime = status:%q seen:%q, want attention with revision", runtime.SyncStatus, runtime.LastSeenRevision)
|
|
}
|
|
view, err := GetSource(ctx, project.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetSource() error = %v, want nil", err)
|
|
}
|
|
if view.LastSeen == nil || view.LastSeen.Label != "release/v1" {
|
|
t.Errorf("LastSeen = %+v, want full tag with slash", view.LastSeen)
|
|
}
|
|
if err := preflightGitHubSyncConfirmation(ctx, source.ID, ""); err == nil || err.Error() != errPagesSourceConfirmationNeeded {
|
|
t.Errorf("preflight without confirmation error = %v, want %q", err, errPagesSourceConfirmationNeeded)
|
|
}
|
|
if err := preflightGitHubSyncConfirmation(ctx, source.ID, runtime.LastSeenRevision); err != nil {
|
|
t.Errorf("preflight exact confirmation error = %v, want nil", err)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCheckNotModifiedRefreshesRuntimeWithoutDeployment(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-304")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(_ context.Context, request githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{NotModified: true, ETag: `"same"`}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called for 304 check")
|
|
return nil, nil
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
|
if _, err := checkGitHubSource(ctx, snapshot); err != nil {
|
|
t.Fatalf("checkGitHubSource(304) error = %v, want nil", err)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.ETag != `"same"` || runtime.LastCheckedAt == nil || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
|
t.Errorf("304 runtime = %+v, want refreshed timestamps/etag and released lease", runtime)
|
|
}
|
|
var deployments int64
|
|
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
|
t.Fatalf("count deployments error = %v, want nil", err)
|
|
}
|
|
if deployments != 0 {
|
|
t.Errorf("deployments after check = %d, want 0", deployments)
|
|
}
|
|
}
|
|
|
|
func TestGitHubTargetMismatchPreservesAttentionAndExpeditesRecheck(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-target-mismatch")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
appliedRevision := strings.Repeat("a", 64)
|
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
|
"last_applied_revision": appliedRevision,
|
|
"last_applied_detail": `{"provider":"github","release_id":"100","asset_id":"1","tag":"v1","asset_name":"dist.zip"}`,
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed applied runtime error = %v, want nil", err)
|
|
}
|
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{
|
|
Release: githubrelease.Release{ID: "100", Tag: "v1"},
|
|
Asset: githubrelease.Asset{
|
|
ID: "2", Name: "dist.zip", State: "uploaded",
|
|
UpdatedAt: time.Date(2026, 7, 19, 12, 0, 0, 0, time.UTC),
|
|
},
|
|
RetryAt: &retryAt,
|
|
}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called after target mismatch")
|
|
return nil, nil
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
|
outcome, err := syncGitHubSource(ctx, snapshot, pagesSourceCreatedBySystem, strings.Repeat("b", 64), "")
|
|
if err != nil {
|
|
t.Fatalf("syncGitHubSource(target mismatch) error = %v, want nil stale outcome", err)
|
|
}
|
|
if outcome == nil || !outcome.Stale {
|
|
t.Errorf("syncGitHubSource(target mismatch) = %+v, want stale", outcome)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusAttention {
|
|
t.Errorf("target mismatch SyncStatus = %q, want %q", runtime.SyncStatus, pagesSourceStatusAttention)
|
|
}
|
|
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
|
t.Errorf("target mismatch NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
|
}
|
|
var deployments int64
|
|
if err := db.DB(ctx).Model(&model.PagesDeployment{}).Where("project_id = ?", project.ID).Count(&deployments).Error; err != nil {
|
|
t.Fatalf("count mismatch deployments error = %v, want nil", err)
|
|
}
|
|
if deployments != 0 {
|
|
t.Errorf("target mismatch deployments = %d, want 0", deployments)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCheckLostLeaseReturnsStaleWithoutOverwritingRuntime(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-check-fence")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{}, errors.New("transient provider failure")
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
return nil, errors.New("unexpected")
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
|
"lease_token": "new-owner",
|
|
"lease_expires_at": time.Now().Add(time.Minute),
|
|
"sync_status": pagesSourceStatusSyncing,
|
|
"last_error": "new-owner-state",
|
|
}).Error; err != nil {
|
|
t.Fatalf("replace lease owner error = %v, want nil", err)
|
|
}
|
|
result, err := checkGitHubSource(ctx, snapshot)
|
|
if err != nil {
|
|
t.Fatalf("checkGitHubSource(lost lease) error = %v, want stale no-op", err)
|
|
}
|
|
if result == nil || !result.Stale {
|
|
t.Errorf("checkGitHubSource(lost lease) = %+v, want stale", result)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.LeaseToken != "new-owner" || runtime.LastError != "new-owner-state" || runtime.SyncStatus != pagesSourceStatusSyncing {
|
|
t.Errorf("lost lease runtime = token:%q error:%q status:%q, want new owner state", runtime.LeaseToken, runtime.LastError, runtime.SyncStatus)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCheckRateLimitUsesServerDeadlineAndSuppressesFastRetry(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-rate-limit")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
|
Kind: githubrelease.ErrMetadata, StatusCode: 429, RetryAt: &retryAt,
|
|
}
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
return nil, errors.New("unexpected")
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
|
_, err := checkGitHubSource(ctx, snapshot)
|
|
if err == nil || err.Error() != errPagesSourceSyncFailed {
|
|
t.Fatalf("checkGitHubSource(rate limit) error = %v, want safe sync failure", err)
|
|
}
|
|
if !shouldSkipGitHubActionRetry(err) {
|
|
t.Error("shouldSkipGitHubActionRetry(rate limit) = false, want true")
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.SyncStatus != pagesSourceStatusFailed {
|
|
t.Errorf("rate limit runtime = next:%v status:%q, want deadline >= %v and failed", runtime.NextCheckAt, runtime.SyncStatus, retryAt)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCheckInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-check-target")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{
|
|
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
|
Asset: githubrelease.Asset{
|
|
ID: "2", Name: "dist.zip", State: "uploaded",
|
|
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
|
},
|
|
RetryAt: &retryAt,
|
|
}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called after invalid check target")
|
|
return nil, nil
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionCheck)
|
|
_, err := checkGitHubSource(ctx, snapshot)
|
|
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
|
t.Fatalf("checkGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
|
}
|
|
if !isPermanentSourceSyncError(err) {
|
|
t.Error("invalid check target classification = retryable, want permanent")
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
|
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
|
t.Errorf(
|
|
"invalid check target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
|
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
|
errPagesSourceDigestInvalid, retryAt,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestGitHubSyncInvalidResolvedTargetUsesServerDeadline(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-invalid-sync-target")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{
|
|
Release: githubrelease.Release{ID: "1", Tag: "v1"},
|
|
Asset: githubrelease.Asset{
|
|
ID: "2", Name: "dist.zip", State: "uploaded",
|
|
UpdatedAt: time.Now().UTC(), Digest: "sha256:invalid",
|
|
},
|
|
RetryAt: &retryAt,
|
|
}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called after invalid sync target")
|
|
return nil, nil
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
|
_, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
|
if err == nil || err.Error() != errPagesSourceDigestInvalid {
|
|
t.Fatalf("syncGitHubSource(invalid target) error = %v, want %q", err, errPagesSourceDigestInvalid)
|
|
}
|
|
if !isPermanentSourceSyncError(err) {
|
|
t.Error("invalid sync target classification = retryable, want permanent")
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.LastError != errPagesSourceDigestInvalid ||
|
|
runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) || runtime.LeaseToken != "" {
|
|
t.Errorf(
|
|
"invalid sync target runtime = status:%q error:%q next:%v lease:%q, want failed/%q/deadline >= %v/cleared",
|
|
runtime.SyncStatus, runtime.LastError, runtime.NextCheckAt, runtime.LeaseToken,
|
|
errPagesSourceDigestInvalid, retryAt,
|
|
)
|
|
}
|
|
}
|
|
|
|
func TestGitHubCheckHandlerSkipsProviderFastRetry(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
retryDate bool
|
|
}{
|
|
{name: "bad request", status: http.StatusBadRequest},
|
|
{name: "rate limited forbidden", status: http.StatusForbidden, retryDate: true},
|
|
{name: "too many requests", status: http.StatusTooManyRequests, retryDate: true},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
ctx := setupPagesSourceTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-handler-"+strings.ReplaceAll(test.name, " ", "-"))
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
var retryAt *time.Time
|
|
if test.retryDate {
|
|
deadline := time.Now().Add(2 * time.Hour).UTC()
|
|
retryAt = &deadline
|
|
}
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{}, &githubrelease.Error{
|
|
Kind: githubrelease.ErrMetadata, StatusCode: test.status, RetryAt: retryAt,
|
|
}
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called after provider check failure")
|
|
return nil, nil
|
|
},
|
|
})
|
|
raw, err := json.Marshal(SourceActionPayload{
|
|
SourceID: source.ID, ConfigVersion: source.ConfigVersion,
|
|
Action: sourceActionCheck, Actor: "user:7",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("json.Marshal(check payload) error = %v, want nil", err)
|
|
}
|
|
result, err := (&SourceActionHandler{}).Execute(ctx, raw)
|
|
if result != nil || err == nil || !errors.Is(err, asynq.SkipRetry) {
|
|
t.Fatalf("SourceActionHandler.Execute(status %d) = result:%+v error:%v, want SkipRetry", test.status, result, err)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusFailed || runtime.NextCheckAt == nil || runtime.LeaseToken != "" {
|
|
t.Errorf("provider failure runtime = status:%q next:%v lease:%q", runtime.SyncStatus, runtime.NextCheckAt, runtime.LeaseToken)
|
|
}
|
|
if retryAt != nil && (runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(*retryAt)) {
|
|
t.Errorf("provider failure NextCheckAt = %v, want deadline >= %v", runtime.NextCheckAt, *retryAt)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubSyncActivatesWithMetadataRevisionAndPackageChecksum(t *testing.T) {
|
|
ctx := setupPagesSourceSyncTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-sync")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
AssetName: "dist.zip",
|
|
})
|
|
packageBytes := testPagesZip(t, map[string]string{"index.html": "github-v1"})
|
|
packageHash := sha256.Sum256(packageBytes)
|
|
updatedAt := time.Date(2026, 7, 19, 11, 0, 0, 0, time.UTC)
|
|
retryAt := time.Now().Add(2 * time.Hour).UTC()
|
|
release := githubrelease.Release{ID: "200", Tag: "release/v2"}
|
|
asset := githubrelease.Asset{ID: "10", Name: "dist.zip", State: "uploaded", UpdatedAt: updatedAt}
|
|
client := &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{Release: release, Asset: asset, RetryAt: &retryAt}, nil
|
|
},
|
|
download: func(_ context.Context, request githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
path := filepath.Join(t.TempDir(), "download")
|
|
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
|
t.Fatalf("os.WriteFile(download) error = %v, want nil", err)
|
|
}
|
|
return &githubrelease.DownloadResult{
|
|
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
|
}, nil
|
|
},
|
|
}
|
|
useFakeGitHubReleaseClient(t, client)
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
|
outcome, err := syncGitHubSource(ctx, snapshot, "user:7", "", "")
|
|
if err != nil {
|
|
t.Fatalf("syncGitHubSource() error = %v, want nil", err)
|
|
}
|
|
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
|
t.Fatalf("syncGitHubSource() = %+v, want active deployment", outcome)
|
|
}
|
|
deployment, err := model.GetPagesDeploymentByID(ctx, outcome.Deployment.ID)
|
|
if err != nil {
|
|
t.Fatalf("GetPagesDeploymentByID(%d) error = %v, want nil", outcome.Deployment.ID, err)
|
|
}
|
|
if got, want := deployment.Checksum, hex.EncodeToString(packageHash[:]); got != want {
|
|
t.Errorf("deployment Checksum = %q, want package hash %q", got, want)
|
|
}
|
|
if deployment.SourceRevision == nil || *deployment.SourceRevision == deployment.Checksum {
|
|
t.Errorf("deployment SourceRevision = %v, want metadata revision distinct from package checksum", deployment.SourceRevision)
|
|
}
|
|
if got, want := deployment.SourceLabel, "release/v2"; got != want {
|
|
t.Errorf("deployment SourceLabel = %q, want %q", got, want)
|
|
}
|
|
if deployment.SourceType != PagesSourceTypeGitHubRelease || deployment.TriggerType != pagesSourceTriggerManualSync ||
|
|
deployment.CreatedBy != "user:7" {
|
|
t.Errorf("deployment provenance = type:%q trigger:%q actor:%q", deployment.SourceType, deployment.TriggerType, deployment.CreatedBy)
|
|
}
|
|
if strings.Contains(deployment.SourceMeta, "http") || strings.Contains(deployment.SourceMeta, "token") {
|
|
t.Errorf("deployment SourceMeta = %q, want no URL or token", deployment.SourceMeta)
|
|
}
|
|
if !strings.Contains(deployment.SourceMeta, `"tag":"release/v2"`) || strings.Contains(deployment.SourceMeta, `"label"`) {
|
|
t.Errorf("deployment SourceMeta = %q, want provider-specific tag field", deployment.SourceMeta)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.NextCheckAt == nil || runtime.NextCheckAt.Before(retryAt) {
|
|
t.Errorf("sync runtime NextCheckAt = %v, want server deadline >= %v", runtime.NextCheckAt, retryAt)
|
|
}
|
|
|
|
secondSnapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
|
second, err := syncGitHubSource(ctx, secondSnapshot, "user:7", "", "")
|
|
if err != nil {
|
|
t.Fatalf("syncGitHubSource(idempotent) error = %v, want nil", err)
|
|
}
|
|
if second == nil || !second.Reused || second.Deployment == nil || second.Deployment.ID != outcome.Deployment.ID {
|
|
t.Errorf("syncGitHubSource(idempotent) = %+v, want reused deployment %d", second, outcome.Deployment.ID)
|
|
}
|
|
}
|
|
|
|
func TestGitHubSyncActivatesExactConfirmedReplacement(t *testing.T) {
|
|
ctx := setupPagesSourceSyncTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-replacement")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
AssetName: "dist.zip",
|
|
})
|
|
release := githubrelease.Release{ID: "300", Tag: "v3"}
|
|
asset := githubrelease.Asset{
|
|
ID: "12", Name: "dist.zip", State: "uploaded",
|
|
UpdatedAt: time.Date(2026, 7, 19, 13, 0, 0, 0, time.UTC),
|
|
}
|
|
target, err := buildGitHubSourceTarget(release, asset, nil)
|
|
if err != nil {
|
|
t.Fatalf("buildGitHubSourceTarget() error = %v, want nil", err)
|
|
}
|
|
if err := db.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Updates(map[string]any{
|
|
"last_seen_revision": target.Revision,
|
|
"last_seen_detail": target.DetailJSON,
|
|
"last_applied_revision": strings.Repeat("a", 64),
|
|
"last_applied_detail": `{"provider":"github","release_id":"300","asset_id":"11","tag":"v3","asset_name":"dist.zip"}`,
|
|
"sync_status": pagesSourceStatusAttention,
|
|
}).Error; err != nil {
|
|
t.Fatalf("seed replacement cursor error = %v, want nil", err)
|
|
}
|
|
packageBytes := testPagesZip(t, map[string]string{"index.html": "confirmed-v3"})
|
|
packageHash := sha256.Sum256(packageBytes)
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{Release: release, Asset: asset}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
path := filepath.Join(t.TempDir(), "confirmed.zip")
|
|
if err := os.WriteFile(path, packageBytes, 0o600); err != nil {
|
|
t.Fatalf("os.WriteFile(confirmed package) error = %v, want nil", err)
|
|
}
|
|
return &githubrelease.DownloadResult{
|
|
Path: path, Size: int64(len(packageBytes)), SHA256: hex.EncodeToString(packageHash[:]),
|
|
}, nil
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
|
outcome, err := syncGitHubSource(ctx, snapshot, "user:9", "", target.Revision)
|
|
if err != nil {
|
|
t.Fatalf("syncGitHubSource(confirmed replacement) error = %v, want nil", err)
|
|
}
|
|
if outcome == nil || outcome.Deployment == nil || outcome.Stale {
|
|
t.Fatalf("syncGitHubSource(confirmed replacement) = %+v, want active deployment", outcome)
|
|
}
|
|
_, runtime := mustLoadPagesSource(t, ctx, project.ID)
|
|
if runtime.SyncStatus != pagesSourceStatusIdle || runtime.LastAppliedRevision != target.Revision {
|
|
t.Errorf("confirmed replacement runtime = status:%q applied:%q, want idle/%q", runtime.SyncStatus, runtime.LastAppliedRevision, target.Revision)
|
|
}
|
|
}
|
|
|
|
func TestSourceActionPayloadSeparatesSystemTargetAndUserConfirmation(t *testing.T) {
|
|
handler := &SourceActionHandler{}
|
|
revision := strings.Repeat("a", 64)
|
|
invalid := []SourceActionPayload{
|
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TargetRevision: revision},
|
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TriggerType: pagesSourceTriggerManualSync},
|
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, ConfirmedRevision: revision},
|
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TargetRevision: revision, ConfirmedRevision: revision},
|
|
}
|
|
for _, payload := range invalid {
|
|
raw, _ := json.Marshal(payload)
|
|
if normalized, err := handler.ValidatePayload(raw); err == nil {
|
|
t.Errorf("ValidatePayload(%+v) = %s, nil; want error", payload, normalized)
|
|
}
|
|
}
|
|
valid := []SourceActionPayload{
|
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: pagesSourceCreatedBySystem, TriggerType: pagesSourceTriggerScheduledAutoUpdate, TargetRevision: revision},
|
|
{SourceID: 1, ConfigVersion: 1, Action: sourceActionSync, Actor: "user:1", TriggerType: pagesSourceTriggerManualSync, ConfirmedRevision: revision},
|
|
}
|
|
for _, payload := range valid {
|
|
raw, _ := json.Marshal(payload)
|
|
if _, err := handler.ValidatePayload(raw); err != nil {
|
|
t.Errorf("ValidatePayload(%+v) error = %v, want nil", payload, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestGitHubProviderErrorsMapToSafeRetryClassification(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
provider error
|
|
want string
|
|
permanent bool
|
|
skipRetry bool
|
|
}{
|
|
{
|
|
name: "asset missing", provider: &githubrelease.Error{Kind: githubrelease.ErrAssetNotFound, StatusCode: 200},
|
|
want: errPagesSourceReleaseNotFound, permanent: true, skipRetry: true,
|
|
},
|
|
{
|
|
name: "digest mismatch", provider: &githubrelease.Error{Kind: githubrelease.ErrDigestMismatch, StatusCode: 200},
|
|
want: errPagesSourceDigestMismatch, permanent: true, skipRetry: true,
|
|
},
|
|
{
|
|
name: "rate limit", provider: &githubrelease.Error{
|
|
Kind: githubrelease.ErrMetadata, StatusCode: 429,
|
|
RetryAt: func() *time.Time { value := time.Now().Add(time.Hour); return &value }(),
|
|
},
|
|
want: errPagesSourceSyncFailed, permanent: false, skipRetry: true,
|
|
},
|
|
{
|
|
name: "network", provider: &githubrelease.Error{Kind: githubrelease.ErrDownload},
|
|
want: errPagesSourceSyncFailed, permanent: false, skipRetry: false,
|
|
},
|
|
{
|
|
name: "forbidden without retry", provider: &githubrelease.Error{Kind: githubrelease.ErrMetadata, StatusCode: 403},
|
|
want: errPagesSourceSyncFailed, permanent: true, skipRetry: true,
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
domainErr := githubSourceDomainError(test.provider)
|
|
if got := domainErr.Error(); got != test.want {
|
|
t.Errorf("githubSourceDomainError() = %q, want %q", got, test.want)
|
|
}
|
|
var typedDomainError *githubSourceProviderDomainError
|
|
if !errors.As(domainErr, &typedDomainError) {
|
|
t.Fatalf("githubSourceDomainError() type = %T, want *githubSourceProviderDomainError", domainErr)
|
|
}
|
|
if got := typedDomainError.permanent; got != test.permanent {
|
|
t.Errorf("githubSourceProviderDomainError.permanent = %t, want %t", got, test.permanent)
|
|
}
|
|
if got := shouldSkipGitHubActionRetry(domainErr); got != test.skipRetry {
|
|
t.Errorf("shouldSkipGitHubActionRetry() = %t, want %t", got, test.skipRetry)
|
|
}
|
|
if strings.Contains(domainErr.Error(), "status=") || strings.Contains(domainErr.Error(), "repo=") {
|
|
t.Errorf("githubSourceDomainError() = %q, want stable Pages message", domainErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGitHubSyncRejectsStaleConfirmationWithoutChangingActive(t *testing.T) {
|
|
ctx := setupPagesSourceSyncTest(t)
|
|
project := mustCreatePagesSourceProject(t, ctx, "github-confirm-stale")
|
|
oldActive := mustCreateActiveManualDeployment(t, ctx, project.ID, "old")
|
|
source, _ := mustConfigureGitHubSourceWithoutDispatch(t, ctx, project.ID, SourceUpdateInput{
|
|
SourceType: PagesSourceTypeGitHubRelease,
|
|
RepositoryURL: "https://github.com/a/b",
|
|
})
|
|
asset := githubrelease.Asset{ID: "2", Name: "dist.zip", State: "uploaded", UpdatedAt: time.Now().UTC()}
|
|
useFakeGitHubReleaseClient(t, &fakeGitHubReleaseClient{
|
|
resolve: func(context.Context, githubrelease.ResolveRequest) (githubrelease.ResolveResult, error) {
|
|
return githubrelease.ResolveResult{Release: githubrelease.Release{ID: "1", Tag: "v1"}, Asset: asset}, nil
|
|
},
|
|
download: func(context.Context, githubrelease.DownloadRequest) (*githubrelease.DownloadResult, error) {
|
|
t.Fatal("Download called for stale confirmation")
|
|
return nil, nil
|
|
},
|
|
})
|
|
snapshot, _, _ := acquireSourceLease(ctx, source.ID, source.ConfigVersion, sourceActionSync)
|
|
_, err := syncGitHubSource(ctx, snapshot, "user:1", "", strings.Repeat("f", 64))
|
|
if err == nil || err.Error() != errPagesSourceConfirmationStale {
|
|
t.Errorf("syncGitHubSource(stale confirmation) error = %v, want %q", err, errPagesSourceConfirmationStale)
|
|
}
|
|
storedProject, loadErr := model.GetPagesProjectByID(ctx, project.ID)
|
|
if loadErr != nil {
|
|
t.Fatalf("GetPagesProjectByID() error = %v, want nil", loadErr)
|
|
}
|
|
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != oldActive.ID {
|
|
t.Errorf("ActiveDeploymentID = %v, want old active %d", storedProject.ActiveDeploymentID, oldActive.ID)
|
|
}
|
|
}
|