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.
249 lines
8.0 KiB
Go
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)
|
|
}
|
|
}
|