Files
ThothII/tools/tht/internal/backup/manifest_test.go

133 lines
5.0 KiB
Go

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")
}
}