package hub import ( "context" "errors" "io" "log/slog" "testing" "time" ) func TestAuthorizeIsTheAdmittedParticipationRule(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) h := testHub(&now) ctx := context.Background() a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotAdmitted) { t.Fatalf("unjoined: want ErrNotAdmitted, got %v", err) } if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil { t.Fatal(err) } if err := h.Authorize(ctx, "r1", "sa", a.Peer); err != nil { t.Fatalf("admitted member: %v", err) } if err := h.Authorize(ctx, "r1", "sb", a.Peer); !errors.Is(err, ErrUnknownPeer) { t.Fatalf("another session: want ErrUnknownPeer, got %v", err) } if err := h.Authorize(ctx, "r2", "sa", a.Peer); !errors.Is(err, ErrUnknownPeer) { t.Fatalf("another room: want ErrUnknownPeer, got %v", err) } // personID 100 is testHub's own guest threshold (personID < 100 is a // member, as TestMemberJoinsAndGuestWaits in admission_test.go // already relies on) โ€” a personID of 2 here would derive as a // member and be admitted outright, proving nothing about a waiting // guest. g, _ := h.Open("r1", "sg", 100, "Gus", KindGuest) h.Join(ctx, "r1", "sg", g.Peer) if err := h.Authorize(ctx, "r1", "sg", g.Peer); !errors.Is(err, ErrNotAdmitted) { t.Fatalf("waiting guest: want ErrNotAdmitted, got %v", err) } } // TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest is // ยง15.12 through Authorize, mirroring admission_test.go's // TestJoinDropsAndAnnouncesAMemberWhoLostStanding: standing is a // mutable map keyed on personID, moved without a tick running, so the // only thing that could have refused the member is Authorize's own // re-derivation. Bob is admitted as a guest (personID 100, testHub's // own guest threshold) before his grant "expires" โ€” ruling 14 says // Authorize never even *asks* for a guest, which is asserted directly // by counting StandingFor calls: standingLostLocked's own admitted- // guest guard would still make the *outcome* (nil, participation kept) // look right even if memberStandingGone's early return for a // non-member kind were deleted and it called StandingFor for Bob // anyway โ€” so the outcome alone does not discriminate that mutation. // The call count does. func TestAuthorizeDropsAMemberWhoseStandingIsGoneAndKeepsAnAdmittedGuest(t *testing.T) { now := time.Date(2026, 9, 10, 9, 0, 0, 0, time.UTC) standing := map[int64]bool{1: true, 100: true} guestStandingCalls := 0 h := New(Config{ Now: func() time.Time { return now }, Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), StandingFor: func(ctx context.Context, room string, id int64) (Kind, bool, error) { if id == 100 { guestStandingCalls++ return KindGuest, standing[id], nil } return KindMember, standing[id], nil }, }) ctx := context.Background() a, _ := h.Open("r1", "sa", 1, "Ann", KindMember) if err := h.Join(ctx, "r1", "sa", a.Peer); err != nil { t.Fatal(err) } b, _ := h.Open("r1", "sb", 100, "Bob", KindGuest) if err := h.Join(ctx, "r1", "sb", b.Peer); err != nil { t.Fatal(err) } if err := h.Admit(ctx, "r1", "sa", a.Peer, b.Peer); err != nil { t.Fatal(err) } drain(a.Sub) drain(b.Sub) standing[1] = false // Ann taken off the team, no tick has run if err := h.Authorize(ctx, "r1", "sa", a.Peer); !errors.Is(err, ErrNotYours) { t.Fatalf("member whose standing is gone: want ErrNotYours, got %v", err) } if _, found := h.Find("r1", "sa", a.Peer); found { t.Fatal("Ann's participation should have ended") } bev := drain(b.Sub) if len(bev) == 0 || bev[len(bev)-1].Type != TypeGone || bev[len(bev)-1].From != a.Peer { t.Fatalf("Bob's stream got %v, want gone naming Ann last", types(bev)) } standing[100] = false // Bob's grant has "expired" before := guestStandingCalls if err := h.Authorize(ctx, "r1", "sb", b.Peer); err != nil { t.Fatalf("admitted guest with a lapsed grant: %v", err) } if guestStandingCalls != before { t.Fatalf("Authorize asked StandingFor for a guest: %d calls during this Authorize, want 0", guestStandingCalls-before) } }