Files
go-sip/internal/agent/recording_recovery_test.go
T

158 lines
6.2 KiB
Go

package agent
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
)
func privateRecoveryRoot(t *testing.T) string {
t.Helper()
root := filepath.Join(t.TempDir(), "private-recording-recovery")
if err := os.Mkdir(root, 0700); err != nil {
t.Fatal(err)
}
if err := os.Chmod(root, 0700); err != nil {
t.Fatal(err)
}
return root
}
func failedRecordingFixture() RecordingRecoveryEntry {
return RecordingRecoveryEntry{
CallID: "call-1", SourceEventID: "execute-1", RecordingID: "rec-1", UploadID: "upload-1",
Bucket: "mock-bucket", ObjectKey: "tenant/call/rec.wav",
ResultPayload: json.RawMessage(`{"call_id":"call-1","transcript":[]}`),
}
}
func TestFailedRecordingPersistsOriginalTargetAndTwoPrivateFiles(t *testing.T) {
root := privateRecoveryRoot(t)
now := time.Date(2026, 10, 1, 10, 0, 0, 0, time.UTC)
recovery := RecordingRecovery{Root: root, Now: func() time.Time { return now }}
audio := []byte("RIFF original failed upload")
entry, err := recovery.SaveFailure(audio, failedRecordingFixture(), &UploadHTTPError{StatusCode: 503})
if err != nil {
t.Fatal(err)
}
sha := sha256.Sum256(audio)
if entry.State != "retry_pending" || entry.Attempts != 1 || entry.SavedAt != now || entry.NextAttemptAt != now.Add(time.Minute) || entry.ExpiresAt != now.Add(48*time.Hour) || entry.SHA256 != hex.EncodeToString(sha[:]) || entry.SizeBytes != int64(len(audio)) {
t.Fatalf("retry identity or 48-hour clock was lost: %+v", entry)
}
dir := filepath.Join(root, "mock-bucket", "tenant", "call", "rec.wav")
files, err := os.ReadDir(dir)
if err != nil || len(files) != 2 || files[0].Name() != "info.json" || files[1].Name() != "recording.wav" {
t.Fatalf("failed upload must preserve exactly the audio and call info: files=%v err=%v", files, err)
}
for _, name := range []string{"info.json", "recording.wav"} {
file, err := os.Stat(filepath.Join(dir, name))
if err != nil {
t.Fatalf("recovery file %q is missing: %v", name, err)
}
if file.Mode().Perm() != 0600 {
t.Fatalf("recovery file %q is not restricted: mode=%v", name, file.Mode())
}
}
stored, err := os.ReadFile(filepath.Join(dir, "recording.wav"))
if err != nil || !bytes.Equal(stored, audio) {
t.Fatalf("original recording was lost or changed: %v", err)
}
metadata, err := os.ReadFile(filepath.Join(dir, "info.json"))
if err != nil || strings.Contains(string(metadata), "signature=") || strings.Contains(string(metadata), "mock-token") {
t.Fatalf("recovery info includes grant credentials or cannot be read: %v", err)
}
loaded, err := recovery.Load(entry.Bucket, entry.ObjectKey)
if err != nil || !reflect.DeepEqual(loaded, entry) {
t.Fatalf("persistent identity did not survive a fresh read: loaded=%+v err=%v", loaded, err)
}
}
func TestFailedRecordingRequiresActualOSSFailure(t *testing.T) {
root := privateRecoveryRoot(t)
recovery := RecordingRecovery{Root: root}
_, err := recovery.SaveFailure([]byte("recording"), failedRecordingFixture(), nil)
if err == nil {
t.Fatal("normal upload path must not create recovery files")
}
files, err := os.ReadDir(root)
if err != nil || len(files) != 0 {
t.Fatalf("no OSS failure still created files: entries=%v err=%v", files, err)
}
}
func TestFailedRecordingMetadataWriteFailureDoesNotFabricateResult(t *testing.T) {
root := privateRecoveryRoot(t)
recovery := RecordingRecovery{Root: root, writeState: func(string, any) error { return errors.New("disk full") }}
fixture := failedRecordingFixture()
_, err := recovery.SaveFailure([]byte("original recording"), fixture, &UploadHTTPError{StatusCode: 503})
if err == nil || !strings.Contains(err.Error(), "disk full") {
t.Fatalf("failed metadata persistence was hidden: %v", err)
}
dir := filepath.Join(root, fixture.Bucket, filepath.FromSlash(fixture.ObjectKey))
if _, err := os.Stat(filepath.Join(dir, "recording.wav")); err != nil {
t.Fatalf("original audio must remain for manual repair: %v", err)
}
if _, err := os.Stat(filepath.Join(dir, "info.json")); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("invalid metadata appeared durable: %v", err)
}
restarted := RecordingRecovery{Root: root}
if _, err := restarted.Load(fixture.Bucket, fixture.ObjectKey); err == nil {
t.Fatal("orphan recording must not be treated as a recoverable final result")
}
}
func TestFailedRecordingAmbiguousPUTRequiresManualRecovery(t *testing.T) {
recovery := RecordingRecovery{Root: privateRecoveryRoot(t)}
entry, err := recovery.SaveFailure([]byte("original recording"), failedRecordingFixture(), ErrUploadOutcomeUnknown)
if err != nil || entry.State != "outcome_unknown" || !entry.NextAttemptAt.IsZero() {
t.Fatalf("uncertain PUT must never become an automatic retry: entry=%+v err=%v", entry, err)
}
}
func TestFailedRecordingRejectsPathTraversalBeforeWriting(t *testing.T) {
root := privateRecoveryRoot(t)
fixture := failedRecordingFixture()
fixture.ObjectKey = "../outside.wav"
recovery := RecordingRecovery{Root: root}
_, err := recovery.SaveFailure([]byte("recording"), fixture, &UploadHTTPError{StatusCode: 503})
if err == nil {
t.Fatal("object key escaped the recovery root")
}
files, err := os.ReadDir(root)
if err != nil || len(files) != 0 {
t.Fatalf("invalid object key wrote files: entries=%v err=%v", files, err)
}
}
func TestFailedRecordingRejectsUnknownRecoveryStateFields(t *testing.T) {
root := privateRecoveryRoot(t)
recovery := RecordingRecovery{Root: root}
entry, err := recovery.SaveFailure([]byte("RIFF original audio"), failedRecordingFixture(), &UploadHTTPError{StatusCode: 503})
if err != nil {
t.Fatal(err)
}
infoPath := filepath.Join(root, entry.Bucket, filepath.FromSlash(entry.ObjectKey), "info.json")
original, err := os.ReadFile(infoPath)
if err != nil {
t.Fatal(err)
}
changed := bytes.Replace(original, []byte(`"state"`), []byte(`"unrecognized_recovery_state":true,"state"`), 1)
if bytes.Equal(original, changed) || !json.Valid(changed) {
t.Fatal("test fixture did not preserve valid JSON while adding an unknown field")
}
if err := os.WriteFile(infoPath, changed, 0600); err != nil {
t.Fatal(err)
}
if _, err := recovery.Load(entry.Bucket, entry.ObjectKey); !errors.Is(err, ErrRecordingRecoveryIncomplete) {
t.Fatalf("unknown state fields were silently accepted: %v", err)
}
}