177 lines
6.1 KiB
Go
177 lines
6.1 KiB
Go
package ingress
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"context"
|
||
|
|
"crypto/rand"
|
||
|
|
"crypto/sha256"
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"net/http"
|
||
|
|
"net/http/httptest"
|
||
|
|
"os"
|
||
|
|
"path/filepath"
|
||
|
|
"strconv"
|
||
|
|
"testing"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"yovision/bell/contracts"
|
||
|
|
"yovision/bell/internal/event"
|
||
|
|
)
|
||
|
|
|
||
|
|
type ingressIDs struct{}
|
||
|
|
|
||
|
|
func (ingressIDs) NewEventID() (string, error) {
|
||
|
|
return "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type ingressPrivacy struct{}
|
||
|
|
|
||
|
|
func (ingressPrivacy) VideoAllowed(context.Context, int64, int64, int64) (bool, error) {
|
||
|
|
return true, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
type fakeRepository struct {
|
||
|
|
replayResult Result
|
||
|
|
replayFound bool
|
||
|
|
replayErr error
|
||
|
|
processErr error
|
||
|
|
processed int
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeRepository) Replay(context.Context, string, string, [sha256.Size]byte, string, string, [sha256.Size]byte) (Result, bool, error) {
|
||
|
|
return f.replayResult, f.replayFound, f.replayErr
|
||
|
|
}
|
||
|
|
|
||
|
|
func (f *fakeRepository) ProcessEvent(_ context.Context, _, _ string, _ [sha256.Size]byte, producer string, _ [sha256.Size]byte, value event.Event) (Result, error) {
|
||
|
|
f.processed++
|
||
|
|
if f.processErr != nil {
|
||
|
|
return Result{}, f.processErr
|
||
|
|
}
|
||
|
|
return Result{SchemaVersion: 1, ProducerID: producer, SourceEventID: value.SourceEventID(), EventID: value.ID(), Status: "accepted", HTTPStatus: 201}, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func ingressCandidate(t *testing.T) []byte {
|
||
|
|
t.Helper()
|
||
|
|
raw, err := os.ReadFile(filepath.Join("..", "..", "..", "docs", "raw", "contracts", "event-v0.1.example-current.json"))
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
var object map[string]any
|
||
|
|
if err := json.Unmarshal(raw, &object); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
delete(object, "id")
|
||
|
|
object["kind"] = "zone_entry"
|
||
|
|
object["severity"] = "medium"
|
||
|
|
object["evidence"] = map[string]any{"snapshot_uris": []any{}, "clip_uri": nil, "clip_range": nil}
|
||
|
|
encoded, err := json.Marshal(object)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
return encoded
|
||
|
|
}
|
||
|
|
|
||
|
|
func newIngressHandler(t *testing.T, repository Repository) (*Handler, []byte) {
|
||
|
|
t.Helper()
|
||
|
|
guard, err := event.NewEvidenceGuard("private-customer")
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
factory, err := event.NewFactory(contracts.EventV01Schema, ingressIDs{}, ingressPrivacy{}, guard)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
secret := make([]byte, 32)
|
||
|
|
if _, err := rand.Read(secret); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
handler, err := NewHandler(repository, map[string]Key{"brain-a": {ProducerID: "brain-main", Secret: secret}}, factory)
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
handler.now = func() time.Time { return time.Unix(1_800_000_000, 0).UTC() }
|
||
|
|
return handler, secret
|
||
|
|
}
|
||
|
|
|
||
|
|
func signedRequest(t *testing.T, secret []byte, producer string, candidate []byte) *http.Request {
|
||
|
|
t.Helper()
|
||
|
|
body, err := json.Marshal(map[string]any{"schema_version": 1, "producer_id": producer, "candidate": json.RawMessage(candidate)})
|
||
|
|
if err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
request := httptest.NewRequest(http.MethodPost, Path, bytes.NewReader(body))
|
||
|
|
timestamp := strconv.FormatInt(1_800_000_000, 10)
|
||
|
|
nonce := base64.RawURLEncoding.EncodeToString([]byte("0123456789abcdef"))
|
||
|
|
request.Header.Set(HeaderKeyID, "brain-a")
|
||
|
|
request.Header.Set(HeaderTimestamp, timestamp)
|
||
|
|
request.Header.Set(HeaderNonce, nonce)
|
||
|
|
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(signature(secret, canonicalString(http.MethodPost, Path, timestamp, nonce, body))))
|
||
|
|
return request
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestHandlerAcceptsSignedCandidateAndRejectsProducerSpoofing(t *testing.T) {
|
||
|
|
repository := &fakeRepository{}
|
||
|
|
handler, secret := newIngressHandler(t, repository)
|
||
|
|
response := httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
|
||
|
|
if response.Code != http.StatusCreated || repository.processed != 1 {
|
||
|
|
t.Fatalf("signed candidate: status=%d body=%s processed=%d", response.Code, response.Body.String(), repository.processed)
|
||
|
|
}
|
||
|
|
var result Result
|
||
|
|
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil || result.Status != "accepted" || result.EventID == "" {
|
||
|
|
t.Fatalf("invalid accepted response: %+v %v", result, err)
|
||
|
|
}
|
||
|
|
|
||
|
|
response = httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(response, signedRequest(t, secret, "brain-spoofed", ingressCandidate(t)))
|
||
|
|
if response.Code != http.StatusUnauthorized || repository.processed != 1 {
|
||
|
|
t.Fatalf("producer spoofing was not rejected: %d %s", response.Code, response.Body.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestHandlerReturnsDurableReplayAndStableConflicts(t *testing.T) {
|
||
|
|
repository := &fakeRepository{replayFound: true, replayResult: Result{
|
||
|
|
SchemaVersion: 1, ProducerID: "brain-main", SourceEventID: "source-1",
|
||
|
|
EventID: "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B", Status: "duplicate", HTTPStatus: 200,
|
||
|
|
}}
|
||
|
|
handler, secret := newIngressHandler(t, repository)
|
||
|
|
response := httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
|
||
|
|
if response.Code != http.StatusOK || repository.processed != 0 {
|
||
|
|
t.Fatalf("durable replay did not bypass factory persistence: %d", response.Code)
|
||
|
|
}
|
||
|
|
|
||
|
|
repository.replayFound = false
|
||
|
|
repository.replayErr = ErrSourceConflict
|
||
|
|
response = httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", ingressCandidate(t)))
|
||
|
|
if response.Code != http.StatusConflict {
|
||
|
|
t.Fatalf("source conflict status=%d body=%s", response.Code, response.Body.String())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func TestHandlerRejectsInvalidSignatureAndUpstreamPlatformID(t *testing.T) {
|
||
|
|
repository := &fakeRepository{}
|
||
|
|
handler, secret := newIngressHandler(t, repository)
|
||
|
|
request := signedRequest(t, secret, "brain-main", ingressCandidate(t))
|
||
|
|
request.Header.Set(HeaderSignature, base64.RawURLEncoding.EncodeToString(make([]byte, 32)))
|
||
|
|
response := httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(response, request)
|
||
|
|
if response.Code != http.StatusUnauthorized {
|
||
|
|
t.Fatalf("invalid signature status=%d", response.Code)
|
||
|
|
}
|
||
|
|
|
||
|
|
var object map[string]any
|
||
|
|
if err := json.Unmarshal(ingressCandidate(t), &object); err != nil {
|
||
|
|
t.Fatal(err)
|
||
|
|
}
|
||
|
|
object["id"] = "evt_01J8XQ2K7M3P5R9T0V4W6Y8Z2B"
|
||
|
|
withID, _ := json.Marshal(object)
|
||
|
|
response = httptest.NewRecorder()
|
||
|
|
handler.ServeHTTP(response, signedRequest(t, secret, "brain-main", withID))
|
||
|
|
if response.Code != http.StatusUnprocessableEntity || repository.processed != 0 {
|
||
|
|
t.Fatalf("upstream ID status=%d body=%s", response.Code, response.Body.String())
|
||
|
|
}
|
||
|
|
}
|