Files
T
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

313 lines
11 KiB
Go

// 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)
}