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