feat: deliver Sense audits to Bell (T-016)
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user