feat: 实现可靠事件与证据 connector (#153)

This commit is contained in:
QiuSW
2026-08-31 14:57:48 +08:00
parent cf00d73436
commit 04c5deecfb
36 changed files with 4102 additions and 14 deletions
@@ -0,0 +1,117 @@
package event_ingress
import (
"bytes"
"context"
"crypto/rand"
"crypto/tls"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"gorm.io/gorm"
"go-admin/app/bell/integration/machine_identity"
)
type EvidenceClient struct {
Endpoint string
Signer machine_identity.Signer
HTTP interface {
Do(*http.Request) (*http.Response, error)
}
}
func NewEvidenceClient(endpoint string, signer machine_identity.Signer) (*EvidenceClient, error) {
parsed, err := url.Parse(endpoint)
if err != nil || parsed.Scheme != "https" || parsed.Host == "" || parsed.User != nil || parsed.Path != "" || parsed.RawQuery != "" || parsed.Fragment != "" {
return nil, errors.New("Sense evidence endpoint must be an HTTPS origin without userinfo")
}
transport := &http.Transport{TLSClientConfig: &tls.Config{MinVersion: tls.VersionTLS12}, TLSHandshakeTimeout: 5 * time.Second, ResponseHeaderTimeout: 5 * time.Second}
return &EvidenceClient{Endpoint: strings.TrimRight(endpoint, "/"), Signer: signer, HTTP: &http.Client{Transport: transport, Timeout: 8 * time.Second}}, nil
}
func (c EvidenceClient) Refresh(ctx context.Context, db *gorm.DB, status EvidenceStatus) error {
path := "/v1/evidence/" + status.EvidenceID
token, err := c.Signer.Mint("yovision-sense", []string{"evidence:read"}, http.MethodGet, path, nil)
if err != nil {
return c.degrade(db, status, "unavailable", "machine_identity_error")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Endpoint+path, nil)
if err != nil {
return c.degrade(db, status, "unavailable", "invalid_request")
}
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-Request-ID", newCorrelationID())
response, err := c.HTTP.Do(request)
if err != nil {
code := "evidence_unavailable"
if errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded) {
code = "evidence_timeout"
}
return c.degrade(db, status, "unavailable", code)
}
defer response.Body.Close()
body, readErr := io.ReadAll(io.LimitReader(response.Body, 64*1024+1))
if readErr != nil || len(body) > 64*1024 {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
if response.StatusCode == http.StatusNotFound {
return c.degrade(db, status, "unavailable", "evidence_not_found")
}
if response.StatusCode == http.StatusGone {
return c.degrade(db, status, "expired", "evidence_expired")
}
if response.StatusCode != http.StatusOK {
return c.degrade(db, status, "unavailable", "evidence_unavailable")
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.DisallowUnknownFields()
var evidence Evidence
if err = decoder.Decode(&evidence); err != nil || evidence.EvidenceID != status.EvidenceID || evidence.OwnerID != status.OwnerID || validateEvidence(evidence) != nil {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
canonical, err := canonicalJSON(body)
if err != nil {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
now := time.Now().UTC()
var expiresAt *time.Time
if evidence.ExpiresAt != "" {
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
if parseErr != nil {
return c.degrade(db, status, "unavailable", "invalid_evidence_response")
}
parsedExpiry = parsedExpiry.UTC()
expiresAt = &parsedExpiry
}
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"status": evidence.Status, "resolution": "current", "current_payload": canonical, "last_error": "", "expires_at": expiresAt, "checked_at": now, "updated_at": now}).Error
}
func (c EvidenceClient) degrade(db *gorm.DB, status EvidenceStatus, resolution, code string) error {
now := time.Now().UTC()
return db.Model(&EvidenceStatus{}).Where("event_id = ? AND evidence_id = ?", status.EventID, status.EvidenceID).Updates(map[string]any{"resolution": resolution, "last_error": code, "checked_at": now, "updated_at": now}).Error
}
func newCorrelationID() string {
raw := make([]byte, 16)
if _, err := rand.Read(raw); err != nil {
return "request-id-fallback"
}
return base64.RawURLEncoding.EncodeToString(raw)
}
func LoadEvidenceClient(getenv func(string) string) (*EvidenceClient, error) {
key, err := machine_identity.LoadPrivateKey(getenv("BELL_SENSE_PRIVATE_KEY_PATH"))
if err != nil {
return nil, fmt.Errorf("load Bell evidence key: %w", err)
}
signer := machine_identity.Signer{Principal: strings.TrimSpace(getenv("BELL_SENSE_PRINCIPAL_ID")), KeyID: strings.TrimSpace(getenv("BELL_SENSE_KEY_ID")), PrivateKey: key}
return NewEvidenceClient(strings.TrimSpace(getenv("BELL_SENSE_EVIDENCE_ENDPOINT")), signer)
}
@@ -0,0 +1,107 @@
package event_ingress
import (
"errors"
"io"
"net/http"
"regexp"
"strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"go-admin/app/bell/integration/machine_identity"
)
const MaxRequestBytes = 64 * 1024
var requestIDPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{15,127}$`)
type Handler struct {
DB *gorm.DB
Verifier machine_identity.Verifier
Enabled bool
Resolver EvidenceRefresher
}
func (h Handler) Post(c *gin.Context) {
if !h.Enabled {
writeProblem(c, http.StatusServiceUnavailable, "connector_disabled", "event connector is disabled", "")
return
}
requestID := c.GetHeader("X-Request-ID")
if requestID == "" {
requestID = uuid.NewString()
} else if !requestIDPattern.MatchString(requestID) {
writeProblem(c, http.StatusBadRequest, "invalid_request_id", "X-Request-ID must be an opaque 16-128 character value", "")
return
}
c.Header("X-Request-ID", requestID)
if c.Request.URL.RawQuery != "" || c.Request.URL.Fragment != "" || c.Request.URL.EscapedPath() != "/v1/events" {
writeProblem(c, http.StatusBadRequest, "invalid_request_target", "event request target must be the normalized /v1/events path", "")
return
}
if relayHeader := c.GetHeader("X-YoVision-Relay-ID"); relayHeader != "" {
relayID := strings.TrimSpace(relayHeader)
if relayID != relayHeader || !validID(relayID) {
writeProblem(c, http.StatusBadRequest, "invalid_event", "relay identity header is invalid", "")
return
}
}
body, err := io.ReadAll(http.MaxBytesReader(c.Writer, c.Request.Body, MaxRequestBytes))
if err != nil {
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload is invalid or too large", "")
return
}
token, err := machine_identity.BearerToken(c.GetHeader("Authorization"))
if err != nil {
writeProblem(c, http.StatusUnauthorized, machineErrorCode(err), "machine identity was rejected", "")
return
}
if _, err = h.Verifier.Verify(token, "yovision-bell", "events:ingest", c.Request.Method, c.Request.URL.EscapedPath(), body); err != nil {
status := http.StatusUnauthorized
code := machineErrorCode(err)
if code == "machine_scope_denied" || code == "machine_audience_denied" {
status = http.StatusForbidden
}
writeProblem(c, status, code, "machine identity was rejected", "")
return
}
parsed, err := ParseEvent(body)
if err != nil {
if errors.Is(err, ErrUnsupportedSchema) {
writeProblem(c, http.StatusUnprocessableEntity, "unsupported_schema_version", "event schema version is unsupported", "")
return
}
writeProblem(c, http.StatusBadRequest, "invalid_event", "event payload failed validation", "")
return
}
result, err := (Service{DB: h.DB, Resolver: h.Resolver}).Ingest(c.Request.Context(), parsed)
if errors.Is(err, ErrIdempotencyConflict) {
writeProblem(c, http.StatusConflict, "idempotency_conflict", "idempotency key is already bound to another payload", result.EventID)
return
}
if err != nil {
writeProblem(c, http.StatusServiceUnavailable, "ingest_unavailable", "event ingest is temporarily unavailable", "")
return
}
status := http.StatusCreated
if result.Disposition == "duplicate" {
status = http.StatusOK
}
c.JSON(status, result)
}
func writeProblem(c *gin.Context, status int, code, message, existing string) {
c.Header("Content-Type", "application/problem+json")
c.JSON(status, Problem{Code: code, Message: message, ExistingEventID: existing})
}
func machineErrorCode(err error) string {
var machineErr *machine_identity.Error
if errors.As(err, &machineErr) {
return machineErr.Code
}
return "machine_token_invalid"
}
@@ -0,0 +1,116 @@
package event_ingress
import (
"encoding/json"
"time"
)
const (
EventSchemaVersion = "yovision.event/v1"
EvidenceSchemaVersion = "yovision.evidence-reference/v1"
)
type Event struct {
SchemaVersion string `json:"schema_version"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
SiteRef string `json:"site_ref"`
DeviceRef string `json:"device_ref"`
ProfileRef string `json:"profile_ref"`
EventType string `json:"event_type"`
OccurredAt string `json:"occurred_at"`
Severity string `json:"severity"`
Rule Rule `json:"rule"`
Model Model `json:"model"`
Observation Observation `json:"observation"`
Region Region `json:"region"`
Evidence []Evidence `json:"evidence"`
}
type Rule struct {
RuleID string `json:"rule_id"`
Version string `json:"version"`
}
type Model struct {
Name string `json:"name"`
Version string `json:"version"`
}
type Observation struct {
TrackID string `json:"track_id"`
Category string `json:"category"`
Confidence float64 `json:"confidence"`
BBoxNormalized []float64 `json:"bbox_normalized,omitempty"`
}
type Region struct {
RegionID string `json:"region_id"`
Kind string `json:"kind"`
CrossingDirection string `json:"crossing_direction,omitempty"`
}
type Evidence struct {
SchemaVersion string `json:"schema_version"`
EvidenceID string `json:"evidence_id"`
OwnerID string `json:"owner_id"`
Type string `json:"type"`
Status string `json:"status"`
CapturedAt string `json:"captured_at"`
StatusUpdatedAt string `json:"status_updated_at"`
ExpiresAt string `json:"expires_at,omitempty"`
ContentType string `json:"content_type,omitempty"`
Integrity *EvidenceIntegrity `json:"integrity,omitempty"`
Failure *EvidenceFailure `json:"failure,omitempty"`
}
type EvidenceIntegrity struct {
Algorithm string `json:"algorithm"`
Digest string `json:"digest"`
SizeBytes int64 `json:"size_bytes"`
}
type EvidenceFailure struct {
Code string `json:"code"`
Retryable bool `json:"retryable"`
}
type IngestResult struct {
EventID string `json:"event_id"`
ProducerID string `json:"producer_id"`
SourceEventID string `json:"source_event_id"`
Disposition string `json:"disposition"`
PayloadSHA256 string `json:"payload_sha256"`
}
type Problem struct {
Code string `json:"code"`
Message string `json:"message"`
Field string `json:"field,omitempty"`
ExistingEventID string `json:"existing_event_id,omitempty"`
}
type ParsedEvent struct {
Event Event
Canonical json.RawMessage
Digest string
Occurred time.Time
}
// EvidenceStatus is mutable Bell-owned resolution metadata kept separately
// from the immutable Event and from Alert acknowledgement/close facts.
type EvidenceStatus struct {
EventID string `gorm:"type:uuid;primaryKey"`
EvidenceID string `gorm:"size:128;primaryKey"`
OwnerID string `gorm:"size:128;not null;index"`
Status string `gorm:"size:16;not null"`
Resolution string `gorm:"size:16;not null;index"`
CurrentPayload json.RawMessage `gorm:"column:current_payload;type:jsonb;not null"`
LastError string `gorm:"size:64;not null;default:''"`
ExpiresAt *time.Time `gorm:"index"`
CheckedAt *time.Time
CreatedAt time.Time `gorm:"not null"`
UpdatedAt time.Time `gorm:"not null"`
}
func (EvidenceStatus) TableName() string { return "bell_evidence_status" }
@@ -0,0 +1,42 @@
package event_ingress
import (
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ReplayToken is Bell-owned security state. It is intentionally separate from
// business Receipt idempotency and remains effective across process restarts.
type ReplayToken struct {
Principal string `gorm:"size:128;primaryKey"`
TokenID string `gorm:"size:64;primaryKey"`
ExpiresAt time.Time `gorm:"not null;index"`
CreatedAt time.Time `gorm:"not null"`
}
func (ReplayToken) TableName() string { return "bell_machine_token_replays" }
type PersistentReplayStore struct{ DB *gorm.DB }
func (s PersistentReplayStore) Consume(principal, tokenID string, expiresAt, now time.Time) bool {
if s.DB == nil {
return false
}
accepted := false
err := s.DB.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("expires_at <= ?", now.UTC()).Delete(&ReplayToken{}).Error; err != nil {
return err
}
result := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&ReplayToken{
Principal: principal, TokenID: tokenID, ExpiresAt: expiresAt.UTC(), CreatedAt: now.UTC(),
})
if result.Error != nil {
return result.Error
}
accepted = result.RowsAffected == 1
return nil
})
return err == nil && accepted
}
@@ -0,0 +1,40 @@
package event_ingress
import (
"fmt"
"os"
"strings"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"go-admin/app/bell/integration/machine_identity"
)
func RegisterRuntime(engine *gin.Engine) error {
enabled := strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVENT_INGRESS_ENABLED")), "true")
if !enabled {
return nil
}
db := sdk.Runtime.GetDbByKey("")
if db == nil {
return fmt.Errorf("Bell event ingress database is unavailable")
}
if !db.Migrator().HasTable(&ReplayToken{}) || !db.Migrator().HasTable(&EvidenceStatus{}) {
return fmt.Errorf("Bell event ingress migration is required")
}
registry, err := machine_identity.LoadRegistry(os.Getenv("BELL_MACHINE_PRINCIPAL_REGISTRY"), "yovision-bell")
if err != nil {
return fmt.Errorf("load Bell machine identity registry: %w", err)
}
var resolver EvidenceRefresher
if strings.EqualFold(strings.TrimSpace(os.Getenv("BELL_EVIDENCE_RESOLVER_ENABLED")), "true") {
resolver, err = LoadEvidenceClient(os.Getenv)
if err != nil {
return fmt.Errorf("load Bell evidence resolver: %w", err)
}
}
handler := Handler{DB: db, Enabled: true, Resolver: resolver, Verifier: machine_identity.Verifier{Registry: registry, Replay: PersistentReplayStore{DB: db}}}
engine.POST("/v1/events", handler.Post)
return nil
}
@@ -0,0 +1,125 @@
package event_ingress
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"go-admin/app/bell/event"
"go-admin/app/bell/receipt"
)
var ErrIdempotencyConflict = errors.New("idempotency_conflict")
type EvidenceRefresher interface {
Refresh(context.Context, *gorm.DB, EvidenceStatus) error
}
type Service struct {
DB *gorm.DB
Resolver EvidenceRefresher
}
func (s Service) Ingest(ctx context.Context, parsed ParsedEvent) (IngestResult, error) {
if s.DB == nil {
return IngestResult{}, errors.New("event database is unavailable")
}
var output IngestResult
err := s.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if tx.Dialector.Name() == "postgres" {
key := fmt.Sprintf("%d:%s:%s", len(parsed.Event.ProducerID), parsed.Event.ProducerID, parsed.Event.SourceEventID)
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtextextended(?, 0))", key).Error; err != nil {
return err
}
}
var existing struct {
EventID string
PayloadSHA256 string
}
err := tx.Model(&receipt.Receipt{}).Clauses(clause.Locking{Strength: "UPDATE"}).
Select("event_id", "payload_sha256").Where("producer_id = ? AND source_event_id = ?", parsed.Event.ProducerID, parsed.Event.SourceEventID).First(&existing).Error
if err == nil {
if existing.PayloadSHA256 != parsed.Digest {
output.EventID = existing.EventID
return ErrIdempotencyConflict
}
output = IngestResult{EventID: existing.EventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "duplicate", PayloadSHA256: parsed.Digest}
return tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeReplay, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
now := time.Now().UTC()
eventID := uuid.NewString()
var evidenceRef *string
if len(parsed.Event.Evidence) > 0 {
value := parsed.Event.Evidence[0].EvidenceID
evidenceRef = &value
}
item := event.Event{ID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID,
EventType: parsed.Event.EventType, OccurredAt: parsed.Occurred, Location: parsed.Event.SiteRef + "/" + parsed.Event.DeviceRef,
Severity: parsed.Event.Severity, EvidenceRef: evidenceRef, NormalizedPayload: parsed.Canonical, PayloadSHA256: parsed.Digest, ReceivedAt: now}
receiptItem := receipt.Receipt{ID: uuid.NewString(), EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, AcceptedAt: now}
if err := tx.Create(&item).Error; err != nil {
return err
}
if err := tx.Create(&receiptItem).Error; err != nil {
return err
}
for _, evidence := range parsed.Event.Evidence {
payload, marshalErr := json.Marshal(evidence)
if marshalErr != nil {
return marshalErr
}
canonical, canonicalErr := canonicalJSON(payload)
if canonicalErr != nil {
return canonicalErr
}
var expiresAt *time.Time
if evidence.ExpiresAt != "" {
parsedExpiry, parseErr := time.Parse(time.RFC3339Nano, evidence.ExpiresAt)
if parseErr != nil {
return parseErr
}
parsedExpiry = parsedExpiry.UTC()
expiresAt = &parsedExpiry
}
status := EvidenceStatus{EventID: eventID, EvidenceID: evidence.EvidenceID, OwnerID: evidence.OwnerID,
Status: evidence.Status, Resolution: "snapshot", CurrentPayload: canonical, ExpiresAt: expiresAt, CreatedAt: now, UpdatedAt: now}
if err := tx.Create(&status).Error; err != nil {
return err
}
}
if err := tx.Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeAccepted, ActorID: 0, CreatedAt: now}).Error; err != nil {
return err
}
output = IngestResult{EventID: eventID, ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, Disposition: "created", PayloadSHA256: parsed.Digest}
return nil
})
if errors.Is(err, ErrIdempotencyConflict) {
auditErr := s.DB.WithContext(ctx).Create(&receipt.IngestAudit{ProducerID: parsed.Event.ProducerID, SourceEventID: parsed.Event.SourceEventID, PayloadSHA256: parsed.Digest, Outcome: receipt.OutcomeConflict, ActorID: 0, CreatedAt: time.Now().UTC()}).Error
if auditErr != nil {
return IngestResult{}, fmt.Errorf("record conflict audit: %w", auditErr)
}
return output, ErrIdempotencyConflict
}
if err != nil || s.Resolver == nil {
return output, err
}
var statuses []EvidenceStatus
if err = s.DB.WithContext(ctx).Where("event_id = ?", output.EventID).Find(&statuses).Error; err != nil {
return IngestResult{}, err
}
for _, status := range statuses {
// Evidence lookup is supplementary. The immutable Event/Receipt boundary
// remains accepted even when Sense is unavailable.
_ = s.Resolver.Refresh(ctx, s.DB.WithContext(ctx), status)
}
return output, nil
}
@@ -0,0 +1,217 @@
package event_ingress
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"regexp"
"strconv"
"strings"
"time"
)
var (
ErrInvalidEvent = errors.New("invalid_event")
ErrUnsupportedSchema = errors.New("unsupported_schema_version")
identifierPattern = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$`)
hexDigestPattern = regexp.MustCompile(`^[a-f0-9]{64}$`)
)
func ParseEvent(raw []byte) (ParsedEvent, error) {
var event Event
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&event); err != nil {
return ParsedEvent{}, fmt.Errorf("%w: malformed or unknown member", ErrInvalidEvent)
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return ParsedEvent{}, fmt.Errorf("%w: trailing JSON value", ErrInvalidEvent)
}
if event.SchemaVersion == "" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.SchemaVersion != EventSchemaVersion {
return ParsedEvent{}, ErrUnsupportedSchema
}
occurred, err := time.Parse("2006-01-02T15:04:05.000Z", event.OccurredAt)
if err != nil || !validID(event.ProducerID) || !validID(event.SourceEventID) || !validID(event.SiteRef) ||
!validID(event.DeviceRef) || !validID(event.ProfileRef) || !validID(event.Rule.RuleID) ||
!validID(event.Observation.TrackID) || !validID(event.Region.RegionID) || event.Rule.Version == "" ||
len(event.Rule.Version) > 64 || event.Model.Name == "" || len(event.Model.Name) > 128 ||
event.Model.Version == "" || len(event.Model.Version) > 64 || event.Observation.Confidence < 0 ||
event.Observation.Confidence > 1 || math.IsNaN(event.Observation.Confidence) || math.IsInf(event.Observation.Confidence, 0) {
return ParsedEvent{}, ErrInvalidEvent
}
if event.EventType != "dangerous_area_entered" && event.EventType != "directional_line_crossed" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Severity != "low" && event.Severity != "medium" && event.Severity != "high" && event.Severity != "critical" {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Observation.Category != "person" && event.Observation.Category != "vehicle" && event.Observation.Category != "other" {
return ParsedEvent{}, ErrInvalidEvent
}
if len(event.Observation.BBoxNormalized) != 0 && len(event.Observation.BBoxNormalized) != 4 {
return ParsedEvent{}, ErrInvalidEvent
}
for _, value := range event.Observation.BBoxNormalized {
if value < 0 || value > 1 || math.IsNaN(value) || math.IsInf(value, 0) {
return ParsedEvent{}, ErrInvalidEvent
}
}
if (event.EventType == "dangerous_area_entered" && (event.Region.Kind != "area" || event.Region.CrossingDirection != "")) ||
(event.EventType == "directional_line_crossed" && (event.Region.Kind != "line" || (event.Region.CrossingDirection != "a_to_b" && event.Region.CrossingDirection != "b_to_a"))) {
return ParsedEvent{}, ErrInvalidEvent
}
if event.Evidence == nil || len(event.Evidence) > 8 {
return ParsedEvent{}, ErrInvalidEvent
}
seenEvidence := map[string]bool{}
for _, evidence := range event.Evidence {
evidenceJSON, marshalErr := json.Marshal(evidence)
canonicalEvidence, canonicalErr := canonicalJSON(evidenceJSON)
if err := validateEvidence(evidence); err != nil || marshalErr != nil || canonicalErr != nil || seenEvidence[string(canonicalEvidence)] {
return ParsedEvent{}, ErrInvalidEvent
}
seenEvidence[string(canonicalEvidence)] = true
}
canonical, err := canonicalJSON(raw)
if err != nil {
return ParsedEvent{}, ErrInvalidEvent
}
if containsExplicitNull(raw) {
return ParsedEvent{}, fmt.Errorf("%w: optional members must be omitted", ErrInvalidEvent)
}
digest := sha256.Sum256(canonical)
return ParsedEvent{Event: event, Canonical: canonical, Digest: hex.EncodeToString(digest[:]), Occurred: occurred}, nil
}
func validateEvidence(value Evidence) error {
if value.SchemaVersion != EvidenceSchemaVersion || !validID(value.EvidenceID) || !validID(value.OwnerID) ||
(value.Type != "snapshot" && value.Type != "clip") {
return ErrInvalidEvent
}
if _, err := time.Parse(time.RFC3339Nano, value.CapturedAt); err != nil {
return ErrInvalidEvent
}
if _, err := time.Parse(time.RFC3339Nano, value.StatusUpdatedAt); err != nil {
return ErrInvalidEvent
}
if value.ExpiresAt != "" {
if _, err := time.Parse(time.RFC3339Nano, value.ExpiresAt); err != nil {
return ErrInvalidEvent
}
}
switch value.Status {
case "pending", "processing":
if value.ContentType != "" || value.Integrity != nil || value.Failure != nil {
return ErrInvalidEvent
}
case "success":
if value.Integrity == nil || value.Failure != nil || (value.ContentType != "image/jpeg" && value.ContentType != "image/png" && value.ContentType != "video/mp4") ||
value.Integrity.Algorithm != "sha256" || !hexDigestPattern.MatchString(value.Integrity.Digest) || value.Integrity.SizeBytes < 0 {
return ErrInvalidEvent
}
case "failed":
if value.Failure == nil || value.ContentType != "" || value.Integrity != nil ||
(value.Failure.Code != "capture_failed" && value.Failure.Code != "processing_failed" && value.Failure.Code != "expired" && value.Failure.Code != "unavailable") {
return ErrInvalidEvent
}
default:
return ErrInvalidEvent
}
return nil
}
func canonicalJSON(raw []byte) ([]byte, error) {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
return nil, err
}
value, err := normalizeJCSNumbers(value)
if err != nil {
return nil, err
}
var buffer bytes.Buffer
encoder := json.NewEncoder(&buffer)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(value); err != nil {
return nil, err
}
canonical := bytes.TrimSuffix(buffer.Bytes(), []byte("\n"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2028`), []byte("\u2028"))
canonical = bytes.ReplaceAll(canonical, []byte(`\u2029`), []byte("\u2029"))
return canonical, nil
}
func normalizeJCSNumbers(value any) (any, error) {
switch typed := value.(type) {
case json.Number:
number, err := strconv.ParseFloat(string(typed), 64)
if err != nil || math.IsNaN(number) || math.IsInf(number, 0) {
return nil, errors.New("JSON number is outside the RFC 8785 domain")
}
if number == 0 {
return float64(0), nil
}
return number, nil
case []any:
for index, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[index] = normalized
}
case map[string]any:
for key, item := range typed {
normalized, err := normalizeJCSNumbers(item)
if err != nil {
return nil, err
}
typed[key] = normalized
}
}
return value, nil
}
func containsExplicitNull(raw []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(raw))
decoder.UseNumber()
var value any
if decoder.Decode(&value) != nil {
return true
}
return hasNull(value)
}
func hasNull(value any) bool {
switch typed := value.(type) {
case nil:
return true
case []any:
for _, item := range typed {
if hasNull(item) {
return true
}
}
case map[string]any:
for _, item := range typed {
if hasNull(item) {
return true
}
}
}
return false
}
func validID(value string) bool {
return identifierPattern.MatchString(value) && !strings.ContainsAny(strings.ToLower(value), "\\/@")
}
+4
View File
@@ -9,6 +9,7 @@ import (
"github.com/go-admin-team/go-admin-core/sdk/config"
jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth"
"go-admin/app/bell/integration/event_ingress"
"go-admin/app/bell/synthetic"
"go-admin/common/middleware"
)
@@ -32,6 +33,9 @@ func InitRouter() {
for _, register := range registrars {
register(v1, authMiddleware)
}
if err := event_ingress.RegisterRuntime(engine); err != nil {
log.Errorf("Bell event ingress init error: %v", err)
}
if synthetic.Enabled(config.ApplicationConfig.Mode, os.Getenv) {
registerSyntheticRouter(v1, authMiddleware)
}
@@ -0,0 +1,29 @@
package version
import (
"runtime"
"go-admin/app/bell/integration/event_ingress"
"go-admin/cmd/migrate/migration"
common "go-admin/common/models"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
func init() {
_, fileName, _, _ := runtime.Caller(0)
migration.Migrate.SetVersion(migration.GetFilename(fileName), migrateBellEventIngress)
}
func migrateBellEventIngress(db *gorm.DB, version string) error {
return db.Transaction(func(tx *gorm.DB) error {
if err := tx.AutoMigrate(
&event_ingress.ReplayToken{},
&event_ingress.EvidenceStatus{},
); err != nil {
return err
}
return tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&common.Migration{Version: version}).Error
})
}
@@ -0,0 +1,65 @@
package version
import (
"testing"
"time"
"go-admin/app/bell/integration/event_ingress"
common "go-admin/common/models"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestBellEventIngressMigrationIsIdempotent(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&common.Migration{}); err != nil {
t.Fatal(err)
}
const version = "2026083112000"
for attempt := 0; attempt < 2; attempt++ {
if err = migrateBellEventIngress(db, version); err != nil {
t.Fatalf("migration attempt %d: %v", attempt+1, err)
}
}
for name, model := range map[string]any{
"replay tokens": &event_ingress.ReplayToken{},
"evidence statuses": &event_ingress.EvidenceStatus{},
} {
if !db.Migrator().HasTable(model) {
t.Fatalf("%s table missing", name)
}
var count int64
if err = db.Model(model).Count(&count).Error; err != nil {
t.Fatalf("count %s: %v", name, err)
}
if count != 0 {
t.Fatalf("migration inserted %d %s fixtures", count, name)
}
}
if !db.Migrator().HasIndex(&event_ingress.ReplayToken{}, "ExpiresAt") {
t.Fatal("replay expiry index missing")
}
now := time.Now().UTC()
token := event_ingress.ReplayToken{Principal: "brain", TokenID: "token-1", ExpiresAt: now.Add(time.Minute), CreatedAt: now}
if err = db.Create(&token).Error; err != nil {
t.Fatal(err)
}
if err = db.Create(&token).Error; err == nil {
t.Fatal("duplicate replay token accepted")
}
var applied int64
if err = db.Model(&common.Migration{}).Where("version = ?", version).Count(&applied).Error; err != nil {
t.Fatal(err)
}
if applied != 1 {
t.Fatalf("migration records=%d, want 1", applied)
}
}
@@ -0,0 +1,404 @@
package event_ingress_test
import (
"bytes"
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/go-admin-team/go-admin-core/sdk"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"go-admin/app/bell/event"
"go-admin/app/bell/integration/event_ingress"
"go-admin/app/bell/integration/machine_identity"
"go-admin/app/bell/receipt"
)
func TestPostgresConcurrentBusinessAndSecurityIdempotency(t *testing.T) {
dsn := os.Getenv("BELL_EVENT_INGRESS_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("set BELL_EVENT_INGRESS_TEST_DATABASE_URL to run PostgreSQL concurrency verification")
}
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
t.Fatal(err)
}
parsed, err := event_ingress.ParseEvent(fixture(t, "dangerous-area.json"))
if err != nil {
t.Fatal(err)
}
const workers = 12
var created, duplicate, failures atomic.Int32
var wait sync.WaitGroup
for range workers {
wait.Add(1)
go func() {
defer wait.Done()
result, ingestErr := (event_ingress.Service{DB: db}).Ingest(context.Background(), parsed)
if ingestErr != nil {
failures.Add(1)
return
}
if result.Disposition == "created" {
created.Add(1)
} else if result.Disposition == "duplicate" {
duplicate.Add(1)
}
}()
}
wait.Wait()
if created.Load() != 1 || duplicate.Load() != workers-1 || failures.Load() != 0 {
t.Fatalf("concurrent ingest created=%d duplicate=%d failures=%d", created.Load(), duplicate.Load(), failures.Load())
}
now := time.Now().UTC()
var consumed atomic.Int32
for range workers {
wait.Add(1)
go func() {
defer wait.Done()
if (event_ingress.PersistentReplayStore{DB: db}).Consume("yv:sense:school-a", "concurrent-token-id-0001", now.Add(time.Minute), now) {
consumed.Add(1)
}
}()
}
wait.Wait()
if consumed.Load() != 1 {
t.Fatalf("concurrent replay consume accepted %d requests", consumed.Load())
}
}
func TestRuntimeRegistrationIsOptionalAndMigrationGated(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "")
disabled := gin.New()
if err := event_ingress.RegisterRuntime(disabled); err != nil || len(disabled.Routes()) != 0 {
t.Fatalf("disabled runtime err=%v routes=%#v", err, disabled.Routes())
}
db, err := gorm.Open(sqlite.Open("file:bell-runtime?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
sdk.Runtime.SetDb("", db)
t.Cleanup(func() { sdk.Runtime.SetDb("", nil) })
t.Setenv("BELL_EVENT_INGRESS_ENABLED", "true")
if err = event_ingress.RegisterRuntime(gin.New()); err == nil {
t.Fatal("enabled runtime started without formal migration")
}
if err = db.AutoMigrate(&event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
t.Fatal(err)
}
registryPath := writeRegistry(t, "yovision-bell", "yv:sense:school-a", "sense-key-0001")
t.Setenv("BELL_MACHINE_PRINCIPAL_REGISTRY", registryPath)
registered := gin.New()
if err = event_ingress.RegisterRuntime(registered); err != nil {
t.Fatalf("enabled runtime did not register after migration: %v", err)
}
routes := registered.Routes()
if len(routes) != 1 || routes[0].Method != http.MethodPost || routes[0].Path != "/v1/events" {
t.Fatalf("unexpected ingress routes: %#v", routes)
}
}
func TestContractFixtureIdempotencyConflictAndReplayPersistence(t *testing.T) {
gin.SetMode(gin.TestMode)
databasePath := filepath.Join(t.TempDir(), "bell-ingress.sqlite")
db := openDatabasePath(t, databasePath)
body := fixture(t, "dangerous-area.json")
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
registry, err := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
if err != nil {
t.Fatal(err)
}
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
signer := machine_identity.Signer{Principal: "yv:sense:school-a", KeyID: "sense-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
newHandler := func() event_ingress.Handler {
return event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
}
firstToken := mint(t, signer, body)
first := request(t, newHandler(), body, firstToken)
if first.Code != http.StatusCreated {
t.Fatalf("first ingest status=%d body=%s", first.Code, first.Body.String())
}
var created event_ingress.IngestResult
decode(t, first, &created)
if created.Disposition != "created" || created.PayloadSHA256 != "4cc1e93820195caf713ea675ff33f178c9d4997dd8a81cb61287e9fea0e3d5e1" {
t.Fatalf("unexpected created result: %+v", created)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
if err = sqlDatabase.Close(); err != nil {
t.Fatal(err)
}
db = openDatabasePath(t, databasePath)
// A new process-local Handler and replay store still reject the old token,
// proving that security replay state is durable rather than in-memory.
replayedToken := request(t, newHandler(), body, firstToken)
if replayedToken.Code != http.StatusUnauthorized || !strings.Contains(replayedToken.Body.String(), "machine_token_replayed") {
t.Fatalf("token replay status=%d body=%s", replayedToken.Code, replayedToken.Body.String())
}
duplicate := request(t, newHandler(), body, mint(t, signer, body))
if duplicate.Code != http.StatusOK {
t.Fatalf("business duplicate status=%d body=%s", duplicate.Code, duplicate.Body.String())
}
var duplicateResult event_ingress.IngestResult
decode(t, duplicate, &duplicateResult)
if duplicateResult.Disposition != "duplicate" || duplicateResult.EventID != created.EventID {
t.Fatalf("duplicate did not retain event identity: %+v", duplicateResult)
}
numericVariant := bytes.Replace(body, []byte(`0.93`), []byte(`0.930`), 1)
numericDuplicate := request(t, newHandler(), numericVariant, mint(t, signer, numericVariant))
if numericDuplicate.Code != http.StatusOK || !strings.Contains(numericDuplicate.Body.String(), created.PayloadSHA256) {
t.Fatalf("JCS-equivalent numeric payload was not a duplicate: %d %s", numericDuplicate.Code, numericDuplicate.Body.String())
}
var changed map[string]any
if err = json.Unmarshal(body, &changed); err != nil {
t.Fatal(err)
}
changed["severity"] = "critical"
conflicting, _ := json.Marshal(changed)
conflict := request(t, newHandler(), conflicting, mint(t, signer, conflicting))
if conflict.Code != http.StatusConflict || !strings.Contains(conflict.Body.String(), "idempotency_conflict") || !strings.Contains(conflict.Body.String(), created.EventID) {
t.Fatalf("conflict status=%d body=%s", conflict.Code, conflict.Body.String())
}
assertCount(t, db, &event.Event{}, 1)
assertCount(t, db, &receipt.Receipt{}, 1)
assertCount(t, db, &receipt.IngestAudit{}, 4)
}
func TestEvidenceDegradationIdentityErrorsAndDisabledConnector(t *testing.T) {
db := openDatabase(t)
publicKey, privateKey, _ := ed25519.GenerateKey(rand.Reader)
registry, _ := machine_identity.NewRegistry(machine_identity.KeyRecord{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PublicKey: publicKey, Audience: "yovision-bell", Scopes: []string{"events:ingest"}, Enabled: true})
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
signer := machine_identity.Signer{Principal: "yv:brain:school-a", KeyID: "brain-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
handler := event_ingress.Handler{DB: db, Enabled: true, Verifier: machine_identity.Verifier{Registry: registry, Replay: event_ingress.PersistentReplayStore{DB: db}, Now: func() time.Time { return now }}}
pending := fixture(t, "dangerous-area.json")
if response := requestWithID(t, handler, pending, mint(t, signer, pending), "short"); response.Code != http.StatusBadRequest || !strings.Contains(response.Body.String(), "invalid_request_id") {
t.Fatalf("invalid request id status=%d body=%s", response.Code, response.Body.String())
}
missingRequestID := requestWithID(t, handler, pending, mint(t, signer, pending), "")
if missingRequestID.Code != http.StatusCreated || !requestIDPatternForTest(missingRequestID.Header().Get("X-Request-ID")) {
t.Fatalf("trusted hop did not create a request id: %d %s", missingRequestID.Code, missingRequestID.Body.String())
}
queryResponse := requestTarget(t, handler, pending, mint(t, signer, pending), "/v1/events?debug=true")
if queryResponse.Code != http.StatusBadRequest || !strings.Contains(queryResponse.Body.String(), "invalid_request_target") {
t.Fatalf("query target was accepted: %d %s", queryResponse.Code, queryResponse.Body.String())
}
if response := request(t, handler, pending, mint(t, signer, pending)); response.Code != http.StatusOK {
t.Fatalf("pending evidence rejected: %d %s", response.Code, response.Body.String())
}
failed := fixture(t, "directional-line-crossed.json")
if response := request(t, handler, failed, mint(t, signer, failed)); response.Code != http.StatusCreated {
t.Fatalf("failed evidence rejected: %d %s", response.Code, response.Body.String())
}
assertCount(t, db, &event.Event{}, 2)
wrongAudienceToken, err := signer.Mint("yovision-sense", []string{"events:ingest"}, http.MethodPost, "/v1/events", pending)
if err != nil {
t.Fatal(err)
}
if response := request(t, handler, pending, wrongAudienceToken); response.Code != http.StatusForbidden {
t.Fatalf("wrong audience was not forbidden: %d %s", response.Code, response.Body.String())
}
if response := request(t, event_ingress.Handler{Enabled: false}, pending, "none"); response.Code != http.StatusServiceUnavailable {
t.Fatalf("disabled connector status=%d", response.Code)
}
}
func TestEvidenceResolverCurrentMissingExpiredAndTimeout(t *testing.T) {
db := openDatabase(t)
now := time.Date(2026, 8, 31, 1, 0, 0, 0, time.UTC)
status := event_ingress.EvidenceStatus{EventID: "event-1", EvidenceID: "ev-school-east-0001", OwnerID: "sense-school-a", Status: "pending", Resolution: "snapshot", CurrentPayload: json.RawMessage(`{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"pending","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:01.125Z"}`), CreatedAt: now, UpdatedAt: now}
if err := db.Create(&status).Error; err != nil {
t.Fatal(err)
}
_, privateKey, _ := ed25519.GenerateKey(rand.Reader)
signer := machine_identity.Signer{Principal: "yv:bell:school-a", KeyID: "bell-key-0001", PrivateKey: privateKey, Now: func() time.Time { return now }}
response := func(code int, body string) *http.Response {
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}
}
client := event_ingress.EvidenceClient{Endpoint: "https://sense.example", Signer: signer, HTTP: doFunc(func(*http.Request) (*http.Response, error) {
return response(http.StatusNotFound, `{}`), nil
})}
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_not_found" {
t.Fatalf("missing resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
}
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusGone, `{}`), nil })
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "expired" {
t.Fatalf("expired resolution=%s db=%v", status.Resolution, err)
}
current := `{"schema_version":"yovision.evidence-reference/v1","evidence_id":"ev-school-east-0001","owner_id":"sense-school-a","type":"snapshot","status":"success","captured_at":"2026-08-31T00:00:01.125Z","status_updated_at":"2026-08-31T00:00:02.125Z","content_type":"image/jpeg","integrity":{"algorithm":"sha256","digest":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","size_bytes":1}}`
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return response(http.StatusOK, current), nil })
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "current" || status.Status != "success" || status.LastError != "" {
t.Fatalf("current status=%s resolution=%s error=%s db=%v", status.Status, status.Resolution, status.LastError, err)
}
client.HTTP = doFunc(func(*http.Request) (*http.Response, error) { return nil, context.DeadlineExceeded })
if err := client.Refresh(context.Background(), db, status); err != nil {
t.Fatal(err)
}
if err := db.First(&status, "event_id = ? AND evidence_id = ?", "event-1", "ev-school-east-0001").Error; err != nil || status.Resolution != "unavailable" || status.LastError != "evidence_timeout" {
t.Fatalf("timeout resolution=%s error=%s db=%v", status.Resolution, status.LastError, err)
}
}
func openDatabase(t *testing.T) *gorm.DB {
return openDatabasePath(t, filepath.Join(t.TempDir(), "bell-ingress.sqlite"))
}
func openDatabasePath(t *testing.T, databasePath string) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(databasePath), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&event.Event{}, &receipt.Receipt{}, &receipt.IngestAudit{}, &event_ingress.ReplayToken{}, &event_ingress.EvidenceStatus{}); err != nil {
t.Fatal(err)
}
sqlDatabase, err := db.DB()
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sqlDatabase.Close() })
return db
}
func fixture(t *testing.T, name string) []byte {
t.Helper()
path := filepath.Join("..", "..", "..", "..", "..", "contracts", "events", "v1", "examples", name)
body, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return body
}
func writeRegistry(t *testing.T, audience, principal, keyID string) string {
t.Helper()
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
document := map[string]any{
"version": "yovision.machine-principal-registry/v1", "audience": audience,
"principals": []any{map[string]any{
"principal_id": principal, "enabled": true,
"keys": []any{map[string]any{
"kid": keyID, "public_key_base64url": base64.RawURLEncoding.EncodeToString(publicKey),
"status": "active", "scopes": []string{"events:ingest"},
}},
}},
}
encoded, err := json.Marshal(document)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(t.TempDir(), "registry.json")
if err = os.WriteFile(path, encoded, 0o600); err != nil {
t.Fatal(err)
}
return path
}
func mint(t *testing.T, signer machine_identity.Signer, body []byte) string {
t.Helper()
token, err := signer.Mint("yovision-bell", []string{"events:ingest"}, http.MethodPost, "/v1/events", body)
if err != nil {
t.Fatal(err)
}
return token
}
func request(t *testing.T, handler event_ingress.Handler, body []byte, token string) *httptest.ResponseRecorder {
return requestWithID(t, handler, body, token, "request-id-0000001")
}
func requestWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, requestID string) *httptest.ResponseRecorder {
return requestTargetWithID(t, handler, body, token, "/v1/events", requestID)
}
func requestTarget(t *testing.T, handler event_ingress.Handler, body []byte, token, target string) *httptest.ResponseRecorder {
return requestTargetWithID(t, handler, body, token, target, "request-id-0000001")
}
func requestTargetWithID(t *testing.T, handler event_ingress.Handler, body []byte, token, target, requestID string) *httptest.ResponseRecorder {
t.Helper()
request := httptest.NewRequest(http.MethodPost, target, bytes.NewReader(body))
request.Header.Set("Authorization", "Bearer "+token)
request.Header.Set("X-Request-ID", requestID)
response := httptest.NewRecorder()
context, _ := gin.CreateTestContext(response)
context.Request = request
handler.Post(context)
return response
}
func requestIDPatternForTest(value string) bool {
if len(value) < 16 || len(value) > 128 {
return false
}
for index, r := range value {
if !(r >= 'A' && r <= 'Z' || r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || index > 0 && strings.ContainsRune("._:-", r)) {
return false
}
}
return true
}
func decode(t *testing.T, response *httptest.ResponseRecorder, target any) {
t.Helper()
if err := json.Unmarshal(response.Body.Bytes(), target); err != nil {
t.Fatal(err)
}
}
func assertCount(t *testing.T, db *gorm.DB, model any, expected int64) {
t.Helper()
var count int64
if err := db.Model(model).Count(&count).Error; err != nil || count != expected {
t.Fatalf("count %T=%d expected=%d err=%v", model, count, expected, err)
}
}
type doFunc func(*http.Request) (*http.Response, error)
func (function doFunc) Do(request *http.Request) (*http.Response, error) { return function(request) }