133 lines
5.3 KiB
Go
133 lines
5.3 KiB
Go
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
|
||
|
|
}
|