372 lines
16 KiB
Go
372 lines
16 KiB
Go
package artifacts
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
|
)
|
|
|
|
func TestRemoteCommitManifestRejectsUnsupportedVersion(t *testing.T) {
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
pointer := testCommitPointer(t, testCurrentSessionPrefix(), commit)
|
|
commit.FormatVersion++
|
|
if _, err := EncodeRemoteCommitManifest(commit); err == nil || !strings.Contains(err.Error(), "unsupported remote commit format version") {
|
|
t.Fatalf("EncodeRemoteCommitManifest() error = %v, want unsupported version", err)
|
|
}
|
|
|
|
pointer.FormatVersion++
|
|
if _, err := EncodeCurrentCommitPointer(pointer); err == nil || !strings.Contains(err.Error(), "unsupported current commit pointer format version") {
|
|
t.Fatalf("EncodeCurrentCommitPointer() error = %v, want unsupported version", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoteCommitDecodersRejectUnknownFields(t *testing.T) {
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
commitData, err := EncodeRemoteCommitManifest(commit)
|
|
if err != nil {
|
|
t.Fatalf("EncodeRemoteCommitManifest() error = %v", err)
|
|
}
|
|
commitData = appendUnknownJSONField(t, commitData)
|
|
if _, err := DecodeRemoteCommitManifest(commitData); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
|
t.Fatalf("DecodeRemoteCommitManifest() error = %v, want unknown-field rejection", err)
|
|
}
|
|
|
|
pointer := testCommitPointer(t, testCurrentSessionPrefix(), commit)
|
|
pointerData, err := EncodeCurrentCommitPointer(pointer)
|
|
if err != nil {
|
|
t.Fatalf("EncodeCurrentCommitPointer() error = %v", err)
|
|
}
|
|
pointerData = appendUnknownJSONField(t, pointerData)
|
|
if _, err := DecodeCurrentCommitPointer(pointerData); err == nil || !strings.Contains(err.Error(), "unknown field") {
|
|
t.Fatalf("DecodeCurrentCommitPointer() error = %v, want unknown-field rejection", err)
|
|
}
|
|
}
|
|
|
|
func TestParseImmutableRunObjectKeyAcceptsSameSessionRuns(t *testing.T) {
|
|
prefix := testCurrentSessionPrefix()
|
|
for _, runID := range []string{"20260519T010203Z-a1b2c3d4", "20260401T010203Z-11223344"} {
|
|
key := S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "published/artifacts/recap.md")
|
|
identity, err := ParseImmutableRunObjectKey(prefix, key)
|
|
if err != nil {
|
|
t.Fatalf("ParseImmutableRunObjectKey(%q) error = %v", runID, err)
|
|
}
|
|
if identity.RunID != runID || identity.RelativePath != "published/artifacts/recap.md" {
|
|
t.Fatalf("ParseImmutableRunObjectKey(%q) = %#v", runID, identity)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseImmutableRunObjectKeyRejectsUnsafeOrMutableKeys(t *testing.T) {
|
|
prefix := testCurrentSessionPrefix()
|
|
for _, key := range []string{
|
|
prefix + "current/commit-pointer.json",
|
|
prefix + "locks/source.lock",
|
|
prefix + "runs//published/recap.md",
|
|
prefix + "runs/20260519T010203Z-a1b2c3d4",
|
|
prefix + "runs/20260519T010203Z-a1b2c3d4/published/../recap.md",
|
|
"dnd/campaigns/other/sessions/2026-05-03/runs/20260519T010203Z-a1b2c3d4/published/recap.md",
|
|
} {
|
|
if _, err := ParseImmutableRunObjectKey(prefix, key); err == nil {
|
|
t.Fatalf("ParseImmutableRunObjectKey(%q) error = nil, want rejection", key)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRemoteCommitAllowsPublishedOutputFromEarlierSameSessionRunOnly(t *testing.T) {
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
prefix := testCurrentSessionPrefix()
|
|
published := RemoteArtifact{
|
|
Type: RemoteArtifactTypePublishedOutput, Source: "narratio.artifact.recap",
|
|
DestinationKey: S3RunRelativeDestinationKey(S3RunPrefix(prefix, "20260401T010203Z-11223344"), "published/artifacts/recap.md"),
|
|
SHA256: strings.Repeat("a", 64), Size: 1, Generation: "older-generation",
|
|
}
|
|
commit.Artifacts = append(commit.Artifacts, published)
|
|
if err := commit.ValidateForSessionPrefix(prefix); err != nil {
|
|
t.Fatalf("ValidateForSessionPrefix() error = %v", err)
|
|
}
|
|
|
|
commit.Artifacts[1].DestinationKey = "dnd/campaigns/other/sessions/2026-05-03/runs/20260401T010203Z-11223344/published/artifacts/recap.md"
|
|
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
|
t.Fatal("ValidateForSessionPrefix() cross-session error = nil")
|
|
}
|
|
commit.Artifacts[1].DestinationKey = published.DestinationKey
|
|
commit.Artifacts[1].Type = RemoteArtifactTypeRunFile
|
|
if err := commit.ValidateForSessionPrefix(prefix); err == nil {
|
|
t.Fatal("ValidateForSessionPrefix() earlier non-published object error = nil")
|
|
}
|
|
}
|
|
|
|
func TestRemoteCommitRejectsEffectivePublishedDestinationCollisionAcrossRuns(t *testing.T) {
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
prefix := testCurrentSessionPrefix()
|
|
for index, runID := range []string{commit.RunID, "20260401T010203Z-11223344"} {
|
|
commit.Artifacts = append(commit.Artifacts, RemoteArtifact{
|
|
Type: RemoteArtifactTypePublishedOutput, Source: fmt.Sprintf("narratio.artifact.recap_%d", index),
|
|
DestinationKey: S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "published/artifacts/recap.md"),
|
|
SHA256: strings.Repeat("a", 64), Size: 1, Generation: fmt.Sprintf("generation-%d", index),
|
|
})
|
|
}
|
|
if err := commit.ValidateForSessionPrefix(prefix); err == nil || !strings.Contains(err.Error(), "duplicate published output destination") {
|
|
t.Fatalf("ValidateForSessionPrefix() error = %v, want effective destination collision", err)
|
|
}
|
|
}
|
|
|
|
func TestRemoteCommitDecoderAcceptsHistoricalRunManifestArtifact(t *testing.T) {
|
|
data := []byte(`{"format_version":1,"campaign":"sample-campaign","session_id":"2026-05-03","run_id":"20260519T010203Z-a1b2c3d4","artifacts":[{"type":"run_manifest","source":"run:manifest.json","destination_key":"sample-campaign/2026-05-03/runs/20260519T010203Z-a1b2c3d4/manifest.json","sha256":"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef","size":1,"generation":"historical"}]}`)
|
|
|
|
commit, err := DecodeRemoteCommitManifest(data)
|
|
if err != nil {
|
|
t.Fatalf("DecodeRemoteCommitManifest() error = %v", err)
|
|
}
|
|
if got := commit.Artifacts[0].Type; got != RemoteArtifactTypeRunManifest {
|
|
t.Fatalf("artifact type = %q, want %q", got, RemoteArtifactTypeRunManifest)
|
|
}
|
|
}
|
|
|
|
func TestLoadCurrentStateReadsVerifiedRemoteCommit(t *testing.T) {
|
|
store := &storage.FakeBackend{}
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
seedRemoteCommit(t, store, testCurrentSessionPrefix(), commit, "commit-generation", "manifest-generation")
|
|
|
|
state, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{
|
|
ExpectedCampaign: commit.Campaign,
|
|
ExpectedSessionID: commit.SessionID,
|
|
ValidateRunID: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("LoadCurrentState() error = %v", err)
|
|
}
|
|
if state.Commit == nil || state.Pointer == nil {
|
|
t.Fatalf("state does not expose selected immutable commit: %#v", state)
|
|
}
|
|
if state.CurrentPointerKey != S3CurrentCommitPointerKey(testCurrentSessionPrefix()) {
|
|
t.Fatalf("CurrentPointerKey = %q", state.CurrentPointerKey)
|
|
}
|
|
if state.Manifest.RunID != commit.RunID {
|
|
t.Fatalf("manifest run ID = %q, want %q", state.Manifest.RunID, commit.RunID)
|
|
}
|
|
if len(store.Downloads) != 0 {
|
|
t.Fatalf("committed current-state downloads = %#v, want direct bounded reads", store.Downloads)
|
|
}
|
|
wantReads := []string{state.CurrentPointerKey, state.Pointer.CommitKey, state.CurrentManifestKey}
|
|
if len(store.Reads) != len(wantReads) {
|
|
t.Fatalf("committed current-state reads = %#v, want %q", store.Reads, wantReads)
|
|
}
|
|
for index, want := range wantReads {
|
|
if store.Reads[index].Key != want {
|
|
t.Fatalf("committed current-state read %d = %q, want %q", index, store.Reads[index].Key, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadCurrentStateUsesMetadataFromOpenedObjectVersion(t *testing.T) {
|
|
store := &storage.FakeBackend{ListErr: errors.New("list must not be used for object verification")}
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
seedRemoteCommit(t, store, testCurrentSessionPrefix(), commit, "commit-generation", "manifest-generation")
|
|
|
|
if _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}); err != nil {
|
|
t.Fatalf("LoadCurrentState() error = %v, want verification from Read metadata", err)
|
|
}
|
|
}
|
|
|
|
func TestDecodeCommittedManifestGuidesRemoteRecovery(t *testing.T) {
|
|
_, err := decodeCommittedManifest(context.Background(), []byte(`{"session_id":"2026-05-03"}`))
|
|
if err == nil {
|
|
t.Fatal("decodeCommittedManifest() error = nil, want unsupported schema failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "schema_version is required") || !strings.Contains(err.Error(), "republish this session") {
|
|
t.Fatalf("decodeCommittedManifest() error = %v, want schema and republish guidance", err)
|
|
}
|
|
if strings.Contains(err.Error(), "local state") {
|
|
t.Fatalf("decodeCommittedManifest() error = %v, must not describe remote state as local", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadCurrentStateRejectsPointerCommitIdentityMismatch(t *testing.T) {
|
|
store := &storage.FakeBackend{}
|
|
prefix := testCurrentSessionPrefix()
|
|
pointerRunID := "20260519T010203Z-a1b2c3d4"
|
|
commit := testRemoteCommit(t, "20260520T010203Z-a1b2c3d4")
|
|
commitData, err := EncodeRemoteCommitManifest(commit)
|
|
if err != nil {
|
|
t.Fatalf("EncodeRemoteCommitManifest() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: S3RunCommitKey(prefix, pointerRunID), Data: commitData, ETag: "commit-generation"})
|
|
pointer := CurrentCommitPointer{
|
|
FormatVersion: RemoteCommitFormatVersion,
|
|
Campaign: commit.Campaign,
|
|
SessionID: commit.SessionID,
|
|
RunID: pointerRunID,
|
|
CommitKey: S3RunCommitKey(prefix, pointerRunID),
|
|
CommitSHA256: remoteObjectSHA256(commitData),
|
|
CommitSize: int64(len(commitData)),
|
|
CommitGeneration: "commit-generation",
|
|
}
|
|
pointerData, err := EncodeCurrentCommitPointer(pointer)
|
|
if err != nil {
|
|
t.Fatalf("EncodeCurrentCommitPointer() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: S3CurrentCommitPointerKey(prefix), Data: pointerData})
|
|
|
|
_, err = LoadCurrentState(context.Background(), store, prefix, CurrentStateValidation{})
|
|
if err == nil || !strings.Contains(err.Error(), "does not match selected remote commit") {
|
|
t.Fatalf("error = %v, want pointer/commit identity mismatch", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadCurrentStateRejectsCommitManifestIdentityMismatch(t *testing.T) {
|
|
store := &storage.FakeBackend{}
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
pointer := seedRemoteCommit(t, store, testCurrentSessionPrefix(), commit, "commit-generation", "manifest-generation")
|
|
|
|
wrongManifest := testManifestJSON(t, "2026-05-03", "wrong-campaign", commit.RunID)
|
|
store.SeedObject(storage.FakeObject{
|
|
Key: commit.Artifacts[0].DestinationKey,
|
|
Data: wrongManifest,
|
|
ETag: "manifest-generation",
|
|
})
|
|
commit.Artifacts[0].SHA256 = remoteObjectSHA256(wrongManifest)
|
|
commit.Artifacts[0].Size = int64(len(wrongManifest))
|
|
commitData, err := EncodeRemoteCommitManifest(commit)
|
|
if err != nil {
|
|
t.Fatalf("EncodeRemoteCommitManifest() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: pointer.CommitKey, Data: commitData, ETag: "commit-generation"})
|
|
pointer.CommitSHA256 = remoteObjectSHA256(commitData)
|
|
pointer.CommitSize = int64(len(commitData))
|
|
pointerData, err := EncodeCurrentCommitPointer(*pointer)
|
|
if err != nil {
|
|
t.Fatalf("EncodeCurrentCommitPointer() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: S3CurrentCommitPointerKey(testCurrentSessionPrefix()), Data: pointerData})
|
|
|
|
_, err = LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{})
|
|
if err == nil || !strings.Contains(err.Error(), "does not match committed session manifest") {
|
|
t.Fatalf("error = %v, want commit/manifest identity mismatch", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadCurrentStateRejectsCommitChecksumOrGenerationMismatch(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
mutate func(*CurrentCommitPointer)
|
|
want string
|
|
}{
|
|
{
|
|
name: "checksum",
|
|
mutate: func(pointer *CurrentCommitPointer) {
|
|
pointer.CommitSHA256 = strings.Repeat("0", 64)
|
|
},
|
|
want: "checksum mismatch",
|
|
},
|
|
{
|
|
name: "generation",
|
|
mutate: func(pointer *CurrentCommitPointer) {
|
|
pointer.CommitGeneration = "stale-generation"
|
|
},
|
|
want: "generation mismatch",
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
store := &storage.FakeBackend{}
|
|
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
|
pointer := seedRemoteCommit(t, store, testCurrentSessionPrefix(), commit, "commit-generation", "manifest-generation")
|
|
test.mutate(pointer)
|
|
pointerData, err := EncodeCurrentCommitPointer(*pointer)
|
|
if err != nil {
|
|
t.Fatalf("EncodeCurrentCommitPointer() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: S3CurrentCommitPointerKey(testCurrentSessionPrefix()), Data: pointerData})
|
|
|
|
_, err = LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{})
|
|
if err == nil || !strings.Contains(err.Error(), test.want) {
|
|
t.Fatalf("error = %v, want %q", err, test.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func testRemoteCommit(t *testing.T, runID string) RemoteCommitManifest {
|
|
t.Helper()
|
|
prefix := testCurrentSessionPrefix()
|
|
manifestData := testManifestJSON(t, "2026-05-03", "sample-campaign", runID)
|
|
return RemoteCommitManifest{
|
|
FormatVersion: RemoteCommitFormatVersion,
|
|
Campaign: "sample-campaign",
|
|
SessionID: "2026-05-03",
|
|
RunID: runID,
|
|
Artifacts: []RemoteArtifact{{
|
|
Type: RemoteArtifactTypeSessionManifest,
|
|
Source: "session.manifest",
|
|
DestinationKey: S3RunRelativeDestinationKey(S3RunPrefix(prefix, runID), "session-manifest.json"),
|
|
SHA256: remoteObjectSHA256(manifestData),
|
|
Size: int64(len(manifestData)),
|
|
Generation: "manifest-generation",
|
|
}},
|
|
}
|
|
}
|
|
|
|
func testCommitPointer(t *testing.T, prefix string, commit RemoteCommitManifest) CurrentCommitPointer {
|
|
t.Helper()
|
|
commitData, err := EncodeRemoteCommitManifest(commit)
|
|
if err != nil {
|
|
t.Fatalf("EncodeRemoteCommitManifest() error = %v", err)
|
|
}
|
|
return CurrentCommitPointer{
|
|
FormatVersion: RemoteCommitFormatVersion,
|
|
Campaign: commit.Campaign,
|
|
SessionID: commit.SessionID,
|
|
RunID: commit.RunID,
|
|
CommitKey: S3RunCommitKey(prefix, commit.RunID),
|
|
CommitSHA256: remoteObjectSHA256(commitData),
|
|
CommitSize: int64(len(commitData)),
|
|
CommitGeneration: "commit-generation",
|
|
}
|
|
}
|
|
|
|
func seedRemoteCommit(t *testing.T, store *storage.FakeBackend, prefix string, commit RemoteCommitManifest, commitGeneration, manifestGeneration string) *CurrentCommitPointer {
|
|
t.Helper()
|
|
manifestData := testManifestJSON(t, commit.SessionID, commit.Campaign, commit.RunID)
|
|
commit.Artifacts[0].SHA256 = remoteObjectSHA256(manifestData)
|
|
commit.Artifacts[0].Size = int64(len(manifestData))
|
|
commit.Artifacts[0].Generation = manifestGeneration
|
|
commitData, err := EncodeRemoteCommitManifest(commit)
|
|
if err != nil {
|
|
t.Fatalf("EncodeRemoteCommitManifest() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{
|
|
Key: S3RunCommitKey(prefix, commit.RunID),
|
|
Data: commitData,
|
|
ETag: commitGeneration,
|
|
})
|
|
store.SeedObject(storage.FakeObject{
|
|
Key: commit.Artifacts[0].DestinationKey,
|
|
Data: manifestData,
|
|
ETag: manifestGeneration,
|
|
})
|
|
pointer := testCommitPointer(t, prefix, commit)
|
|
pointer.CommitGeneration = commitGeneration
|
|
pointerData, err := EncodeCurrentCommitPointer(pointer)
|
|
if err != nil {
|
|
t.Fatalf("EncodeCurrentCommitPointer() error = %v", err)
|
|
}
|
|
store.SeedObject(storage.FakeObject{Key: S3CurrentCommitPointerKey(prefix), Data: pointerData})
|
|
return &pointer
|
|
}
|
|
|
|
func appendUnknownJSONField(t *testing.T, data []byte) []byte {
|
|
t.Helper()
|
|
trimmed := bytes.TrimSpace(data)
|
|
if len(trimmed) < 2 || trimmed[len(trimmed)-1] != '}' {
|
|
t.Fatalf("invalid JSON object %q", trimmed)
|
|
}
|
|
return append(append([]byte(nil), trimmed[:len(trimmed)-1]...), []byte(`,"unexpected":true}`)...)
|
|
}
|