Add an append-only ledger of entitlement set rule changes with per-pool effect rows, a preview-and-commit rule change flow, and an automatic drain that settles deferred recomputations. Rules gain a tier reduction policy, resource keys declare over-limit behavior, and the materializer now lowers limits when a rule stops applying. Add entitlement set rule change ledger and preview flow Add an append-only ledger of entitlement set rule changes with a preview-and-commit operator flow. Rule writes now go through an enclosed `core.commit_rule_change` function that files an act row and one obligation per carrying pool, with a drain workflow settling deferred recomputations. The preview dry-runs the materializer with a rule overlay and renders per-pool buckets, reduction-policy disclosures, and provider over-limit consequences. Materializing transactions take a shared advisory rendezvous that rule changes hold exclusively, enforced by a possession assertion. Add History and Entitlement changes surfaces, a rule-less warning on five product-selection surfaces, and a `tier_reduction_policy` column that gates FedWiki parking.
263 lines
9.3 KiB
Go
263 lines
9.3 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package web_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/entitlements"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/identity"
|
|
"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/organization"
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// memberHarness runs the member-card handler over tx-rollback isolation:
|
|
// unlike the webhook handler (which reads through the DB pool), the member
|
|
// handler's Querier is an exported field, so it can be rebound to the
|
|
// harness transaction and nothing needs committed fixtures or cleanup.
|
|
type memberHarness struct {
|
|
t *testing.T
|
|
ctx context.Context
|
|
tx *sql.Tx
|
|
idQ *identity.Queries
|
|
orgQ *organization.Queries
|
|
entQ *entitlements.Queries
|
|
h *web.MemberForumHandler
|
|
}
|
|
|
|
func newMemberHarness(t *testing.T, mode linkage.Mode) *memberHarness {
|
|
t.Helper()
|
|
database := testDB(t)
|
|
tx, err := entitlements.BeginRuleChange(context.Background(), database)
|
|
if err != nil {
|
|
t.Fatalf("begin tx: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = tx.Rollback() })
|
|
|
|
h, err := web.NewMemberForumHandler(web.MemberForumHandlerConfig{
|
|
Logger: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})),
|
|
ForumURL: "https://forum.example.test",
|
|
Mode: mode,
|
|
ResourceKeys: []string{"discourse_posting"},
|
|
TemplatesFS: os.DirFS("../templates"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct handler: %v", err)
|
|
}
|
|
h.Q = dcmod.New(tx)
|
|
|
|
return &memberHarness{
|
|
t: t, ctx: context.Background(), tx: tx,
|
|
idQ: identity.New(tx),
|
|
orgQ: organization.New(tx),
|
|
entQ: entitlements.New(tx),
|
|
h: h,
|
|
}
|
|
}
|
|
|
|
func (m *memberHarness) newPerson(name string) string {
|
|
m.t.Helper()
|
|
suffix := uuid.New().String()[:8]
|
|
user, err := m.idQ.CreateUser(m.ctx, "subj-"+suffix)
|
|
if err != nil {
|
|
m.t.Fatalf("create user: %v", err)
|
|
}
|
|
person, err := m.idQ.CreatePerson(m.ctx, identity.CreatePersonParams{
|
|
UserID: user.UserID, DisplayName: name, PrimaryEmail: name + "-" + suffix + "@example.com", PrimaryEmailVerified: true,
|
|
})
|
|
if err != nil {
|
|
m.t.Fatalf("create person: %v", err)
|
|
}
|
|
return person.PersonID
|
|
}
|
|
|
|
// grantForumAccess builds the full conferral chain (org → pool → boolean
|
|
// rule → product → grant → provision → materialize) so the person's access
|
|
// flows through the same desired-set predicate the sweep uses.
|
|
func (m *memberHarness) grantForumAccess(ownerID string) {
|
|
m.t.Helper()
|
|
suffix := uuid.New().String()[:8]
|
|
org, err := m.orgQ.CreateOrganization(m.ctx, organization.CreateOrganizationParams{
|
|
Name: "Org " + suffix, OrgType: "personal", OwnerPersonID: ownerID,
|
|
})
|
|
if err != nil {
|
|
m.t.Fatalf("create org: %v", err)
|
|
}
|
|
pool, err := m.entQ.CreateResourcePool(m.ctx, entitlements.CreateResourcePoolParams{
|
|
OrgID: org.OrgID, Name: "Default", PoolType: "default", IsAutoManaged: true,
|
|
})
|
|
if err != nil {
|
|
m.t.Fatalf("create pool: %v", err)
|
|
}
|
|
set, err := m.entQ.CreateEntitlementSet(m.ctx, entitlements.CreateEntitlementSetParams{
|
|
Name: "Forum Set " + suffix, IsActive: true,
|
|
})
|
|
if err != nil {
|
|
m.t.Fatalf("create set: %v", err)
|
|
}
|
|
if _, err := entitlements.CommitRuleChangeTx(m.ctx, m.entQ, entitlements.CommitRuleChangeInput{
|
|
SetID: set.SetID,
|
|
ChangeKind: entitlements.ChangeKindRuleAdded,
|
|
ActorType: entitlements.ActorTypeSystem,
|
|
Rule: entitlements.RuleFields{
|
|
RuleType: "boolean",
|
|
ResourceKey: sql.NullString{String: "discourse_posting", Valid: true},
|
|
},
|
|
}); err != nil {
|
|
m.t.Fatalf("create boolean rule: %v", err)
|
|
}
|
|
var productID string
|
|
if err := m.tx.QueryRowContext(m.ctx,
|
|
`INSERT INTO core.products (name, is_active, is_public, lifecycle_status, entitlement_set_id)
|
|
VALUES ($1, TRUE, TRUE, 'published', $2) RETURNING product_id`,
|
|
"Forum Product "+suffix, set.SetID).Scan(&productID); err != nil {
|
|
m.t.Fatalf("create product: %v", err)
|
|
}
|
|
grant, err := m.entQ.CreateGrant(m.ctx, entitlements.CreateGrantParams{
|
|
ProductID: productID,
|
|
GrantedToOrgID: uuid.NullUUID{UUID: uuid.MustParse(org.OrgID), Valid: true},
|
|
GrantedByPersonID: uuid.NullUUID{UUID: uuid.MustParse(ownerID), Valid: true},
|
|
GrantReason: "manual",
|
|
Quantity: 1,
|
|
ValidFrom: time.Now(),
|
|
})
|
|
if err != nil {
|
|
m.t.Fatalf("create grant: %v", err)
|
|
}
|
|
if _, outcome, err := m.entQ.Confer(m.ctx, entitlements.ConferParams{
|
|
PoolID: pool.PoolID, ProductID: productID,
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true}, Quantity: 1,
|
|
}); err != nil || outcome != "created" {
|
|
m.t.Fatalf("confer = (%q, %v)", outcome, err)
|
|
}
|
|
if err := entitlements.MaterializePoolEntitlements(m.ctx, m.entQ, pool.PoolID); err != nil {
|
|
m.t.Fatalf("materialize: %v", err)
|
|
}
|
|
}
|
|
|
|
func (m *memberHarness) linkPerson(personID string, uid int64, username string) {
|
|
m.t.Helper()
|
|
if _, err := dcmod.New(m.tx).CreateUserLink(m.ctx, dcmod.CreateUserLinkParams{
|
|
PersonID: personID, DiscourseUserID: uid, DiscourseUsername: username, LinkedVia: "oidc",
|
|
}); err != nil {
|
|
m.t.Fatalf("link person: %v", err)
|
|
}
|
|
}
|
|
|
|
func (m *memberHarness) render(personID string) string {
|
|
m.t.Helper()
|
|
rec := httptest.NewRecorder()
|
|
m.h.RenderForPerson(rec, m.ctx, personID)
|
|
if rec.Code != 200 {
|
|
m.t.Fatalf("render = %d, body: %s", rec.Code, rec.Body.String())
|
|
}
|
|
body := rec.Body.String()
|
|
// Read-only contract: no mutating controls in any state.
|
|
for _, banned := range []string{"<form", "hx-post", "hx-delete", "hx-put", "hx-patch"} {
|
|
if strings.Contains(body, banned) {
|
|
m.t.Errorf("member forum partial contains mutating control %q: %s", banned, body)
|
|
}
|
|
}
|
|
return body
|
|
}
|
|
|
|
// The three member-visible states: access+link → Active with a forum link;
|
|
// access without a link → guidance, not an error; no access → not-included
|
|
// message, card body still renders.
|
|
func TestMemberForumAccessStates(t *testing.T) {
|
|
m := newMemberHarness(t, linkage.ModeOIDC)
|
|
|
|
linked := m.newPerson("linked")
|
|
m.grantForumAccess(linked)
|
|
m.linkPerson(linked, 101, "linked_user")
|
|
body := m.render(linked)
|
|
if !strings.Contains(body, ">Active<") || !strings.Contains(body, `href="https://forum.example.test"`) {
|
|
t.Errorf("linked member missing Active state or forum link: %s", body)
|
|
}
|
|
|
|
unlinked := m.newPerson("unlinked")
|
|
m.grantForumAccess(unlinked)
|
|
body = m.render(unlinked)
|
|
if !strings.Contains(body, ">Granted<") || !strings.Contains(body, "Sign in to the forum") {
|
|
t.Errorf("unlinked member missing granted-state guidance: %s", body)
|
|
}
|
|
if strings.Contains(body, "Error") {
|
|
t.Errorf("unlinked member rendered as an error state")
|
|
}
|
|
|
|
none := m.newPerson("none")
|
|
body = m.render(none)
|
|
if !strings.Contains(body, "isn't included in your current plan") {
|
|
t.Errorf("member without access missing not-included state: %s", body)
|
|
}
|
|
|
|
// A conflict-status link blocks delivery: render as not-linked
|
|
// guidance, never as Active.
|
|
conflicted := m.newPerson("conflicted")
|
|
m.grantForumAccess(conflicted)
|
|
m.linkPerson(conflicted, 102, "conflicted_user")
|
|
if _, err := m.tx.ExecContext(m.ctx,
|
|
`UPDATE discourse.user_links SET status = 'conflict' WHERE person_id = $1`, conflicted); err != nil {
|
|
t.Fatalf("set conflict: %v", err)
|
|
}
|
|
body = m.render(conflicted)
|
|
if strings.Contains(body, ">Active<") {
|
|
t.Errorf("conflict-status link rendered as Active: %s", body)
|
|
}
|
|
if !strings.Contains(body, ">Granted<") {
|
|
t.Errorf("conflict-status link missing guidance state: %s", body)
|
|
}
|
|
}
|
|
|
|
// Email mode swaps the not-yet-linked guidance copy.
|
|
func TestMemberForumEmailModeGuidance(t *testing.T) {
|
|
m := newMemberHarness(t, linkage.ModeEmail)
|
|
person := m.newPerson("emailmode")
|
|
m.grantForumAccess(person)
|
|
body := m.render(person)
|
|
if !strings.Contains(body, "account email") {
|
|
t.Errorf("email mode missing email guidance copy: %s", body)
|
|
}
|
|
}
|
|
|
|
// An unauthenticated request (no session) gets 401, not a render. A bare
|
|
// scs manager (in-memory store) stands in for auth.Setup, which would dial
|
|
// the real Valkey session store; GetUserSession only consults the manager.
|
|
func TestMemberForumUnauthenticated(t *testing.T) {
|
|
authConfig := &auth.Config{SessionManager: scs.New()}
|
|
h, err := web.NewMemberForumHandler(web.MemberForumHandlerConfig{
|
|
Logger: slog.Default(),
|
|
AuthConfig: authConfig,
|
|
ForumURL: "https://forum.example.test",
|
|
Mode: linkage.ModeOIDC,
|
|
TemplatesFS: os.DirFS("../templates"),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("construct handler: %v", err)
|
|
}
|
|
|
|
// GetUserSession requires scs session data in context, so route the
|
|
// request through LoadAndSave exactly as the server middleware stack
|
|
// does; with no session cookie the loaded session is empty → 401.
|
|
rec := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodGet, "/partials/discourse/forum-access", nil)
|
|
authConfig.SessionManager.LoadAndSave(http.HandlerFunc(h.GetForumAccess)).ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusUnauthorized {
|
|
t.Fatalf("unauthenticated request = %d, want 401", rec.Code)
|
|
}
|
|
}
|