Files
member-console/internal/integrations/discourse/client/client_test.go
T
cgalo5758 a94ff08336 Add Discourse integration
Deliver forum posting entitlements through managed group membership with
identity linkage, periodic reconciliation, webhook handling, and an
operator mapping surface.

Include fake and live test environments, setup documentation,
migrations,
and end-to-end coverage.
2026-07-20 19:49:34 -07:00

246 lines
7.8 KiB
Go

package client_test
import (
"context"
"errors"
"testing"
"time"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/discoursetest"
)
// newClient points a fast client (no meaningful throttle) at a fresh fake.
func newClient(t *testing.T) (*client.Client, *discoursetest.Fake) {
t.Helper()
fake := discoursetest.NewFake()
t.Cleanup(fake.Close)
c := client.New(client.Config{
BaseURL: fake.URL(),
APIKey: "test-key",
APIUsername: "system",
RatePerMinute: 60_000,
})
fake.APIKey = "test-key"
return c, fake
}
func TestFindUserByEmail(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
fake.AddUser(discoursetest.FakeUser{Username: "alice", Email: "Alice@example.com", Active: true})
// Case-insensitive exact match.
u, err := c.FindUserByEmail(ctx, "alice@EXAMPLE.com")
if err != nil {
t.Fatalf("find by email: %v", err)
}
if u == nil || u.Username != "alice" {
t.Fatalf("expected alice, got %+v", u)
}
// Miss returns (nil, nil), not an error.
u, err = c.FindUserByEmail(ctx, "nobody@example.com")
if err != nil {
t.Fatalf("miss lookup errored: %v", err)
}
if u != nil {
t.Fatalf("expected nil user on miss, got %+v", u)
}
}
func TestUserByExternalID(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
fake.AddUser(discoursetest.FakeUser{
Username: "bob", Email: "bob@example.com", Active: true,
ExternalIDs: map[string]string{"oidc": "subject-123"},
})
u, err := c.UserByExternalID(ctx, "oidc", "subject-123")
if err != nil {
t.Fatalf("by-external: %v", err)
}
if u == nil || u.Username != "bob" {
t.Fatalf("expected bob, got %+v", u)
}
// 404 → (nil, nil).
u, err = c.UserByExternalID(ctx, "oidc", "unknown-subject")
if err != nil {
t.Fatalf("404 lookup errored: %v", err)
}
if u != nil {
t.Fatalf("expected nil on 404, got %+v", u)
}
}
func TestCreateUserWithExternalIDs(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
err := c.CreateUser(ctx, client.CreateUserParams{
Name: "Carol", Email: "carol@example.com", Password: "x9k2m1pq77aa", Username: "carol",
ExternalIDs: map[string]string{"oidc": "subject-carol"},
})
if err != nil {
t.Fatalf("create user: %v", err)
}
// The create response identifies nothing (live-verified: no user_id) —
// the created user is only reachable by lookup, as the linkage layer
// does. By-external doubles as the create-and-link roundtrip check.
u, err := c.UserByExternalID(ctx, "oidc", "subject-carol")
if err != nil || u == nil {
t.Fatalf("by-external after create = (%+v, %v), want created user", u, err)
}
created, ok := fake.GetUser(u.ID)
if !ok {
t.Fatalf("user %d not present in fake", u.ID)
}
if !created.Active || !created.Approved {
t.Errorf("expected active+approved, got %+v", created)
}
if created.ExternalIDs["oidc"] != "subject-carol" {
t.Errorf("external id not bound: %+v", created.ExternalIDs)
}
// Duplicate username: Discourse reports success=false, not an HTTP error.
if err := c.CreateUser(ctx, client.CreateUserParams{
Name: "Carol2", Email: "carol2@example.com", Password: "x9k2m1pq77bb", Username: "carol",
}); err == nil {
t.Fatal("expected refusal for duplicate username")
}
// Duplicate email: enumeration protection reports success while creating
// nothing — the client cannot tell, only a follow-up lookup can.
if err := c.CreateUser(ctx, client.CreateUserParams{
Name: "Carol3", Email: "carol@example.com", Password: "x9k2m1pq77cc", Username: "carol3",
}); err != nil {
t.Fatalf("duplicate-email create should look like success, got %v", err)
}
if u, err := c.FindUserByEmail(ctx, "carol@example.com"); err != nil || u == nil || u.Username != "carol" {
t.Fatalf("carol@ should still resolve to the original user, got (%+v, %v)", u, err)
}
}
func TestVerifyKey(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
if err := c.VerifyKey(ctx); err != nil {
t.Fatalf("valid key: %v", err)
}
bad := client.New(client.Config{BaseURL: fake.URL(), APIKey: "wrong-key", APIUsername: "system", RatePerMinute: 60_000})
err := bad.VerifyKey(ctx)
if !errors.Is(err, client.ErrInvalidAPIKey) {
t.Fatalf("dead key: got %v, want ErrInvalidAPIKey", err)
}
}
func TestGroupMembershipLifecycle(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
gid := fake.AddGroup("members", false)
fake.AddUser(discoursetest.FakeUser{Username: "alice", Email: "a@example.com", Active: true})
fake.AddUser(discoursetest.FakeUser{Username: "bob", Email: "b@example.com", Active: true})
g, err := c.GetGroup(ctx, "members")
if err != nil || g == nil {
t.Fatalf("get group = (%+v, %v)", g, err)
}
if g.ID != gid || g.Automatic {
t.Fatalf("unexpected group %+v", g)
}
if missing, err := c.GetGroup(ctx, "nope"); err != nil || missing != nil {
t.Fatalf("missing group = (%+v, %v), want (nil, nil)", missing, err)
}
if err := c.AddGroupMembers(ctx, gid, []string{"alice", "bob"}); err != nil {
t.Fatalf("add members: %v", err)
}
if got := len(fake.Members(gid)); got != 2 {
t.Fatalf("expected 2 members, got %d", got)
}
// All-already-members 422 is success (idempotent convergence).
if err := c.AddGroupMembers(ctx, gid, []string{"alice", "bob"}); err != nil {
t.Fatalf("re-add errored: %v", err)
}
// Partial overlap is a plain success.
fake.AddUser(discoursetest.FakeUser{Username: "carol", Email: "c@example.com", Active: true})
if err := c.AddGroupMembers(ctx, gid, []string{"alice", "carol"}); err != nil {
t.Fatalf("partial add errored: %v", err)
}
if err := c.RemoveGroupMembers(ctx, gid, []string{"alice"}); err != nil {
t.Fatalf("remove member: %v", err)
}
// Not-a-member removal is success (already converged).
if err := c.RemoveGroupMembers(ctx, gid, []string{"alice"}); err != nil {
t.Fatalf("re-remove errored: %v", err)
}
if got := len(fake.Members(gid)); got != 2 {
t.Fatalf("expected 2 members after removal, got %d", got)
}
}
func TestListGroupMembersPagination(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
gid := fake.AddGroup("big", false)
// More members than one page (client pages at 100).
for i := 0; i < 130; i++ {
uid := fake.AddUser(discoursetest.FakeUser{
Username: "user" + string(rune('a'+i%26)) + string(rune('a'+(i/26)%26)) + string(rune('a'+(i/676))),
Email: "u@example.com", Active: true,
})
fake.AddMember(gid, uid)
}
members, err := c.ListGroupMembers(ctx, "big")
if err != nil {
t.Fatalf("list members: %v", err)
}
if len(members) != 130 {
t.Fatalf("expected 130 members across pages, got %d", len(members))
}
}
func TestRateLimit429Retry(t *testing.T) {
c, fake := newClient(t)
ctx := context.Background()
fake.AddUser(discoursetest.FakeUser{Username: "alice", Email: "a@example.com", Active: true})
fake.Fail429(1)
start := time.Now()
u, err := c.FindUserByEmail(ctx, "a@example.com")
if err != nil {
t.Fatalf("expected retry to succeed after 429, got %v", err)
}
if u == nil || u.Username != "alice" {
t.Fatalf("unexpected user %+v", u)
}
// Retry-After: 1 from the fake — the client must actually have waited.
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
t.Errorf("expected ≥~1s backoff before retry, took %v", elapsed)
}
}
func TestClientSideThrottle(t *testing.T) {
fake := discoursetest.NewFake()
t.Cleanup(fake.Close)
// 120/min = 2/sec with burst 1: three calls take ≥ ~1s.
c := client.New(client.Config{BaseURL: fake.URL(), APIKey: "k", APIUsername: "system", RatePerMinute: 120})
ctx := context.Background()
start := time.Now()
for i := 0; i < 3; i++ {
if _, err := c.FindUserByEmail(ctx, "x@example.com"); err != nil {
t.Fatalf("call %d: %v", i, err)
}
}
if elapsed := time.Since(start); elapsed < 900*time.Millisecond {
t.Errorf("three calls at 2/sec finished in %v; throttle not applied", elapsed)
}
}