Files
member-console/internal/integrations/discourse/web/webhook_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

310 lines
11 KiB
Go

package web_test
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/db"
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/client"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/discoursetest"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/linkage"
dcmod "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/store"
"git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/discourse/web"
"git.coopcloud.tech/wiki-cafe/member-console/internal/migrate"
"github.com/google/uuid"
_ "github.com/jackc/pgx/v5/stdlib"
)
const testSecret = "test-webhook-secret"
func testDB(t *testing.T) *sql.DB {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set, skipping integration test")
}
database, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open database: %v", err)
}
if err := db.RunMigrations(database, migrate.Sources()); err != nil {
t.Fatalf("run migrations: %v", err)
}
t.Cleanup(func() { database.Close() })
return database
}
type webhookFixture struct {
t *testing.T
ctx context.Context
database *sql.DB
fake *discoursetest.Fake
mux *http.ServeMux
started []string // personIDs handed to the starter
}
// newWebhookFixture builds a handler over COMMITTED fixtures (the handler
// reads through the DB pool, so tx-rollback isolation is unavailable);
// everything seeded registers a cleanup delete.
func newWebhookFixture(t *testing.T, mode linkage.Mode) *webhookFixture {
t.Helper()
f := &webhookFixture{t: t, ctx: context.Background(), database: testDB(t)}
f.fake = discoursetest.NewFake()
t.Cleanup(f.fake.Close)
handler := web.NewWebhookHandler(web.WebhookHandlerConfig{
DB: f.database,
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
Secret: testSecret,
Mode: mode,
Client: client.New(client.Config{BaseURL: f.fake.URL(), APIKey: "k", APIUsername: "system", RatePerMinute: 60_000}),
StartReconcile: func(personID string) error {
f.started = append(f.started, personID)
return nil
},
})
f.mux = http.NewServeMux()
handler.RegisterRoutes(f.mux)
t.Cleanup(func() {
_, _ = f.database.ExecContext(context.Background(),
"DELETE FROM core.webhook_events WHERE provider = 'discourse'")
})
return f
}
// newPerson seeds a committed user+person with cleanup.
func (f *webhookFixture) newPerson(email, subj string) string {
f.t.Helper()
idQ := identity.New(f.database)
user, err := idQ.CreateUser(f.ctx, subj)
if err != nil {
f.t.Fatalf("create user: %v", err)
}
person, err := idQ.CreatePerson(f.ctx, identity.CreatePersonParams{
UserID: user.UserID, DisplayName: "Hook Person", PrimaryEmail: email, PrimaryEmailVerified: true,
})
if err != nil {
f.t.Fatalf("create person: %v", err)
}
f.t.Cleanup(func() {
ctx := context.Background()
_, _ = f.database.ExecContext(ctx, "DELETE FROM discourse.user_links WHERE person_id = $1", person.PersonID)
_, _ = f.database.ExecContext(ctx, "DELETE FROM core.persons WHERE person_id = $1", person.PersonID)
_, _ = f.database.ExecContext(ctx, "DELETE FROM core.users WHERE user_id = $1", user.UserID)
})
return person.PersonID
}
func (f *webhookFixture) newMapping(groupName string, gid int64) dcmod.GroupMapping {
f.t.Helper()
mapping, err := dcmod.New(f.database).CreateGroupMapping(f.ctx, dcmod.CreateGroupMappingParams{
ResourceKey: "discourse_posting", GroupName: groupName, DiscourseGroupID: gid,
})
if err != nil {
f.t.Fatalf("create mapping: %v", err)
}
f.t.Cleanup(func() {
_, _ = f.database.ExecContext(context.Background(),
"DELETE FROM discourse.group_mappings WHERE mapping_id = $1", mapping.MappingID)
})
return mapping
}
func (f *webhookFixture) linkPerson(personID string, uid int64, username string) {
f.t.Helper()
if _, err := dcmod.New(f.database).CreateUserLink(f.ctx, dcmod.CreateUserLinkParams{
PersonID: personID, DiscourseUserID: uid, DiscourseUsername: username, LinkedVia: "oidc",
}); err != nil {
f.t.Fatalf("link person: %v", err)
}
}
// deliver posts a webhook with the given signature treatment and returns the
// response code.
func (f *webhookFixture) deliver(event string, payload any, sign bool, eventID string) int {
f.t.Helper()
body, err := json.Marshal(payload)
if err != nil {
f.t.Fatal(err)
}
req := httptest.NewRequest(http.MethodPost, web.WebhookPath, bytes.NewReader(body))
req.Header.Set("X-Discourse-Event", event)
req.Header.Set("X-Discourse-Event-Id", eventID)
if sign {
mac := hmac.New(sha256.New, []byte(testSecret))
mac.Write(body)
req.Header.Set("X-Discourse-Event-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
}
rec := httptest.NewRecorder()
f.mux.ServeHTTP(rec, req)
return rec.Code
}
func (f *webhookFixture) eventStatus(eventID string) string {
f.t.Helper()
var status string
err := f.database.QueryRowContext(f.ctx,
"SELECT status FROM core.webhook_events WHERE provider = 'discourse' AND provider_event_id = $1",
eventID).Scan(&status)
if err != nil {
return "absent (" + err.Error() + ")"
}
return status
}
// Scenario: invalid or missing signature → rejected, no side effects.
func TestWebhookRejectsBadSignature(t *testing.T) {
f := newWebhookFixture(t, linkage.ModeOIDC)
eventID := uuid.New().String()
if code := f.deliver("user_created", map[string]any{"user": map[string]any{"id": 1}}, false, eventID); code != http.StatusForbidden {
t.Errorf("unsigned delivery = %d, want 403", code)
}
// Wrong secret == wrong signature: sign with a different key by hand.
body := []byte(`{"user":{"id":1}}`)
mac := hmac.New(sha256.New, []byte("wrong-secret"))
mac.Write(body)
req := httptest.NewRequest(http.MethodPost, web.WebhookPath, bytes.NewReader(body))
req.Header.Set("X-Discourse-Event", "user_created")
req.Header.Set("X-Discourse-Event-Id", eventID)
req.Header.Set("X-Discourse-Event-Signature", "sha256="+hex.EncodeToString(mac.Sum(nil)))
rec := httptest.NewRecorder()
f.mux.ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden {
t.Errorf("mis-signed delivery = %d, want 403", rec.Code)
}
if got := f.eventStatus(eventID); got[:6] != "absent" {
t.Errorf("expected no recorded event, got status %q", got)
}
if len(f.started) != 0 {
t.Errorf("starter called %d times on rejected deliveries", len(f.started))
}
}
// Scenario: valid signed user_created (oidc mode) links via admin-record
// external id and starts the person's reconcile; duplicate delivery is
// acknowledged without reprocessing.
func TestWebhookUserCreatedOIDCAndDuplicate(t *testing.T) {
f := newWebhookFixture(t, linkage.ModeOIDC)
subj := "subj-" + uuid.New().String()[:8]
personID := f.newPerson("hook-"+uuid.New().String()[:8]+"@example.com", subj)
uid := f.fake.AddUser(discoursetest.FakeUser{
Username: "newbie", Email: "n@example.com", Active: true,
ExternalIDs: map[string]string{"oidc": subj},
})
eventID := uuid.New().String()
payload := map[string]any{"user": map[string]any{"id": uid, "username": "newbie", "email": "n@example.com"}}
if code := f.deliver("user_created", payload, true, eventID); code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
if len(f.started) != 1 || f.started[0] != personID {
t.Fatalf("starter calls = %v, want exactly [%s]", f.started, personID)
}
if got := f.eventStatus(eventID); got != "processed" {
t.Errorf("event status = %q, want processed", got)
}
// Duplicate delivery: same event id → no second reconcile.
if code := f.deliver("user_created", payload, true, eventID); code != http.StatusOK {
t.Fatalf("duplicate delivery = %d, want 200", code)
}
if len(f.started) != 1 {
t.Errorf("starter called %d times after duplicate, want 1", len(f.started))
}
}
// Scenario: user_created for an organic signup (no member-console person)
// is processed with nothing to reconcile.
func TestWebhookUserCreatedUnknownUser(t *testing.T) {
f := newWebhookFixture(t, linkage.ModeOIDC)
uid := f.fake.AddUser(discoursetest.FakeUser{Username: "organic", Email: "o@example.com", Active: true})
eventID := uuid.New().String()
code := f.deliver("user_created", map[string]any{"user": map[string]any{"id": uid, "username": "organic"}}, true, eventID)
if code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
if len(f.started) != 0 {
t.Errorf("starter called for organic signup")
}
if got := f.eventStatus(eventID); got != "processed" {
t.Errorf("event status = %q, want processed", got)
}
}
// Scenario: email mode reverse-resolves by verified primary email.
func TestWebhookUserCreatedEmailMode(t *testing.T) {
f := newWebhookFixture(t, linkage.ModeEmail)
email := "mail-" + uuid.New().String()[:8] + "@example.com"
personID := f.newPerson(email, "subj-"+uuid.New().String()[:8])
eventID := uuid.New().String()
code := f.deliver("user_created", map[string]any{"user": map[string]any{"id": 42, "username": "mailer", "email": email}}, true, eventID)
if code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
if len(f.started) != 1 || f.started[0] != personID {
t.Errorf("starter calls = %v, want [%s]", f.started, personID)
}
}
// Scenario: user_removed_from_group on a managed group with a linked person
// triggers their reconcile; the same event on an unmanaged group does not.
// Payload shape is the live-verified GroupUser join row (group_id/user_id,
// no names) under "group_user".
func TestWebhookGroupEvents(t *testing.T) {
f := newWebhookFixture(t, linkage.ModeOIDC)
personID := f.newPerson("grp-"+uuid.New().String()[:8]+"@example.com", "subj-"+uuid.New().String()[:8])
groupName := "managed-" + uuid.New().String()[:8]
gid := f.fake.AddGroup(groupName, false)
f.newMapping(groupName, gid)
uid := f.fake.AddUser(discoursetest.FakeUser{Username: "grpuser", Email: "g@example.com", Active: true})
f.linkPerson(personID, uid, "grpuser")
// Managed group → reconcile.
code := f.deliver("user_removed_from_group", map[string]any{
"group_user": map[string]any{"group_id": gid, "user_id": uid, "notification_level": 3, "id": 27, "created_at": "2026-07-20T01:00:00.000Z"},
}, true, uuid.New().String())
if code != http.StatusOK {
t.Fatalf("delivery = %d, want 200", code)
}
if len(f.started) != 1 || f.started[0] != personID {
t.Fatalf("starter calls = %v, want [%s]", f.started, personID)
}
// Unmanaged group → acknowledged, no reconcile.
code = f.deliver("user_added_to_group", map[string]any{
"group_user": map[string]any{"group_id": gid + 999, "user_id": uid, "notification_level": 3, "id": 28, "created_at": "2026-07-20T01:00:00.000Z"},
}, true, uuid.New().String())
if code != http.StatusOK {
t.Fatalf("unmanaged delivery = %d, want 200", code)
}
if len(f.started) != 1 {
t.Errorf("starter called for unmanaged group event")
}
}
// Guard against silent fixture leaks: fail loudly if cleanup can't run.
func TestMain(m *testing.M) {
code := m.Run()
if code == 0 {
fmt.Fprintln(os.Stderr, "webhook tests done (committed fixtures cleaned per-test)")
}
os.Exit(code)
}