158 lines
6.2 KiB
Go
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)
|
|
}
|
|
}
|