feat(sense): add reconciliation safety controls [T-012]
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
package orphan
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"yovision/sense/internal/store"
|
||||
)
|
||||
|
||||
type fakeLease struct {
|
||||
owner, token string
|
||||
expires time.Time
|
||||
}
|
||||
|
||||
type fakeRepository struct {
|
||||
mu sync.Mutex
|
||||
leases map[string]fakeLease
|
||||
ownership []store.MediaPathOwnership
|
||||
scans map[string]store.OrphanScan
|
||||
deleted map[string]map[string]bool
|
||||
}
|
||||
|
||||
func newFakeRepository() *fakeRepository {
|
||||
return &fakeRepository{
|
||||
leases: make(map[string]fakeLease), scans: make(map[string]store.OrphanScan),
|
||||
deleted: make(map[string]map[string]bool),
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeRepository) AcquireOperationalLease(
|
||||
_ context.Context,
|
||||
name, owner, token string,
|
||||
now time.Time,
|
||||
duration time.Duration,
|
||||
) (bool, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
current, exists := f.leases[name]
|
||||
if exists && current.expires.After(now) && (current.owner != owner || current.token != token) {
|
||||
return false, nil
|
||||
}
|
||||
f.leases[name] = fakeLease{owner: owner, token: token, expires: now.Add(duration)}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ReleaseOperationalLease(
|
||||
_ context.Context,
|
||||
name, owner, token string,
|
||||
now time.Time,
|
||||
) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
current := f.leases[name]
|
||||
if current.owner == owner && current.token == token {
|
||||
current.expires = now
|
||||
f.leases[name] = current
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) ListMediaPathOwnership(context.Context) ([]store.MediaPathOwnership, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return append([]store.MediaPathOwnership(nil), f.ownership...), nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) SaveOrphanScan(
|
||||
_ context.Context,
|
||||
scan store.OrphanScan,
|
||||
owner, token string,
|
||||
) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
lease := f.leases[store.OperationalLeaseOrphanScan]
|
||||
if lease.owner != owner || lease.token != token || !lease.expires.After(scan.CompletedAt) {
|
||||
return store.ErrOperationalLeaseLost
|
||||
}
|
||||
f.scans[scan.ID] = cloneScan(scan)
|
||||
lease.expires = scan.CompletedAt
|
||||
f.leases[store.OperationalLeaseOrphanScan] = lease
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) GetOrphanScan(_ context.Context, id string) (store.OrphanScan, error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
scan, exists := f.scans[id]
|
||||
if !exists {
|
||||
return store.OrphanScan{}, store.ErrOrphanScanNotFound
|
||||
}
|
||||
result := cloneScan(scan)
|
||||
for index := range result.Findings {
|
||||
result.Findings[index].Deleted = f.deleted[id][result.Findings[index].PathName]
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) RecordOrphanCleanup(
|
||||
_ context.Context,
|
||||
scanID, pathName, _, status, _ string,
|
||||
_ time.Time,
|
||||
) error {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
if f.deleted[scanID] == nil {
|
||||
f.deleted[scanID] = make(map[string]bool)
|
||||
}
|
||||
if status == "deleted" {
|
||||
f.deleted[scanID][pathName] = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cloneScan(value store.OrphanScan) store.OrphanScan {
|
||||
value.Findings = append([]store.OrphanFinding(nil), value.Findings...)
|
||||
return value
|
||||
}
|
||||
|
||||
type fakeRuntime struct {
|
||||
paths map[string]bool
|
||||
fail map[string]bool
|
||||
deleted []string
|
||||
}
|
||||
|
||||
func (f *fakeRuntime) ListPathNames(context.Context) ([]string, error) {
|
||||
values := make([]string, 0, len(f.paths))
|
||||
for path, present := range f.paths {
|
||||
if present {
|
||||
values = append(values, path)
|
||||
}
|
||||
}
|
||||
sort.Strings(values)
|
||||
return values, nil
|
||||
}
|
||||
|
||||
func (f *fakeRuntime) DeletePath(_ context.Context, path string) error {
|
||||
if f.fail[path] {
|
||||
return errors.New("redacted media failure")
|
||||
}
|
||||
delete(f.paths, path)
|
||||
f.deleted = append(f.deleted, path)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSafetyGateUsesExactTenPercentBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
if allowed, reason := safetyGate(1, 10); !allowed || reason != "allowed" {
|
||||
t.Fatalf("exact 10%% boundary was rejected: %v %s", allowed, reason)
|
||||
}
|
||||
if allowed, reason := safetyGate(1, 9); allowed || reason != "ratio_exceeded" {
|
||||
t.Fatalf("more than 10%% was accepted: %v %s", allowed, reason)
|
||||
}
|
||||
if allowed, reason := safetyGate(129, 2000); allowed || reason != "scope_exceeded" {
|
||||
t.Fatalf("129-item scope was accepted: %v %s", allowed, reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReportClassifiesOwnershipAndApplyNeverDeletesUnowned(t *testing.T) {
|
||||
repository := newFakeRepository()
|
||||
repository.ownership = []store.MediaPathOwnership{
|
||||
{PathName: "owned-stale", DeviceID: "old-device"},
|
||||
{PathName: "owned-current", DeviceID: "live-device", CurrentClaim: true},
|
||||
}
|
||||
runtime := &fakeRuntime{paths: map[string]bool{
|
||||
"owned-stale": true, "owned-current": true,
|
||||
"unowned-1": true, "unowned-2": true, "unowned-3": true, "unowned-4": true,
|
||||
"unowned-5": true, "unowned-6": true, "unowned-7": true, "unowned-8": true,
|
||||
}, fail: make(map[string]bool)}
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
manager := New(repository, runtime, "ins_test", nil)
|
||||
manager.now = func() time.Time { return now }
|
||||
scan, err := manager.Report(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scan.ObservedCount != 10 || scan.OwnedStaleCount != 1 || scan.UnownedCount != 8 || !scan.SafetyAllowed {
|
||||
t.Fatalf("unexpected report: %+v", scan)
|
||||
}
|
||||
result, err := manager.Apply(
|
||||
context.Background(), scan.ID, "operator-1", Confirmation(scan.ID),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Deleted != 1 || len(runtime.deleted) != 1 || runtime.deleted[0] != "owned-stale" {
|
||||
t.Fatalf("cleanup escaped owned stale set: result=%+v deleted=%v", result, runtime.deleted)
|
||||
}
|
||||
for _, path := range []string{"owned-current", "unowned-1", "unowned-8"} {
|
||||
if !runtime.paths[path] {
|
||||
t.Fatalf("cleanup deleted protected path %q", path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyBlocksExpiredOrOversizedSnapshotBeforeDelete(t *testing.T) {
|
||||
repository := newFakeRepository()
|
||||
repository.ownership = []store.MediaPathOwnership{{PathName: "stale", DeviceID: "old"}}
|
||||
runtime := &fakeRuntime{paths: map[string]bool{"stale": true}, fail: make(map[string]bool)}
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
manager := New(repository, runtime, "ins_test", nil)
|
||||
manager.now = func() time.Time { return now }
|
||||
scan, err := manager.Report(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if scan.SafetyAllowed || scan.SafetyReason != "ratio_exceeded" {
|
||||
t.Fatalf("single-path 100%% cleanup was not blocked: %+v", scan)
|
||||
}
|
||||
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSafetyBlocked) {
|
||||
t.Fatalf("expected ratio gate, got %v", err)
|
||||
}
|
||||
if len(runtime.deleted) != 0 {
|
||||
t.Fatal("ratio-blocked cleanup mutated MediaMTX")
|
||||
}
|
||||
|
||||
for index := 0; index < 9; index++ {
|
||||
runtime.paths[string(rune('a'+index))] = true
|
||||
}
|
||||
scan, err = manager.Report(context.Background())
|
||||
if err != nil || !scan.SafetyAllowed {
|
||||
t.Fatalf("expected a fresh 1/10 executable report: %+v %v", scan, err)
|
||||
}
|
||||
now = now.Add(reportTTL)
|
||||
if _, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID)); !errors.Is(err, ErrSnapshotExpired) {
|
||||
t.Fatalf("expected expired snapshot, got %v", err)
|
||||
}
|
||||
if len(runtime.deleted) != 0 {
|
||||
t.Fatal("expired cleanup mutated MediaMTX")
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyIsRetryableAndCannotExpandBeyondSnapshot(t *testing.T) {
|
||||
repository := newFakeRepository()
|
||||
repository.ownership = []store.MediaPathOwnership{
|
||||
{PathName: "stale-a", DeviceID: "old-a"},
|
||||
{PathName: "stale-b", DeviceID: "old-b"},
|
||||
}
|
||||
runtime := &fakeRuntime{paths: make(map[string]bool), fail: map[string]bool{"stale-b": true}}
|
||||
runtime.paths["stale-a"], runtime.paths["stale-b"] = true, true
|
||||
for index := 0; index < 18; index++ {
|
||||
runtime.paths[string(rune(0x100+index))] = true
|
||||
}
|
||||
now := time.Date(2026, 8, 7, 0, 0, 0, 0, time.UTC)
|
||||
manager := New(repository, runtime, "ins_test", nil)
|
||||
manager.now = func() time.Time { return now }
|
||||
scan, err := manager.Report(context.Background())
|
||||
if err != nil || !scan.SafetyAllowed {
|
||||
t.Fatalf("expected 2/20 report: %+v %v", scan, err)
|
||||
}
|
||||
repository.ownership = append(repository.ownership,
|
||||
store.MediaPathOwnership{PathName: "new-stale", DeviceID: "new-old"})
|
||||
runtime.paths["new-stale"] = true
|
||||
result, err := manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
|
||||
if err == nil || result.Deleted != 1 || result.Failed != 1 {
|
||||
t.Fatalf("expected one partial failure: %+v %v", result, err)
|
||||
}
|
||||
if !runtime.paths["new-stale"] {
|
||||
t.Fatal("cleanup expanded beyond the approved snapshot")
|
||||
}
|
||||
runtime.fail["stale-b"] = false
|
||||
result, err = manager.Apply(context.Background(), scan.ID, "operator", Confirmation(scan.ID))
|
||||
if err != nil || result.Deleted != 1 || result.Failed != 0 {
|
||||
t.Fatalf("failed item was not retryable: %+v %v", result, err)
|
||||
}
|
||||
if !runtime.paths["new-stale"] {
|
||||
t.Fatal("retry expanded beyond the approved snapshot")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user