Files
OpenFlare/backend/openflare/plugins/server/domain/pages/source_test.go
T

376 lines
14 KiB
Go

// Copyright 2026 Arctel.net
// SPDX-License-Identifier: Apache-2.0
package pages
import (
"context"
"fmt"
"strings"
"testing"
"time"
"Wavelet/openflare/plugins/server/kernel/repository"
"Wavelet/openflare/plugins/server/kernel/model"
)
func setupPagesSourceTest(t *testing.T) context.Context {
t.Helper()
cleanup := setupPagesTestDB(t)
t.Cleanup(cleanup)
sqlDB, err := repository.DB(t.Context()).DB()
if err != nil {
t.Fatalf("repository.DB().DB() error = %v, want nil", err)
}
// SQLite :memory: is scoped to one connection. Keeping one connection also
// makes lease tests exercise the production CAS without creating empty
// per-connection databases.
sqlDB.SetMaxOpenConns(1)
return t.Context()
}
func TestRevisionViewReadsLegacySourceDetailLabel(t *testing.T) {
tests := []struct {
name string
detail string
want string
}{
{
name: "remote",
detail: `{"provider":"remote_url","label":"legacy.zip"}`,
want: "legacy.zip",
},
{
name: "github",
detail: `{"provider":"github","label":"v1.2.3","asset_name":"dist.zip"}`,
want: "v1.2.3",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
view := revisionView(strings.Repeat("a", 64), test.detail)
if view.Label != test.want {
t.Errorf("revisionView(%s).Label = %q, want %q", test.name, view.Label, test.want)
}
})
}
}
func mustCreatePagesSourceProject(t *testing.T, ctx context.Context, slug string) *model.PagesProject {
t.Helper()
view, err := CreateProject(ctx, Input{
Name: "Source " + slug,
Slug: slug,
Enabled: true,
EntryFile: "index.html",
})
if err != nil {
t.Fatalf("CreateProject(%q) error = %v, want nil", slug, err)
}
project, err := repository.GetPagesProjectByID(ctx, view.ID)
if err != nil {
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", view.ID, err)
}
return project
}
func mustConfigureRemoteSource(
t *testing.T,
ctx context.Context,
projectID uint,
remoteURL string,
allowInsecure bool,
) (*model.PagesProjectSource, *model.PagesProjectSourceRuntime) {
t.Helper()
_, err := UpdateSource(ctx, projectID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: remoteURL,
AllowInsecure: allowInsecure,
})
if err != nil {
t.Fatalf("UpdateSource(%d, %q) error = %v, want nil", projectID, remoteURL, err)
}
var source model.PagesProjectSource
if err := repository.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 := repository.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 TestValidateRemoteSourceInputRejectsModeIncompatibleFields(t *testing.T) {
tests := []struct {
name string
input SourceUpdateInput
}{
{
name: "missing source type",
input: SourceUpdateInput{
RemoteURL: "https://example.com/site.zip",
},
},
{
name: "remote rejects repository field",
input: SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: "https://example.com/site.zip",
RepositoryURL: "https://github.com/example/site",
},
},
{
name: "remote rejects automatic updates",
input: SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: "https://example.com/site.zip",
AutoUpdateEnabled: true,
},
},
{
name: "missing remote url",
input: SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if err := validateRemoteSourceInput(test.input); err == nil {
t.Errorf("validateRemoteSourceInput(%+v) error = nil, want non-nil", test.input)
}
})
}
}
func TestUpdateSourceNewRemoteRequiresExplicitURL(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "remote-requires-url")
_, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
})
if err == nil {
t.Fatal("UpdateSource(new remote without URL) error = nil, want non-nil")
}
if got, want := err.Error(), errPagesSourceRemoteURLRequired; got != want {
t.Errorf("UpdateSource(new remote without URL) error = %q, want %q", got, want)
}
}
func TestRemoteSourceCRUDPreservesSecretAndResetsRuntimeByIdentity(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "remote-crud")
firstURL := "https://Artifacts.Example.com:443/dist/site.zip?token=first-secret&expires=1"
source, runtime := mustConfigureRemoteSource(t, ctx, project.ID, firstURL, false)
if got, want := source.ConfigVersion, 1; got != want {
t.Errorf("new source ConfigVersion = %d, want %d", got, want)
}
if got, want := runtime.SyncStatus, pagesSourceStatusIdle; got != want {
t.Errorf("new runtime SyncStatus = %q, want %q", got, want)
}
view, err := GetSource(ctx, project.ID)
if err != nil {
t.Fatalf("GetSource(%d) error = %v, want nil", project.ID, err)
}
if got, want := view.RemoteURL, firstURL; got != want {
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
}
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: firstURL,
}); err != nil {
t.Fatalf("UpdateSource(%d, no-op) error = %v, want nil", project.ID, err)
}
var unchangedSource model.PagesProjectSource
if err := repository.DB(ctx).Where("id = ?", source.ID).First(&unchangedSource).Error; err != nil {
t.Fatalf("load no-op source error = %v, want nil", err)
}
if got, want := unchangedSource.ConfigVersion, source.ConfigVersion; got != want {
t.Errorf("no-op source ConfigVersion = %d, want unchanged %d", got, want)
}
seenRevision := strings.Repeat("a", 64)
appliedRevision := strings.Repeat("b", 64)
future := time.Now().Add(time.Hour)
if err := repository.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).
Where("source_id = ?", source.ID).
Updates(map[string]any{
"last_seen_revision": seenRevision,
"last_seen_detail": `{"provider":"remote_url","display_name":"new.zip"}`,
"last_applied_revision": appliedRevision,
"last_applied_detail": `{"provider":"remote_url","display_name":"old.zip"}`,
"sync_status": pagesSourceStatusSyncing,
"lease_token": "in-flight",
"lease_expires_at": &future,
}).Error; err != nil {
t.Fatalf("seed source runtime error = %v, want nil", err)
}
// Keep the same URL while enabling insecure TLS. The identity and cursor must
// survive, while the in-flight lease is fenced.
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: firstURL,
AllowInsecure: true,
}); err != nil {
t.Fatalf("UpdateSource(%d, preserve URL) error = %v, want nil", project.ID, err)
}
preservedSource, preservedRuntime, err := loadSourceByProject(ctx, project.ID)
if err != nil {
t.Fatalf("loadSourceByProject(%d) error = %v, want nil", project.ID, err)
}
if got, want := preservedSource.RemoteURL, firstURL; got != want {
t.Errorf("preserved RemoteURL = %q, want %q", got, want)
}
if got, want := preservedSource.ConfigVersion, 2; got != want {
t.Errorf("preserved source ConfigVersion = %d, want %d", got, want)
}
if got, want := preservedSource.SourceIdentity, source.SourceIdentity; got != want {
t.Errorf("preserved source identity = %q, want %q", got, want)
}
if got, want := preservedRuntime.LastSeenRevision, seenRevision; got != want {
t.Errorf("preserved LastSeenRevision = %q, want %q", got, want)
}
if got, want := preservedRuntime.SyncStatus, pagesSourceStatusUpdateAvailable; got != want {
t.Errorf("preserved runtime SyncStatus = %q, want %q", got, want)
}
if preservedRuntime.LeaseToken != "" || preservedRuntime.LeaseExpiresAt != nil {
t.Errorf("preserved runtime lease = (%q, %v), want cleared", preservedRuntime.LeaseToken, preservedRuntime.LeaseExpiresAt)
}
// Replacing only the query secret keeps the canonical identity and cursors.
queryReplacementURL := "https://artifacts.example.com/dist/site.zip?token=second-secret"
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: queryReplacementURL,
AllowInsecure: true,
}); err != nil {
t.Fatalf("UpdateSource(%d, query replacement) error = %v, want nil", project.ID, err)
}
querySource, queryRuntime, err := loadSourceByProject(ctx, project.ID)
if err != nil {
t.Fatalf("loadSourceByProject(%d) after query replacement error = %v, want nil", project.ID, err)
}
if got, want := querySource.SourceIdentity, source.SourceIdentity; got != want {
t.Errorf("query replacement identity = %q, want %q", got, want)
}
if got, want := queryRuntime.LastSeenRevision, seenRevision; got != want {
t.Errorf("query replacement LastSeenRevision = %q, want %q", got, want)
}
// Replacing the path changes identity and clears all remote cursors.
pathReplacementURL := "https://artifacts.example.com/dist/other.zip?token=third-secret"
if _, err := UpdateSource(ctx, project.ID, SourceUpdateInput{
SourceType: PagesSourceTypeRemoteURL,
RemoteURL: pathReplacementURL,
AllowInsecure: true,
}); err != nil {
t.Fatalf("UpdateSource(%d, path replacement) error = %v, want nil", project.ID, err)
}
pathSource, pathRuntime, err := loadSourceByProject(ctx, project.ID)
if err != nil {
t.Fatalf("loadSourceByProject(%d) after path replacement error = %v, want nil", project.ID, err)
}
if pathSource.SourceIdentity == source.SourceIdentity {
t.Errorf("path replacement identity = %q, want a new identity", pathSource.SourceIdentity)
}
if pathRuntime.LastSeenRevision != "" || pathRuntime.LastAppliedRevision != "" {
t.Errorf("path replacement cursors = (%q, %q), want empty", pathRuntime.LastSeenRevision, pathRuntime.LastAppliedRevision)
}
if got, want := pathRuntime.SyncStatus, pagesSourceStatusIdle; got != want {
t.Errorf("path replacement SyncStatus = %q, want %q", got, want)
}
pathView, err := GetSource(ctx, project.ID)
if err != nil {
t.Fatalf("GetSource(%d) after path replacement error = %v, want nil", project.ID, err)
}
if got, want := pathView.RemoteURL, pathReplacementURL; got != want {
t.Errorf("GetSource(%d).RemoteURL = %q, want %q", project.ID, got, want)
}
}
func TestRemoteSourceIdentityIgnoresQueryAndNormalizesDefaultPort(t *testing.T) {
first, err := parseRemoteSourceURL("HTTPS://Artifacts.Example.com:443/dist/../dist/site.zip?token=one")
if err != nil {
t.Fatalf("parseRemoteSourceURL(first) error = %v, want nil", err)
}
second, err := parseRemoteSourceURL("https://artifacts.example.com/dist/site.zip?token=two")
if err != nil {
t.Fatalf("parseRemoteSourceURL(second) error = %v, want nil", err)
}
if got, want := remoteSourceIdentity(first), remoteSourceIdentity(second); got != want {
t.Errorf("remoteSourceIdentity(first) = %q, want %q", got, want)
}
}
func TestDeleteSourceIsIdempotentAndKeepsDeploymentState(t *testing.T) {
ctx := setupPagesSourceTest(t)
project := mustCreatePagesSourceProject(t, ctx, "source-delete")
source, _ := mustConfigureRemoteSource(
t,
ctx,
project.ID,
"https://example.com/site.zip?token=delete-secret",
false,
)
deployment := &model.PagesDeployment{
ProjectID: project.ID,
DeploymentNumber: 1,
Checksum: strings.Repeat("c", 64),
Status: model.PagesDeploymentStatusActive,
CreatedBy: "user:1",
SourceType: "manual_upload",
TriggerType: "manual_upload",
}
if err := repository.DB(ctx).Create(deployment).Error; err != nil {
t.Fatalf("create deployment error = %v, want nil", err)
}
if err := repository.DB(ctx).Model(&model.PagesProject{}).
Where("id = ?", project.ID).
Update("active_deployment_id", deployment.ID).Error; err != nil {
t.Fatalf("set active deployment error = %v, want nil", err)
}
for attempt := 1; attempt <= 2; attempt++ {
view, err := DeleteSource(ctx, project.ID)
if err != nil {
t.Fatalf("DeleteSource(%d), attempt %d error = %v, want nil", project.ID, attempt, err)
}
if got, want := view.SourceType, PagesSourceTypeManual; got != want {
t.Errorf("DeleteSource(%d), attempt %d SourceType = %q, want %q", project.ID, attempt, got, want)
}
}
var sourceCount, runtimeCount, deploymentCount int64
if err := repository.DB(ctx).Model(&model.PagesProjectSource{}).Where("id = ?", source.ID).Count(&sourceCount).Error; err != nil {
t.Fatalf("count source error = %v, want nil", err)
}
if err := repository.DB(ctx).Model(&model.PagesProjectSourceRuntime{}).Where("source_id = ?", source.ID).Count(&runtimeCount).Error; err != nil {
t.Fatalf("count runtime error = %v, want nil", err)
}
if err := repository.DB(ctx).Model(&model.PagesDeployment{}).Where("id = ?", deployment.ID).Count(&deploymentCount).Error; err != nil {
t.Fatalf("count deployment error = %v, want nil", err)
}
if sourceCount != 0 || runtimeCount != 0 || deploymentCount != 1 {
t.Errorf("DeleteSource counts = source:%d runtime:%d deployment:%d, want 0, 0, 1", sourceCount, runtimeCount, deploymentCount)
}
storedProject, err := repository.GetPagesProjectByID(ctx, project.ID)
if err != nil {
t.Fatalf("GetPagesProjectByID(%d) error = %v, want nil", project.ID, err)
}
if storedProject.ActiveDeploymentID == nil || *storedProject.ActiveDeploymentID != deployment.ID {
t.Errorf("active deployment = %v, want %d", storedProject.ActiveDeploymentID, deployment.ID)
}
manual, err := GetSource(ctx, project.ID)
if err != nil {
t.Fatalf("GetSource(%d) after delete error = %v, want nil", project.ID, err)
}
if got, want := fmt.Sprint(manual.SourceType), PagesSourceTypeManual; got != want {
t.Errorf("GetSource(%d).SourceType = %q, want %q", project.ID, got, want)
}
}