Define independent remote lock objects

This commit is contained in:
2026-09-05 12:27:51 +00:00
parent 14b4fee214
commit be59bff690
4 changed files with 625 additions and 1 deletions

View File

@@ -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:

View File

@@ -110,7 +110,7 @@ Run `go test ./internal/adapters/storage`,
## Stage 2 — Define Independent Lock Objects
**Status:** Pending
**Status:** Completed
### Goal

View File

@@ -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
}

View File

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