You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
86 lines
3.5 KiB
86 lines
3.5 KiB
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")
|
|
}
|
|
}
|
|
|