package directstream import ( "bytes" "errors" "testing" "time" ) func TestTicketIsBoundAndConsumedOnce(t *testing.T) { now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC) store := NewTicketStore(TicketStoreOptions{ Clock: func() time.Time { return now }, Entropy: bytes.NewReader(bytes.Repeat([]byte{0x3a}, TicketEntropyBytes)), TTL: 30 * time.Second, MaxPending: 4, }) claims := TicketClaims{SessionID: "direct_CH01_000001", Channel: "CH01", Actor: "operator", Access: AccessViewer} issued, err := store.Issue(claims) if err != nil { t.Fatalf("Issue() error = %v", err) } if issued.Value == "" { t.Fatal("Issue() returned an empty ticket") } if got, want := issued.ExpiresAt, now.Add(30*time.Second); !got.Equal(want) { t.Fatalf("ExpiresAt = %v, want %v", got, want) } if _, err := store.Consume(issued.Value, TicketBinding{SessionID: claims.SessionID, Channel: "CH02"}); !errors.Is(err, ErrInvalidTicket) { t.Fatalf("Consume() wrong binding error = %v, want ErrInvalidTicket", err) } consumed, err := store.Consume(issued.Value, TicketBinding{SessionID: claims.SessionID, Channel: claims.Channel}) if err != nil { t.Fatalf("Consume() error = %v", err) } if consumed != claims { t.Fatalf("Consume() claims = %#v, want %#v", consumed, claims) } if _, err := store.Consume(issued.Value, TicketBinding{SessionID: claims.SessionID, Channel: claims.Channel}); !errors.Is(err, ErrInvalidTicket) { t.Fatalf("Consume() replay error = %v, want ErrInvalidTicket", err) } } func TestTicketExpiresAndCapacityIsBounded(t *testing.T) { now := time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC) entropy := bytes.NewReader(append(bytes.Repeat([]byte{0x11}, TicketEntropyBytes), bytes.Repeat([]byte{0x22}, TicketEntropyBytes)...)) store := NewTicketStore(TicketStoreOptions{ Clock: func() time.Time { return now }, Entropy: entropy, TTL: 30 * time.Second, MaxPending: 1, }) firstClaims := TicketClaims{SessionID: "direct_CH01_000001", Channel: "CH01", Actor: "operator", Access: AccessWriter} first, err := store.Issue(firstClaims) if err != nil { t.Fatalf("Issue(first) error = %v", err) } if _, err := store.Issue(TicketClaims{SessionID: "direct_CH02_000001", Channel: "CH02", Actor: "operator", Access: AccessViewer}); !errors.Is(err, ErrTicketCapacity) { t.Fatalf("Issue(over capacity) error = %v, want ErrTicketCapacity", err) } now = now.Add(30 * time.Second) if _, err := store.Consume(first.Value, TicketBinding{SessionID: firstClaims.SessionID, Channel: firstClaims.Channel}); !errors.Is(err, ErrInvalidTicket) { t.Fatalf("Consume(expired) error = %v, want ErrInvalidTicket", err) } if _, err := store.Issue(TicketClaims{SessionID: "direct_CH02_000001", Channel: "CH02", Actor: "operator", Access: AccessViewer}); err != nil { t.Fatalf("Issue(after expiry pruning) error = %v", err) } } func TestTicketRejectsInvalidClaimsAndEntropyFailure(t *testing.T) { store := NewTicketStore(TicketStoreOptions{ Clock: func() time.Time { return time.Unix(0, 0).UTC() }, Entropy: bytes.NewReader(nil), TTL: 30 * time.Second, MaxPending: 1, }) if _, err := store.Issue(TicketClaims{SessionID: "session", Channel: "CH49", Actor: "operator", Access: AccessViewer}); !errors.Is(err, ErrInvalidTicketClaims) { t.Fatalf("Issue(invalid claims) error = %v, want ErrInvalidTicketClaims", err) } if _, err := store.Issue(TicketClaims{SessionID: "session", Channel: "CH01", Actor: "operator", Access: AccessViewer}); err == nil { t.Fatal("Issue(entropy failure) error = nil") } }