Define independent remote lock objects
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -110,7 +110,7 @@ Run `go test ./internal/adapters/storage`,
|
||||
|
||||
## Stage 2 — Define Independent Lock Objects
|
||||
|
||||
**Status:** Pending
|
||||
**Status:** Completed
|
||||
|
||||
### Goal
|
||||
|
||||
|
||||
328
internal/artifacts/remote_lock.go
Normal file
328
internal/artifacts/remote_lock.go
Normal 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
|
||||
}
|
||||
281
internal/artifacts/remote_lock_test.go
Normal file
281
internal/artifacts/remote_lock_test.go
Normal 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})
|
||||
}
|
||||
Reference in New Issue
Block a user