package auditrelay import ( "context" "crypto/sha256" "encoding/base64" "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "testing" "time" ) func testEnvelope(id string) Envelope { return Envelope{SchemaVersion: 1, Event: Event{ EventID: id, EventType: "device.created", TenantID: "tenant", SiteID: "site", DeviceID: "camera-1", Actor: Actor{Type: "system", ID: "sense"}, AggregateGeneration: 1, ProjectionVersions: ProjectionVersions{}, Data: json.RawMessage(`{"kind":"device_created"}`), OccurredAt: time.Unix(1, 0).UTC(), }} } func TestClientSignsCanonicalRequest(t *testing.T) { secret := bytesOf(32, 7) server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { body := make([]byte, request.ContentLength) _, _ = request.Body.Read(body) canonical := CanonicalString(request.Method, request.URL.Path, request.Header.Get(HeaderTimestamp), request.Header.Get(HeaderNonce), body) if request.Header.Get(HeaderSignature) != Signature(secret, canonical) { t.Error("request signature did not match canonical vector") } _ = json.NewEncoder(writer).Encode(BatchResponse{Results: []Result{{EventID: "audit_00000000000000000000000000000001", Status: "accepted"}}}) })) defer server.Close() client, err := NewClient(server.URL+RelayPath, "sense-a", secret, server.Client()) if err != nil { t.Fatal(err) } client.now = func() time.Time { return time.Unix(1_800_000_000, 0) } client.nonce = func() (string, error) { return "AAAAAAAAAAAAAAAAAAAAAA", nil } results, err := client.Send(context.Background(), []Envelope{testEnvelope("audit_00000000000000000000000000000001")}) if err != nil || len(results) != 1 || results[0].Status != "accepted" { t.Fatalf("unexpected relay result: %+v, %v", results, err) } } func TestEndpointAndKeySecurity(t *testing.T) { if _, err := ValidateEndpoint("http://example.com" + RelayPath); err == nil { t.Fatal("remote plaintext relay URL was accepted") } if _, err := ValidateEndpoint("https://example.com" + RelayPath + "?secret=x"); err == nil { t.Fatal("relay URL query was accepted") } secret := bytesOf(32, 9) path := filepath.Join(t.TempDir(), "keys.json") document := map[string]any{"version": 1, "keys": []any{map[string]any{"key_id": "sense-a", "secret_base64url": base64.RawURLEncoding.EncodeToString(secret)}}} raw, _ := json.Marshal(document) if err := os.WriteFile(path, raw, 0o600); err != nil { t.Fatal(err) } loaded, err := LoadKey(path, "sense-a") if err != nil || sha256.Sum256(loaded) != sha256.Sum256(secret) { t.Fatalf("external key was not loaded: %v", err) } } type fakeRepository struct { queued []QueuedEvent completed []Completion } func (*fakeRepository) AuditRelayReady(context.Context) error { return nil } func (f *fakeRepository) ClaimAuditRelayBatch(context.Context, string, int, time.Duration) ([]QueuedEvent, error) { return f.queued, nil } func (f *fakeRepository) CompleteAuditRelayBatch(_ context.Context, _ string, values []Completion) error { f.completed = append([]Completion(nil), values...) return nil } type fakeSender struct { results []Result err error } func (f fakeSender) Send(context.Context, []Envelope) ([]Result, error) { return f.results, f.err } func TestWorkerDispositionAndBackoff(t *testing.T) { first := "audit_00000000000000000000000000000001" second := "audit_00000000000000000000000000000002" repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 10}}} code := "schema_invalid" worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}, {EventID: second, Status: "rejected", ErrorCode: &code}}}, "worker") if err := worker.RelayOnce(context.Background()); err != nil { t.Fatal(err) } if repository.completed[0].Disposition != Delivered || repository.completed[1].Disposition != DeadLetter { t.Fatalf("unexpected dispositions: %+v", repository.completed) } repository.completed = nil worker.sender = fakeSender{err: errors.New("network")} if err := worker.RelayOnce(context.Background()); err == nil { t.Fatal("network failure was hidden") } if repository.completed[0].RetryAfter != time.Second || repository.completed[1].RetryAfter != 300*time.Second { t.Fatalf("retry bounds drifted: %+v", repository.completed) } } func TestWorkerRetriesWholeBatchForIncompleteResponse(t *testing.T) { first := "audit_00000000000000000000000000000001" second := "audit_00000000000000000000000000000002" repository := &fakeRepository{queued: []QueuedEvent{{Envelope: testEnvelope(first), LeaseToken: 1, AttemptCount: 1}, {Envelope: testEnvelope(second), LeaseToken: 2, AttemptCount: 2}}} worker, _ := NewWorker(repository, fakeSender{results: []Result{{EventID: first, Status: "accepted"}}}, "worker") if err := worker.RelayOnce(context.Background()); err == nil { t.Fatal("incomplete response was accepted") } if len(repository.completed) != 2 || repository.completed[0].Disposition != Retry || repository.completed[1].Disposition != Retry { t.Fatalf("incomplete response partially completed the batch: %+v", repository.completed) } } func bytesOf(size int, value byte) []byte { result := make([]byte, size) for index := range result { result[index] = value } return result }