282 lines
12 KiB
Go
282 lines
12 KiB
Go
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})
|
|
}
|