Files
member-console/internal/server/member_switch_gate_db_test.go
T
cgalo5758 b245bdb0a9 Fix security audit findings from 2026-09-21 scan
Remediate six confirmed security issues: deployment-only config keys,
bounded provider responses, short-lived registration sessions, private
init file mode, FedWiki workflow authorization, and switch preview
gates.

- Add DeploymentOnly config key declaration; refuse runtime overrides
  for keys that decide where secrets are sent
- Create httplimit package; bound all provider response reads at 8 MiB
- Set fifteen-minute deadline on /register sessions
- Write mc-config.yaml with 0600 permissions
- Derive FedWiki workflow IDs from site IDs; re-authorize sites before
  mutating activities
- Apply switch authorization gates to the proration preview
2026-09-21 13:57:57 -05:00

192 lines
7.7 KiB
Go

// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
package server_test
import (
"context"
"database/sql"
"net/http"
"net/http/httptest"
"net/url"
"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"
internalstripe "git.coopcloud.tech/wiki-cafe/member-console/internal/integrations/stripe/store"
"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"
)
// TestSwitchRefusesADraftTierThroughTheHandler drives POST
// /partials/member/plans/switch end to end against the database: a draft
// tier that is otherwise fully purchasable (active, public, priced,
// Stripe-mapped) is refused with the unavailable-plan message before any
// subscription is read (2026-09 security audit, finding 12). The control
// posts for a published tier and is refused one precondition later, by
// SwitchPlan's own subscription check, which proves the gate opened.
func TestSwitchRefusesADraftTierThroughTheHandler(t *testing.T) {
database := testDB(t)
ctx := context.Background()
tx, err := entitlements.BeginMaterializing(ctx, database, 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)
sq := internalstripe.New(tx)
sfx := uuid.New().String()[:8]
es, err := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "switch-gate-set-" + sfx})
if err != nil {
t.Fatalf("create entitlement set: %v", err)
}
esID := uuid.NullUUID{UUID: uuid.MustParse(es.SetID), Valid: true}
ladder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "Switch Gate Ladder " + sfx, IsActive: true})
if err != nil {
t.Fatalf("create ladder: %v", err)
}
tier := func(name, lifecycle string) billing.Price {
t.Helper()
product, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: name + " " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: lifecycle,
})
if err != nil {
t.Fatalf("create %s: %v", name, err)
}
price, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: product.ProductID, Currency: "usd", UnitAmount: 1000,
RecurringInterval: sql.NullString{String: "month", Valid: true},
})
if err != nil {
t.Fatalf("create %s price: %v", name, err)
}
if _, err := sq.UpsertPriceMapping(ctx, internalstripe.UpsertPriceMappingParams{
PriceID: price.PriceID, StripePriceID: sql.NullString{String: "price_stripe_" + sfx + "_" + lifecycle, Valid: true}, SyncStatus: "synced",
}); err != nil {
t.Fatalf("%s price mapping: %v", name, err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: product.ProductID}); err != nil {
t.Fatalf("create %s tier: %v", name, err)
}
return price
}
draft := tier("Hidden Tier", "draft")
published := tier("Open Tier", "published")
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: "Switch Gate Member",
PrimaryEmail: "switch-gate-" + sfx + "@example.com", PrimaryEmailVerified: true,
})
if err != nil {
t.Fatalf("create person: %v", err)
}
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{Name: "Switch Gate Org", OrgType: "personal", OwnerPersonID: person.PersonID})
if err != nil {
t.Fatalf("create org: %v", err)
}
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(), Database: database,
})
if err != nil {
t.Fatalf("new handler: %v", err)
}
post := func(priceID string) string {
t.Helper()
form := url.Values{"ladder_id": {ladder.PlanLadderID}, "price_id": {priceID}}
req := httptest.NewRequest(http.MethodPost, "/partials/member/plans/switch", strings.NewReader(form.Encode())).WithContext(sctx)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
rec := httptest.NewRecorder()
h.PostSwitch(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("switch to %s: status %d: %s", priceID, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
const unavailable = "That plan is no longer available."
if body := post(draft.PriceID); !strings.Contains(body, unavailable) {
t.Errorf("a switch onto a draft tier must be refused as unavailable; body:\n%s", body)
}
body := post(published.PriceID)
if strings.Contains(body, unavailable) {
t.Errorf("a published tier must pass the gate; body:\n%s", body)
}
if !strings.Contains(body, "no active subscription to change on this plan.") {
t.Errorf("a published tier must be refused by SwitchPlan's subscription check, one step past the gate; body:\n%s", body)
}
// The preview asks the same gate (2026-09-21 audit finding 6,
// security-audit-remediation-3 design D6): a draft tier, or a tier of
// another ladder, is refused with the switch's own answer, and the
// target's name never reaches the page. The published on-ladder control
// passes the gate and renders its name in the banner.
otherLadder, err := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{Name: "Other Ladder " + sfx, IsActive: true})
if err != nil {
t.Fatalf("create other ladder: %v", err)
}
offLadderProduct, err := bq.CreateProduct(ctx, billing.CreateProductParams{
Name: "Elsewhere Tier " + sfx, IsActive: true, IsPublic: true, EntitlementSetID: esID, LifecycleStatus: "published",
})
if err != nil {
t.Fatalf("create off-ladder product: %v", err)
}
offLadder, err := bq.CreatePrice(ctx, billing.CreatePriceParams{
ProductID: offLadderProduct.ProductID, Currency: "usd", UnitAmount: 500,
RecurringInterval: sql.NullString{String: "month", Valid: true},
})
if err != nil {
t.Fatalf("create off-ladder price: %v", err)
}
if _, err := bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: otherLadder.PlanLadderID, ProductID: offLadderProduct.ProductID}); err != nil {
t.Fatalf("create off-ladder tier: %v", err)
}
preview := func(priceID string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/partials/member/plans/switch/preview?ladder_id="+url.QueryEscape(ladder.PlanLadderID)+"&price_id="+url.QueryEscape(priceID), nil).WithContext(sctx)
rec := httptest.NewRecorder()
h.GetSwitchPreview(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("preview of %s: status %d: %s", priceID, rec.Code, rec.Body.String())
}
return rec.Body.String()
}
if body := preview(draft.PriceID); !strings.Contains(body, unavailable) || strings.Contains(body, "Hidden Tier") {
t.Errorf("a preview of a draft tier must be refused as unavailable and must not name the tier; body:\n%s", body)
}
if body := preview(offLadder.PriceID); !strings.Contains(body, unavailable) || strings.Contains(body, "Elsewhere Tier") {
t.Errorf("a preview of a tier from another ladder must be refused as unavailable and must not name the tier; body:\n%s", body)
}
if body := preview(published.PriceID); strings.Contains(body, unavailable) || !strings.Contains(body, "Open Tier") {
t.Errorf("a preview of a published tier of the ladder must pass the gate and name the tier; body:\n%s", body)
}
}