Files
cgalo5758 88db730fcc Add dual licensing and SPDX headers
Introduce a commercial license option alongside AGPL-3.0-only, require a
CLA for contributors, and document the terms in COMMERCIAL.md and
NOTICE. Add a script to stamp SPDX headers on Go files and apply it
across the tree.
2026-09-06 02:29:42 -05:00

249 lines
8.0 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
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)
}
}