273 lines
8.4 KiB
Go
273 lines
8.4 KiB
Go
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")
|
|
}
|
|
}
|