package backup import ( "encoding/json" "strings" "testing" "time" ) const testRevision = "0123456789abcdef0123456789abcdef01234567" func TestManifestFinalizationNormalizesAndOrdersEntriesDeterministically(t *testing.T) { manifest := Manifest{ SchemaVersion: CurrentSchemaVersion, InstallationID: "local-dev", CreatedAt: time.Date(2026, 8, 16, 10, 11, 12, 123, time.FixedZone("test", 2*60*60)), SourceRevision: testRevision, ComposeProject: "thothii-test", Entries: []Entry{ {Path: `volumes\sessions.tar`, Kind: EntryVolume, Owner: "volume:sessions", SHA256: DigestBytes([]byte("sessions")), Size: 8, Archived: true}, {Path: "configuration/pi/models.json", Kind: EntryFile, Owner: "pi", SHA256: DigestBytes([]byte("models")), Size: 6, Archived: true}, }, Images: []ImageIdentity{ {Service: "frontend", Reference: "frontend:test", ID: "sha256:front"}, {Service: "core", Reference: "core:test", ID: "sha256:core"}, }, Volumes: []VolumeMetadata{ {LogicalName: "sessions", Name: "thothii-test_sessions", Driver: "local"}, {LogicalName: "pi-state", Name: "thothii-test_pi-state", Driver: "local"}, }, } if err := manifest.Finalize(); err != nil { t.Fatal(err) } if got, want := manifest.CreatedAt, time.Date(2026, 8, 16, 8, 11, 12, 123, time.UTC); !got.Equal(want) || got.Location() != time.UTC { t.Fatalf("CreatedAt = %v (%v), want %v UTC", got, got.Location(), want) } if got := []string{manifest.Entries[0].Path, manifest.Entries[1].Path}; got[0] != "configuration/pi/models.json" || got[1] != "volumes/sessions.tar" { t.Fatalf("entry order = %v", got) } if manifest.Images[0].Service != "core" || manifest.Volumes[0].LogicalName != "pi-state" { t.Fatalf("metadata was not deterministically ordered: images=%v volumes=%v", manifest.Images, manifest.Volumes) } first, err := manifest.JSON() if err != nil { t.Fatal(err) } second, err := manifest.JSON() if err != nil { t.Fatal(err) } if string(first) != string(second) { t.Fatalf("manifest encoding is not deterministic:\n%s\n%s", first, second) } } func TestManifestRejectsUnsafePathsInvalidChecksumsAndUnsupportedSchemas(t *testing.T) { valid := Manifest{ SchemaVersion: CurrentSchemaVersion, InstallationID: "local-dev", CreatedAt: time.Now().UTC(), SourceRevision: testRevision, ComposeProject: "thothii-test", Entries: []Entry{{Path: "configuration/operator.env", Kind: EntryFile, Owner: "installation", SHA256: DigestBytes(nil), Archived: true}}, } for _, mutate := range []func(*Manifest){ func(value *Manifest) { value.SchemaVersion = CurrentSchemaVersion + 1 }, func(value *Manifest) { value.Entries[0].Path = "../outside" }, func(value *Manifest) { value.Entries[0].Path = "/absolute" }, func(value *Manifest) { value.Entries[0].SHA256 = "sha256:not-a-digest" }, func(value *Manifest) { value.Entries = append(value.Entries, value.Entries[0]) }, } { candidate := valid candidate.Entries = append([]Entry(nil), valid.Entries...) mutate(&candidate) if err := candidate.Finalize(); err == nil { t.Fatalf("Finalize() accepted invalid manifest: %#v", candidate) } } } func TestManifestSecretMarkerRequiresIncludedExternalSecretPayload(t *testing.T) { base := Manifest{ SchemaVersion: CurrentSchemaVersion, InstallationID: "local-dev", CreatedAt: time.Now().UTC(), SourceRevision: testRevision, ComposeProject: "thothii-test", Entries: []Entry{ {Path: "volumes/workspace-secrets.tar", Kind: EntryVolume, Owner: "volume:workspace-secrets", SHA256: DigestBytes([]byte("managed")), Size: 7, Archived: true, Sensitive: true}, {Path: "external-secrets/000", Kind: EntrySecretReference, Owner: "external-secret", SHA256: DigestBytes([]byte("external")), Size: 8, Archived: false, Sensitive: true, SourcePath: "/protected/secret"}, }, } if err := base.Finalize(); err != nil { t.Fatalf("managed workspace secrets and an excluded external reference must be valid: %v", err) } base.IncludesSecrets = true if err := base.Finalize(); err == nil || !strings.Contains(err.Error(), "external secret") { t.Fatalf("Finalize() error = %v, want missing included external secret", err) } for index := range base.Entries { if base.Entries[index].Kind == EntrySecretReference { base.Entries[index].Kind = EntryExternalSecret base.Entries[index].Archived = true base.Entries[index].Path = "external-secrets/000-secret" } } if err := base.Finalize(); err != nil { t.Fatalf("included external secret marker was rejected: %v", err) } } func TestDecodeManifestRejectsASecondDocument(t *testing.T) { manifest := Manifest{ SchemaVersion: CurrentSchemaVersion, InstallationID: "local-dev", CreatedAt: time.Date(2026, 8, 16, 8, 11, 12, 0, time.UTC), SourceRevision: testRevision, ComposeProject: "thothii-test", } encoded, err := json.Marshal(manifest) if err != nil { t.Fatal(err) } withSecondDocument := append(append([]byte(nil), encoded...), append([]byte(" \n"), encoded...)...) if _, err := DecodeManifest(withSecondDocument); err == nil { t.Fatal("DecodeManifest() accepted a second JSON document") } }