diff --git a/docs/internal/artifacts.md b/docs/internal/artifacts.md index 409ea58..d424f01 100644 --- a/docs/internal/artifacts.md +++ b/docs/internal/artifacts.md @@ -220,6 +220,21 @@ Caller policy is intentionally outside artifacts helpers: - some callers downgrade missing state to status/findings; - some callers skip optional behavior when state is missing. +## Remote Lock Objects + +The artifacts package defines the independent remote-lock payload and identity +contract. Each canonical publish source maps to one object below the validated +session `locks/` prefix; its filename is the lowercase SHA-256 digest of the +exact source followed by `.lock`. + +Lock payloads are strict versioned JSON containing the canonical source, an +optional reason, and a UTC creation time. Reads are bounded to +`MaxRemoteLockObjectBytes` (64 KiB), reject unknown fields and trailing values, +and verify that the payload source matches the key digest. Exact lookup reports +presence explicitly, prefix listing validates every object and sorts by source, +creation uses an absence precondition, forced replacement uses one observed +generation, and removal is idempotent. + ## Key Path Helpers `internal/artifacts/paths.go` and S3-key helpers define canonical helpers for: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 33c6bc2..f303cce 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -110,7 +110,7 @@ Run `go test ./internal/adapters/storage`, ## Stage 2 — Define Independent Lock Objects -**Status:** Pending +**Status:** Completed ### Goal diff --git a/internal/artifacts/remote_lock.go b/internal/artifacts/remote_lock.go new file mode 100644 index 0000000..621a267 --- /dev/null +++ b/internal/artifacts/remote_lock.go @@ -0,0 +1,328 @@ +package artifacts + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path" + "sort" + "strings" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" + "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/config" +) + +const ( + RemoteLockFormatVersion = 1 + MaxRemoteLockObjectBytes = 64 << 10 +) + +const remoteLocksSegment = "locks" + +// RemoteLock records an operator lock on one canonical publish source. +type RemoteLock struct { + FormatVersion int + Source string + Reason string + CreatedAt time.Time +} + +// RemoteLockLookup distinguishes an absent lock from a present validated lock. +type RemoteLockLookup struct { + Lock RemoteLock + Present bool + Generation string +} + +type remoteLockDocument struct { + FormatVersion int `json:"format_version"` + Source string `json:"source"` + Reason string `json:"reason,omitempty"` + CreatedAt string `json:"created_at"` +} + +// NewRemoteLock constructs and validates one lock payload. +func NewRemoteLock(source, reason string, createdAt time.Time) (RemoteLock, error) { + lock := RemoteLock{ + FormatVersion: RemoteLockFormatVersion, + Source: source, + Reason: reason, + CreatedAt: createdAt, + } + if err := lock.Validate(); err != nil { + return RemoteLock{}, err + } + return lock, nil +} + +// Validate checks the complete canonical lock payload contract. +func (l RemoteLock) Validate() error { + if l.FormatVersion != RemoteLockFormatVersion { + return fmt.Errorf("unsupported remote lock format version %d", l.FormatVersion) + } + if err := validateRemoteLockSource(l.Source); err != nil { + return fmt.Errorf("remote lock source: %w", err) + } + if strings.TrimSpace(l.Reason) != l.Reason { + return fmt.Errorf("remote lock reason must not have surrounding whitespace") + } + if l.CreatedAt.IsZero() { + return fmt.Errorf("remote lock created_at is required") + } + _, offset := l.CreatedAt.Zone() + if offset != 0 { + return fmt.Errorf("remote lock created_at must be UTC") + } + return nil +} + +// EncodeRemoteLock returns the canonical bounded JSON representation of a lock. +func EncodeRemoteLock(lock RemoteLock) ([]byte, error) { + if err := lock.Validate(); err != nil { + return nil, err + } + document := remoteLockDocument{ + FormatVersion: lock.FormatVersion, + Source: lock.Source, + Reason: lock.Reason, + CreatedAt: lock.CreatedAt.UTC().Format(time.RFC3339Nano), + } + data, err := json.Marshal(document) + if err != nil { + return nil, fmt.Errorf("encode remote lock: %w", err) + } + if len(data) > MaxRemoteLockObjectBytes { + return nil, fmt.Errorf("encode remote lock: payload exceeds %d-byte limit", MaxRemoteLockObjectBytes) + } + return data, nil +} + +// DecodeRemoteLock strictly decodes and validates one bounded lock payload. +func DecodeRemoteLock(data []byte) (RemoteLock, error) { + if len(data) > MaxRemoteLockObjectBytes { + return RemoteLock{}, fmt.Errorf("decode remote lock: payload exceeds %d-byte limit", MaxRemoteLockObjectBytes) + } + var document remoteLockDocument + if err := decodeStrictJSON(data, &document); err != nil { + return RemoteLock{}, fmt.Errorf("decode remote lock: %w", err) + } + createdAt, err := time.Parse(time.RFC3339Nano, document.CreatedAt) + if err != nil { + return RemoteLock{}, fmt.Errorf("decode remote lock created_at: %w", err) + } + if !strings.HasSuffix(document.CreatedAt, "Z") || createdAt.UTC().Format(time.RFC3339Nano) != document.CreatedAt { + return RemoteLock{}, fmt.Errorf("decode remote lock created_at: must be canonical RFC 3339 UTC") + } + lock := RemoteLock{ + FormatVersion: document.FormatVersion, + Source: document.Source, + Reason: document.Reason, + CreatedAt: createdAt, + } + if err := lock.Validate(); err != nil { + return RemoteLock{}, fmt.Errorf("decode remote lock: %w", err) + } + return lock, nil +} + +// RemoteLockKey returns the independent lock key for one canonical source. +func RemoteLockKey(sessionPrefix, source string) (string, error) { + locksPrefix, err := remoteLocksPrefix(sessionPrefix) + if err != nil { + return "", err + } + if err := validateRemoteLockSource(source); err != nil { + return "", err + } + return locksPrefix + remoteLockDigest(source) + ".lock", nil +} + +// LookupRemoteLock opens and validates the lock for one source when present. +func LookupRemoteLock(ctx context.Context, store storage.ObjectStore, sessionPrefix, source string) (RemoteLockLookup, error) { + if store == nil { + return RemoteLockLookup{}, fmt.Errorf("lookup remote lock: object store is required") + } + key, err := RemoteLockKey(sessionPrefix, source) + if err != nil { + return RemoteLockLookup{}, fmt.Errorf("lookup remote lock: %w", err) + } + return lookupRemoteLockAtKey(ctx, store, key, source) +} + +// ListRemoteLocks validates every object in the reserved lock prefix and sorts +// the resulting locks by source. +func ListRemoteLocks(ctx context.Context, store storage.ObjectStore, sessionPrefix string) ([]RemoteLock, error) { + if store == nil { + return nil, fmt.Errorf("list remote locks: object store is required") + } + locksPrefix, err := remoteLocksPrefix(sessionPrefix) + if err != nil { + return nil, fmt.Errorf("list remote locks: %w", err) + } + objects, err := store.List(ctx, locksPrefix) + if err != nil { + return nil, fmt.Errorf("list remote locks under %q: %w", locksPrefix, err) + } + locks := make([]RemoteLock, 0, len(objects)) + seenSources := make(map[string]struct{}, len(objects)) + for _, object := range objects { + digest, err := remoteLockDigestFromKey(locksPrefix, object.Key) + if err != nil { + return nil, fmt.Errorf("list remote locks: %w", err) + } + info, data, err := storage.ReadObjectBounded(ctx, store, object.Key, MaxRemoteLockObjectBytes) + if err != nil { + return nil, fmt.Errorf("read remote lock %q: %w", object.Key, err) + } + if info.Key != "" && info.Key != object.Key { + return nil, fmt.Errorf("read remote lock %q: store returned key %q", object.Key, info.Key) + } + lock, err := DecodeRemoteLock(data) + if err != nil { + return nil, fmt.Errorf("read remote lock %q: %w", object.Key, err) + } + if actual := remoteLockDigest(lock.Source); actual != digest { + return nil, fmt.Errorf("remote lock %q source hash mismatch", object.Key) + } + if _, duplicate := seenSources[lock.Source]; duplicate { + return nil, fmt.Errorf("remote lock source %q is duplicated", lock.Source) + } + seenSources[lock.Source] = struct{}{} + locks = append(locks, lock) + } + sort.Slice(locks, func(i, j int) bool { return locks[i].Source < locks[j].Source }) + return locks, nil +} + +// AddRemoteLock conditionally creates one lock. With force, one observed lock +// generation may be replaced; conflicts are returned for operator retry. +func AddRemoteLock(ctx context.Context, store storage.ObjectStore, sessionPrefix string, lock RemoteLock, force bool) error { + if store == nil { + return fmt.Errorf("add remote lock: object store is required") + } + data, err := EncodeRemoteLock(lock) + if err != nil { + return fmt.Errorf("add remote lock: %w", err) + } + key, err := RemoteLockKey(sessionPrefix, lock.Source) + if err != nil { + return fmt.Errorf("add remote lock: %w", err) + } + condition := storage.WriteCondition{RequireAbsent: true} + if force { + current, err := lookupRemoteLockAtKey(ctx, store, key, lock.Source) + if err != nil { + return fmt.Errorf("add remote lock %q: %w", lock.Source, err) + } + if current.Present { + if strings.TrimSpace(current.Generation) == "" { + return fmt.Errorf("add remote lock %q: current generation is required for replacement", lock.Source) + } + condition = storage.WriteCondition{MatchETag: current.Generation} + } + } + _, err = store.UploadConditional(ctx, bytes.NewReader(data), key, storage.UploadOptions{ContentType: "application/json"}, condition) + if err != nil { + return fmt.Errorf("add remote lock %q: %w", lock.Source, err) + } + return nil +} + +// RemoveRemoteLock idempotently removes the lock for one canonical source. +func RemoveRemoteLock(ctx context.Context, store storage.ObjectStore, sessionPrefix, source string) error { + if store == nil { + return fmt.Errorf("remove remote lock: object store is required") + } + key, err := RemoteLockKey(sessionPrefix, source) + if err != nil { + return fmt.Errorf("remove remote lock: %w", err) + } + if err := store.Delete(ctx, key); err != nil { + return fmt.Errorf("remove remote lock %q: %w", source, err) + } + return nil +} + +func lookupRemoteLockAtKey(ctx context.Context, store storage.ObjectStore, key, source string) (RemoteLockLookup, error) { + info, data, err := storage.ReadObjectBounded(ctx, store, key, MaxRemoteLockObjectBytes) + if errors.Is(err, os.ErrNotExist) { + return RemoteLockLookup{}, nil + } + if err != nil { + return RemoteLockLookup{}, fmt.Errorf("read object %q: %w", key, err) + } + lock, err := DecodeRemoteLock(data) + if err != nil { + return RemoteLockLookup{}, fmt.Errorf("read object %q: %w", key, err) + } + if lock.Source != source || remoteLockDigest(lock.Source) != strings.TrimSuffix(path.Base(key), ".lock") { + return RemoteLockLookup{}, fmt.Errorf("remote lock %q source hash mismatch", key) + } + return RemoteLockLookup{Lock: lock, Present: true, Generation: info.ETag}, nil +} + +func validateRemoteLockSource(source string) error { + classified, err := artifactpolicy.ClassifySource(source) + if err != nil || classified.Kind == artifactpolicy.SourceKindPreviousArtifact { + return fmt.Errorf("source %q is not a supported publish source", source) + } + if classified.ID != source { + return fmt.Errorf("source %q is not canonical", source) + } + return nil +} + +func remoteLocksPrefix(sessionPrefix string) (string, error) { + if err := validateRemoteLockSessionPrefix(sessionPrefix); err != nil { + return "", err + } + return sessionPrefix + remoteLocksSegment + "/", nil +} + +func validateRemoteLockSessionPrefix(sessionPrefix string) error { + if sessionPrefix == "" || strings.TrimSpace(sessionPrefix) != sessionPrefix || strings.Contains(sessionPrefix, `\`) || !strings.HasSuffix(sessionPrefix, "/") { + return fmt.Errorf("session prefix %q must be a canonical relative object prefix", sessionPrefix) + } + withoutSlash := strings.TrimSuffix(sessionPrefix, "/") + if err := validateRemoteObjectKey(withoutSlash); err != nil { + return fmt.Errorf("session prefix %q: %w", sessionPrefix, err) + } + parts := strings.Split(withoutSlash, "/") + if len(parts) < 4 || parts[len(parts)-4] != config.S3CampaignsSegment || parts[len(parts)-2] != config.S3SessionsSegment { + return fmt.Errorf("session prefix %q does not identify a campaign session", sessionPrefix) + } + if err := ValidateSessionIdentity(parts[len(parts)-3], parts[len(parts)-1]); err != nil { + return fmt.Errorf("session prefix %q: %w", sessionPrefix, err) + } + return nil +} + +func remoteLockDigest(source string) string { + sum := sha256.Sum256([]byte(source)) + return hex.EncodeToString(sum[:]) +} + +func remoteLockDigestFromKey(prefix, key string) (string, error) { + if err := validateRemoteObjectKey(key); err != nil { + return "", fmt.Errorf("invalid remote lock key %q: %w", key, err) + } + if !strings.HasPrefix(key, prefix) { + return "", fmt.Errorf("unexpected object %q outside remote lock prefix %q", key, prefix) + } + name := strings.TrimPrefix(key, prefix) + if strings.Contains(name, "/") || !strings.HasSuffix(name, ".lock") { + return "", fmt.Errorf("unexpected object %q in remote lock prefix", key) + } + digest := strings.TrimSuffix(name, ".lock") + if err := validateSHA256(digest); err != nil { + return "", fmt.Errorf("unexpected remote lock object %q: %w", key, err) + } + return digest, nil +} diff --git a/internal/artifacts/remote_lock_test.go b/internal/artifacts/remote_lock_test.go new file mode 100644 index 0000000..f12f882 --- /dev/null +++ b/internal/artifacts/remote_lock_test.go @@ -0,0 +1,281 @@ +package artifacts + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "gitea.maximumdirect.net/eric/narratio/internal/adapters/storage" +) + +const testRemoteLockSessionPrefix = "dnd/campaigns/forsaken/sessions/2026-09-05/" + +func TestRemoteLockKeyUsesCanonicalSourceDigest(t *testing.T) { + tests := []struct { + source string + digest string + }{ + {source: "narratio.artifact.session_recap", digest: "cf21d18edf63a8c5b5e63a7e205510dcaf328fc6d7ab4e00ee264683e1b62655"}, + {source: "narratio.transcript.final_trimmed", digest: "8896c4f6e4fb1f677b6757d232d2ca72c285a4471123bb0437c9830d9cf5fe94"}, + {source: "narratio.extraction.encounters", digest: "c14d3512feaf44f651ff4039ce27914c619816de87121a739bb9bb75a84ab35d"}, + } + for _, test := range tests { + t.Run(test.source, func(t *testing.T) { + got, err := RemoteLockKey(testRemoteLockSessionPrefix, test.source) + if err != nil { + t.Fatalf("RemoteLockKey() error = %v", err) + } + want := testRemoteLockSessionPrefix + "locks/" + test.digest + ".lock" + if got != want { + t.Fatalf("RemoteLockKey() = %q, want %q", got, want) + } + }) + } +} + +func TestRemoteLockKeyRejectsNoncanonicalInputs(t *testing.T) { + for _, test := range []struct { + name string + prefix string + source string + }{ + {name: "prefix without slash", prefix: strings.TrimSuffix(testRemoteLockSessionPrefix, "/"), source: "narratio.artifact.recap"}, + {name: "non-session prefix", prefix: "dnd/campaigns/forsaken/", source: "narratio.artifact.recap"}, + {name: "traversal prefix", prefix: "dnd/campaigns/forsaken/sessions/../", source: "narratio.artifact.recap"}, + {name: "source whitespace", prefix: testRemoteLockSessionPrefix, source: " narratio.artifact.recap"}, + {name: "previous source", prefix: testRemoteLockSessionPrefix, source: "narratio.previous_session.artifact.recap"}, + {name: "unsupported source", prefix: testRemoteLockSessionPrefix, source: "narratio.artifact.Bad"}, + } { + t.Run(test.name, func(t *testing.T) { + if _, err := RemoteLockKey(test.prefix, test.source); err == nil { + t.Fatal("RemoteLockKey() error = nil, want validation failure") + } + }) + } +} + +func TestRemoteLockEncodingIsStrictAndCanonical(t *testing.T) { + createdAt := time.Date(2026, 9, 5, 12, 34, 56, 123000000, time.UTC) + lock, err := NewRemoteLock("narratio.artifact.session_recap", "manual edits", createdAt) + if err != nil { + t.Fatalf("NewRemoteLock() error = %v", err) + } + data, err := EncodeRemoteLock(lock) + if err != nil { + t.Fatalf("EncodeRemoteLock() error = %v", err) + } + want := `{"format_version":1,"source":"narratio.artifact.session_recap","reason":"manual edits","created_at":"2026-09-05T12:34:56.123Z"}` + if string(data) != want { + t.Fatalf("EncodeRemoteLock() = %s, want %s", data, want) + } + decoded, err := DecodeRemoteLock(data) + if err != nil { + t.Fatalf("DecodeRemoteLock() error = %v", err) + } + if decoded.FormatVersion != lock.FormatVersion || decoded.Source != lock.Source || decoded.Reason != lock.Reason || !decoded.CreatedAt.Equal(lock.CreatedAt) { + t.Fatalf("DecodeRemoteLock() = %#v, want %#v", decoded, lock) + } +} + +func TestDecodeRemoteLockRejectsMalformedContracts(t *testing.T) { + valid := `{"format_version":1,"source":"narratio.artifact.session_recap","created_at":"2026-09-05T12:34:56Z"}` + tests := []struct { + name string + data string + }{ + {name: "unknown field", data: strings.Replace(valid, `}`, `,"extra":true}`, 1)}, + {name: "trailing value", data: valid + `{}`}, + {name: "unsupported version", data: strings.Replace(valid, `"format_version":1`, `"format_version":2`, 1)}, + {name: "noncanonical source", data: strings.Replace(valid, `narratio.artifact.session_recap`, ` narratio.artifact.session_recap`, 1)}, + {name: "previous source", data: strings.Replace(valid, `narratio.artifact.session_recap`, `narratio.previous_session.artifact.session_recap`, 1)}, + {name: "non-UTC timestamp", data: strings.Replace(valid, `2026-09-05T12:34:56Z`, `2026-09-05T12:34:56+00:00`, 1)}, + {name: "invalid timestamp", data: strings.Replace(valid, `2026-09-05T12:34:56Z`, `yesterday`, 1)}, + {name: "reason whitespace", data: strings.Replace(valid, `,"created_at"`, `,"reason":" padded ","created_at"`, 1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, err := DecodeRemoteLock([]byte(test.data)); err == nil { + t.Fatal("DecodeRemoteLock() error = nil, want validation failure") + } + }) + } +} + +func TestRemoteLockEncodingAndDecodingEnforceSizeLimit(t *testing.T) { + lock, err := NewRemoteLock("narratio.artifact.session_recap", strings.Repeat("x", MaxRemoteLockObjectBytes), time.Now().UTC()) + if err != nil { + t.Fatalf("NewRemoteLock() error = %v", err) + } + if _, err := EncodeRemoteLock(lock); err == nil { + t.Fatal("EncodeRemoteLock() error = nil, want size failure") + } + if _, err := DecodeRemoteLock(make([]byte, MaxRemoteLockObjectBytes+1)); err == nil { + t.Fatal("DecodeRemoteLock() error = nil, want size failure") + } +} + +func TestLookupRemoteLockReportsPresenceAndAbsence(t *testing.T) { + store := &storage.FakeBackend{} + lookup, err := LookupRemoteLock(context.Background(), store, testRemoteLockSessionPrefix, "narratio.artifact.session_recap") + if err != nil || lookup.Present { + t.Fatalf("LookupRemoteLock() absent = %#v, error = %v", lookup, err) + } + + lock := mustTestRemoteLock(t, "narratio.artifact.session_recap", "manual edits") + seedRemoteLock(t, store, testRemoteLockSessionPrefix, lock) + lookup, err = LookupRemoteLock(context.Background(), store, testRemoteLockSessionPrefix, lock.Source) + if err != nil { + t.Fatalf("LookupRemoteLock() error = %v", err) + } + if !lookup.Present || lookup.Lock.Source != lock.Source || lookup.Generation == "" { + t.Fatalf("LookupRemoteLock() = %#v, want present validated lock", lookup) + } +} + +func TestListRemoteLocksSortsAndValidatesObjects(t *testing.T) { + store := &storage.FakeBackend{} + seedRemoteLock(t, store, testRemoteLockSessionPrefix, mustTestRemoteLock(t, "narratio.transcript.final_trimmed", "")) + seedRemoteLock(t, store, testRemoteLockSessionPrefix, mustTestRemoteLock(t, "narratio.artifact.session_recap", "")) + + locks, err := ListRemoteLocks(context.Background(), store, testRemoteLockSessionPrefix) + if err != nil { + t.Fatalf("ListRemoteLocks() error = %v", err) + } + if len(locks) != 2 || locks[0].Source != "narratio.artifact.session_recap" || locks[1].Source != "narratio.transcript.final_trimmed" { + t.Fatalf("ListRemoteLocks() = %#v, want source-sorted locks", locks) + } +} + +func TestListRemoteLocksRejectsInvalidPrefixContents(t *testing.T) { + validLock := mustTestRemoteLock(t, "narratio.artifact.session_recap", "") + validData, err := EncodeRemoteLock(validLock) + if err != nil { + t.Fatal(err) + } + validKey, err := RemoteLockKey(testRemoteLockSessionPrefix, validLock.Source) + if err != nil { + t.Fatal(err) + } + tests := []struct { + name string + key string + data []byte + }{ + {name: "unexpected name", key: testRemoteLockSessionPrefix + "locks/index.json", data: validData}, + {name: "nested object", key: testRemoteLockSessionPrefix + "locks/nested/" + strings.TrimPrefix(validKey, testRemoteLockSessionPrefix+"locks/"), data: validData}, + {name: "hash mismatch", key: testRemoteLockSessionPrefix + "locks/8896c4f6e4fb1f677b6757d232d2ca72c285a4471123bb0437c9830d9cf5fe94.lock", data: validData}, + {name: "oversize payload", key: validKey, data: make([]byte, MaxRemoteLockObjectBytes+1)}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + store := &storage.FakeBackend{} + store.SeedObject(storage.FakeObject{Key: test.key, Data: test.data}) + if _, err := ListRemoteLocks(context.Background(), store, testRemoteLockSessionPrefix); err == nil { + t.Fatal("ListRemoteLocks() error = nil, want validation failure") + } + }) + } +} + +func TestListRemoteLocksRejectsDuplicateSources(t *testing.T) { + store := &storage.FakeBackend{} + lock := mustTestRemoteLock(t, "narratio.artifact.session_recap", "") + seedRemoteLock(t, store, testRemoteLockSessionPrefix, lock) + key, _ := RemoteLockKey(testRemoteLockSessionPrefix, lock.Source) + duplicating := &duplicateRemoteLockListing{FakeBackend: store, key: key} + if _, err := ListRemoteLocks(context.Background(), duplicating, testRemoteLockSessionPrefix); err == nil || !strings.Contains(err.Error(), "duplicated") { + t.Fatalf("ListRemoteLocks() error = %v, want duplicate-source failure", err) + } +} + +func TestAddRemoteLockConditionalCreateAndForcedReplacement(t *testing.T) { + ctx := context.Background() + store := &storage.FakeBackend{} + original := mustTestRemoteLock(t, "narratio.artifact.session_recap", "first") + if err := AddRemoteLock(ctx, store, testRemoteLockSessionPrefix, original, false); err != nil { + t.Fatalf("AddRemoteLock() create error = %v", err) + } + if err := AddRemoteLock(ctx, store, testRemoteLockSessionPrefix, original, false); !errors.Is(err, storage.ErrConditionNotMet) { + t.Fatalf("AddRemoteLock() duplicate error = %v, want ErrConditionNotMet", err) + } + + replacement := mustTestRemoteLock(t, original.Source, "replacement") + if err := AddRemoteLock(ctx, store, testRemoteLockSessionPrefix, replacement, true); err != nil { + t.Fatalf("AddRemoteLock() force error = %v", err) + } + lookup, err := LookupRemoteLock(ctx, store, testRemoteLockSessionPrefix, original.Source) + if err != nil || !lookup.Present || lookup.Lock.Reason != "replacement" { + t.Fatalf("LookupRemoteLock() after replacement = %#v, error = %v", lookup, err) + } +} + +func TestAddRemoteLockForcedReplacementReportsGenerationConflict(t *testing.T) { + store := &storage.FakeBackend{} + original := mustTestRemoteLock(t, "narratio.artifact.session_recap", "first") + seedRemoteLock(t, store, testRemoteLockSessionPrefix, original) + store.UploadHook = func(storage.FakeUploadCall) error { + changed := mustTestRemoteLock(t, original.Source, "concurrent") + seedRemoteLock(t, store, testRemoteLockSessionPrefix, changed) + store.UploadHook = nil + return nil + } + err := AddRemoteLock(context.Background(), store, testRemoteLockSessionPrefix, mustTestRemoteLock(t, original.Source, "replacement"), true) + if !errors.Is(err, storage.ErrConditionNotMet) { + t.Fatalf("AddRemoteLock() error = %v, want ErrConditionNotMet", err) + } +} + +func TestRemoveRemoteLockIsIdempotent(t *testing.T) { + store := &storage.FakeBackend{} + lock := mustTestRemoteLock(t, "narratio.artifact.session_recap", "") + seedRemoteLock(t, store, testRemoteLockSessionPrefix, lock) + for range 2 { + if err := RemoveRemoteLock(context.Background(), store, testRemoteLockSessionPrefix, lock.Source); err != nil { + t.Fatalf("RemoveRemoteLock() error = %v", err) + } + } + lookup, err := LookupRemoteLock(context.Background(), store, testRemoteLockSessionPrefix, lock.Source) + if err != nil || lookup.Present { + t.Fatalf("LookupRemoteLock() after remove = %#v, error = %v", lookup, err) + } +} + +type duplicateRemoteLockListing struct { + *storage.FakeBackend + key string +} + +func (s *duplicateRemoteLockListing) List(ctx context.Context, prefix string) ([]storage.ObjectInfo, error) { + objects, err := s.FakeBackend.List(ctx, prefix) + if err != nil { + return nil, err + } + if len(objects) == 1 && objects[0].Key == s.key { + objects = append(objects, objects[0]) + } + return objects, nil +} + +func mustTestRemoteLock(t *testing.T, source, reason string) RemoteLock { + t.Helper() + lock, err := NewRemoteLock(source, reason, time.Date(2026, 9, 5, 12, 34, 56, 0, time.UTC)) + if err != nil { + t.Fatalf("NewRemoteLock() error = %v", err) + } + return lock +} + +func seedRemoteLock(t *testing.T, store *storage.FakeBackend, sessionPrefix string, lock RemoteLock) { + t.Helper() + key, err := RemoteLockKey(sessionPrefix, lock.Source) + if err != nil { + t.Fatalf("RemoteLockKey() error = %v", err) + } + data, err := EncodeRemoteLock(lock) + if err != nil { + t.Fatalf("EncodeRemoteLock() error = %v", err) + } + store.SeedObject(storage.FakeObject{Key: key, Data: data}) +}