Files
member-console/internal/server/member_catalog_test.go
T
cgalo5758 8e7e0dd04d Harden container, OIDC auth, and error handling
Run Docker runtime stage as non-root user app (UID 65532).

Add styled full-page 404/500 error rendering for navigation requests
while preserving plain-text responses for HTMX partials.

Reuse recent unconsumed OIDC login state to avoid state mismatch on
parallel login hits, and merge resource_access in role extraction.

Re-level template headings, add autocomplete tokens, and resolve
catalog resource display names.

Self-label test-stack secrets and document CSRF secret rotation.
2026-07-31 23:27:15 -05:00

297 lines
11 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.coopcloud.tech/wiki-cafe/member-console/internal/auth"
"git.coopcloud.tech/wiki-cafe/member-console/internal/billing"
"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/organization"
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
"github.com/alexedwards/scs/v2"
"github.com/google/uuid"
)
// TestGetPlansLadderAware exercises the ladder-grouped catalog end to end against
// the database: plans group by ladder in rank order, ladder sections honor the
// configurable sort_order, the current rung is marked per (pool, ladder), and a
// product that is a tier in two ladders renders under each with independent
// state. Covers tasks 7.17.5.
func TestGetPlansLadderAware(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
bq := billing.New(tx)
eq := entitlements.New(tx)
iq := identity.New(tx)
oq := organization.New(tx)
sfx := uuid.New().String()[:8]
// Shared entitlement set so the rank-0 default product can back a grant
// (ReapplyDefaultsForPool requires the default tier to have one).
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{
Name: "catalog-set-" + sfx,
})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
// A numeric rule on a seeded resource key: tier cards must render its
// display name ("Wiki Sites", 00002_seed_resource_keys.sql), never the
// raw key (member-product-discovery delta, hardening-polish).
if _, err := eq.CreateEntitlementSetRule(ctx, entitlements.CreateEntitlementSetRuleParams{
SetID: es.SetID,
RuleType: "limit",
ResourceKey: sql.NullString{String: "fedwiki_sites", Valid: true},
ResourceValue: sql.NullInt64{Int64: 16, Valid: true},
StackingPolicy: sql.NullString{String: "additive", Valid: true},
}); err != nil {
t.Fatalf("create entitlement rule: %v", err)
}
// Three public plan products.
mkProduct := func(name string) billing.Product {
p, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + "-" + sfx,
IsActive: true,
IsPublic: true,
EntitlementSetID: esID,
LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create product %s: %v", name, err)
}
return p
}
public := mkProduct("Public")
standard := mkProduct("Standard")
premium := mkProduct("Premium")
mkLadder := func(key, name string, sortOrder int32) billing.PlanLadder {
l, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{
LadderKey: key + "-" + sfx,
Name: name,
IsActive: true,
})
if err != nil {
t.Fatalf("create ladder %s: %v", key, err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE core.plan_ladders SET sort_order = $1 WHERE plan_ladder_id = $2`,
sortOrder, l.PlanLadderID); err != nil {
t.Fatalf("set sort_order on %s: %v", key, err)
}
return l
}
// Support sorts before Hosting (5 < 10) despite the alphabetical order.
hosting := mkLadder("hosting", "Hosting Services", 10)
support := mkLadder("support", "Support Services", 5)
// CreatePlanLadderTier appends at MAX+1; fixtures that need an explicit
// rank place the tier with UpdateTierRank afterwards.
mkTier := func(l billing.PlanLadder, p billing.Product, rank int32) {
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{
PlanLadderID: l.PlanLadderID,
ProductID: p.ProductID,
}); err != nil {
t.Fatalf("create tier: %v", err)
}
if _, err := bq.UpdateTierRank(ctx, billing.UpdateTierRankParams{
PlanLadderID: l.PlanLadderID, ProductID: p.ProductID, Rank: rank,
}); err != nil {
t.Fatalf("set tier rank: %v", err)
}
}
// hosting: Public(0) -> Standard(1) -> Premium(2)
mkTier(hosting, public, 0)
mkTier(hosting, standard, 1)
mkTier(hosting, premium, 2)
// support: Public(0) -> Standard(1). Public and Standard are tiers in BOTH
// ladders (the M:N case).
mkTier(support, public, 0)
mkTier(support, standard, 1)
// Org type defaults to the hosting ladder, so ReapplyDefaults attaches the
// pool to hosting's rank-0 rung (Public).
orgType := "ca-" + uuid.New().String()[:4]
if _, err := tx.ExecContext(ctx,
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id)
VALUES ($1, $2, true, $3)`,
orgType, "Catalog Type", hosting.PlanLadderID); err != nil {
t.Fatalf("create org type: %v", err)
}
user, err := iq.CreateUser(ctx, "u-"+uuid.New().String())
if err != nil {
t.Fatalf("create user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID,
DisplayName: "Catalog Member",
PrimaryEmail: "cat-" + sfx + "@example.com",
PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
Name: "Catalog Org",
Slug: "cat-org-" + sfx,
OrgType: orgType,
OwnerPersonID: person.PersonID,
})
if err != nil {
t.Fatalf("create org: %v", err)
}
pool, err := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
OrgID: org.OrgID,
Name: "default",
Slug: "default",
PoolType: "default",
IsAutoManaged: true,
})
if err != nil {
t.Fatalf("create pool: %v", err)
}
if _, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID,
entitlements.Actor{ActorType: "system", Reason: "test"}); err != nil {
t.Fatalf("reapply defaults: %v", err)
}
// Authenticated session for this org.
sm := scs.New()
sctx, err := sm.Load(ctx, "")
if err != nil {
t.Fatalf("load session: %v", err)
}
sm.Put(sctx, "authenticated", true)
sm.Put(sctx, "org_id", org.OrgID)
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{
EntitlementsQ: eq,
BillingQ: bq,
AuthConfig: &auth.Config{SessionManager: sm},
Logger: discardLogger(),
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans", nil).WithContext(sctx)
rec := httptest.NewRecorder()
h.GetPlans(rec, req)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// Both ladder sections render.
hostingIdx := strings.Index(body, "Hosting Services")
supportIdx := strings.Index(body, "Support Services")
if hostingIdx < 0 || supportIdx < 0 {
t.Fatalf("expected both ladder section headers; hosting=%d support=%d", hostingIdx, supportIdx)
}
// 7.5: ladder sections ordered by sort_order (support=5 before hosting=10).
if supportIdx > hostingIdx {
t.Errorf("expected Support Services (sort_order 5) before Hosting Services (sort_order 10)")
}
// 7.2 + 7.3: the current rung is marked per (pool, ladder). Public is a
// tier in BOTH ladders, and Doc 41's confer derives the product's full
// shape — ReapplyDefaultsForPool enrolls the pool on hosting AND support
// in one conferral (one provision, one junction row per ladder). "Current"
// is a set — one rung per enrolled ladder — so a badge appears in each
// section, computed from that ladder's own attachment.
if got := strings.Count(body, "Current plan"); got != 2 {
t.Errorf("expected 2 'Current plan' badges (Public holds a rung on both ladders), got %d", got)
}
// 7.3: the support section (rendered first) lists Public and Standard — the
// products it shares with hosting — and marks Public current there too.
supportSection := body[:hostingIdx]
if !strings.Contains(supportSection, "Public-"+sfx) || !strings.Contains(supportSection, "Standard-"+sfx) {
t.Errorf("support section should list the shared Public and Standard tiers")
}
if got := strings.Count(supportSection, "Current plan"); got != 1 {
t.Errorf("support section 'Current plan' badges = %d, want 1 (Public's rung on support)", got)
}
// 7.4: nothing is purchasable (no Stripe mapping, Database nil), so no move
// routes to checkout; builtPath moves render the purchase-unavailable reason.
if got := strings.Count(body, `hx-post="/billing/checkout"`); got != 0 {
t.Errorf("expected 0 enabled checkout controls without a Stripe mapping, got %d", got)
}
if !strings.Contains(body, "Not available for purchase yet") {
t.Errorf("expected the purchase-unavailable reason on the disabled move controls")
}
// Feature lines resolve resource-key display names; the raw key must not
// leak onto the card.
if !strings.Contains(body, "16 Wiki Sites") {
t.Errorf("expected entitlement feature to render the display name '16 Wiki Sites'")
}
if strings.Contains(body, "fedwiki_sites") {
t.Errorf("raw resource key 'fedwiki_sites' leaked into the catalog body")
}
}
// TestMemberEntitlementsPerAxisRendering verifies the entitlement partial lists
// the current tier for every enrolled axis (a set), and shows the off-plan state
// for a pool with no attachment. Covers task 7.6 at the presentation layer; the
// handler's per-attachment emission is exercised live by TestGetPlansLadderAware
// via the same GetActiveAttachmentsByPool path.
func TestMemberEntitlementsPerAxisRendering(t *testing.T) {
h, err := server.NewMemberProductsHandler(server.MemberProductsConfig{Logger: discardLogger()})
if err != nil {
t.Fatalf("new handler: %v", err)
}
data := server.EntitlementsData{
HasEntitlements: true,
PoolTiers: []server.PoolTierViewModel{
{PoolName: "default", LadderKey: "hosting", TierName: "Standard", HasTier: true},
{PoolName: "default", LadderKey: "support", TierName: "Basic", HasTier: true},
},
}
rec := httptest.NewRecorder()
h.Templates.Render(rec, "member_entitlements.html", data)
if rec.Code != 200 {
t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
}
body := rec.Body.String()
// Both enrolled axes are listed (not just the first attachment).
for _, want := range []string{"hosting", "Standard", "support", "Basic"} {
if !strings.Contains(body, want) {
t.Errorf("expected entitlement view to mention %q (per-axis listing)", want)
}
}
if got := strings.Count(body, "You are on"); got != 2 {
t.Errorf("expected one 'You are on' line per enrolled axis (2), got %d", got)
}
// A pool with no attachment shows the off-plan state.
off := httptest.NewRecorder()
h.Templates.Render(off, "member_entitlements.html", server.EntitlementsData{
HasEntitlements: true,
PoolTiers: []server.PoolTierViewModel{{PoolName: "default"}},
})
if !strings.Contains(off.Body.String(), "No active plan") {
t.Errorf("expected 'No active plan' for a pool with no attachment")
}
}