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.
245 lines
9.2 KiB
Go
245 lines
9.2 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package server_test
|
|
|
|
// Handler tests for the member "create workspace" atomicity guard
|
|
// (schema-hardening tasks 2.1 + 2.5): a failure partway through creation
|
|
// rolls back the whole transaction and renders an error instead of
|
|
// "Workspace created successfully" (the swallowed-error block this replaces
|
|
// used to report success even when the pool assignment silently failed).
|
|
// DB-backed via TEST_DATABASE_URL (shared testDB helper, see
|
|
// operator_plan_ladders_test.go).
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/alexedwards/scs/v2"
|
|
"github.com/google/uuid"
|
|
|
|
"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/organization"
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/server"
|
|
)
|
|
|
|
// wcSession builds an authenticated member session context for orgID.
|
|
func wcSession(t *testing.T, orgID string) (context.Context, *auth.Config) {
|
|
t.Helper()
|
|
sm := scs.New()
|
|
sctx, err := sm.Load(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("session load: %v", err)
|
|
}
|
|
sm.Put(sctx, "authenticated", true)
|
|
sm.Put(sctx, "org_id", orgID)
|
|
return sctx, &auth.Config{SessionManager: sm}
|
|
}
|
|
|
|
// wcOrg creates a committed organization with no resource pool.
|
|
func wcOrg(t *testing.T, database *sql.DB, name string) string {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
tx, err := entitlements.BeginMaterializing(ctx, database, nil)
|
|
if err != nil {
|
|
t.Fatalf("begin: %v", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
iq := identity.New(tx)
|
|
oq := organization.New(tx)
|
|
user, err := iq.CreateUser(ctx, "wcp-u-"+uuid.NewString())
|
|
if err != nil {
|
|
t.Fatalf("user: %v", err)
|
|
}
|
|
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
|
|
UserID: user.UserID, DisplayName: name,
|
|
PrimaryEmail: "wcp-" + uuid.New().String()[:8] + "@example.com", PrimaryEmailVerified: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("person: %v", err)
|
|
}
|
|
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
|
|
Name: name, OrgType: "personal", OwnerPersonID: person.PersonID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("org: %v", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
t.Fatalf("commit: %v", err)
|
|
}
|
|
return org.OrgID
|
|
}
|
|
|
|
func newWcHandler(t *testing.T, database *sql.DB, authCfg *auth.Config) *server.WorkspacePartialsHandler {
|
|
t.Helper()
|
|
h, err := server.NewWorkspacePartialsHandler(server.WorkspacePartialsConfig{
|
|
OrgQ: organization.New(database),
|
|
EntitlementsQ: entitlements.New(database),
|
|
Database: database,
|
|
AuthConfig: authCfg,
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewWorkspacePartialsHandler: %v", err)
|
|
}
|
|
return h
|
|
}
|
|
|
|
// A failure partway through creation (here: the default-pool insert collides
|
|
// with a suspended default pool the resolution query cannot see) rolls
|
|
// back the whole transaction: no workspace row survives, and the member sees
|
|
// an error instead of "Workspace created successfully" (workspace-management
|
|
// spec, "Pool step failure fails the whole creation").
|
|
func TestCreateWorkspace_FailureRollsBackAndRendersError(t *testing.T) {
|
|
database := testDB(t)
|
|
orgID := wcOrg(t, database, "WSPartial Fail Org")
|
|
|
|
// GetDefaultPoolByOrgID only sees status = 'active', so this suspended
|
|
// decoy is invisible to resolution but still collides via
|
|
// uq_resource_pools_one_default_per_org (migration 00010, partial on
|
|
// pool_type only) once the handler falls through to CreateResourcePool.
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`INSERT INTO core.resource_pools (org_id, name, pool_type, status) VALUES ($1, 'Decoy', 'default', 'suspended')`,
|
|
orgID); err != nil {
|
|
t.Fatalf("seed decoy pool: %v", err)
|
|
}
|
|
|
|
sctx, authCfg := wcSession(t, orgID)
|
|
h := newWcHandler(t, database, authCfg)
|
|
|
|
form := url.Values{"name": {"My Workspace"}}
|
|
req := httptest.NewRequestWithContext(sctx, http.MethodPost, "/partials/workspaces", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.CreateWorkspace(rec, req)
|
|
|
|
// Success fires an HX-Trigger toast, not body text (finding FA-25); a
|
|
// failed creation must not fire it.
|
|
if trigger := rec.Header().Get("HX-Trigger"); strings.Contains(trigger, "showSuccessToast") {
|
|
t.Fatalf("expected the creation to fail, got a success toast trigger: %s", trigger)
|
|
}
|
|
body := rec.Body.String()
|
|
if strings.Contains(body, "—") {
|
|
t.Errorf("member-facing error contains an em dash: %q", body)
|
|
}
|
|
|
|
workspaces, err := organization.New(database).GetWorkspacesByOrgID(context.Background(), orgID)
|
|
if err != nil {
|
|
t.Fatalf("list workspaces: %v", err)
|
|
}
|
|
if len(workspaces) != 0 {
|
|
t.Errorf("workspaces = %d after a failed creation, want 0 (rolled back, not orphaned)", len(workspaces))
|
|
}
|
|
}
|
|
|
|
// Control: an ordinary creation (no poisoned pool) commits both the
|
|
// workspace and its primary pool assignment together, and reports success
|
|
// (workspace-management spec, "Successful creation yields an entitled
|
|
// workspace").
|
|
func TestCreateWorkspace_SuccessYieldsPrimaryPoolAssignment(t *testing.T) {
|
|
database := testDB(t)
|
|
orgID := wcOrg(t, database, "WSPartial Success Org")
|
|
|
|
sctx, authCfg := wcSession(t, orgID)
|
|
h := newWcHandler(t, database, authCfg)
|
|
|
|
form := url.Values{"name": {"My Workspace"}}
|
|
req := httptest.NewRequestWithContext(sctx, http.MethodPost, "/partials/workspaces", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
|
|
h.CreateWorkspace(rec, req)
|
|
|
|
// Success is an HX-Trigger toast (finding FA-25), not body text.
|
|
if trigger := rec.Header().Get("HX-Trigger"); !strings.Contains(trigger, "showSuccessToast") {
|
|
t.Fatalf("expected a success toast trigger, got HX-Trigger=%q, body: %s", trigger, rec.Body.String())
|
|
}
|
|
|
|
workspaces, err := organization.New(database).GetWorkspacesByOrgID(context.Background(), orgID)
|
|
if err != nil {
|
|
t.Fatalf("list workspaces: %v", err)
|
|
}
|
|
if len(workspaces) != 1 {
|
|
t.Fatalf("workspaces = %d, want 1", len(workspaces))
|
|
}
|
|
|
|
assignment, err := entitlements.New(database).GetPrimaryPoolAssignmentByWorkspace(context.Background(), workspaces[0].WorkspaceID)
|
|
if err != nil {
|
|
t.Fatalf("expected a primary pool assignment: %v", err)
|
|
}
|
|
if !assignment.IsPrimary {
|
|
t.Error("expected the assignment to be primary")
|
|
}
|
|
}
|
|
|
|
// A workspace name that duplicates a live one in the same organization,
|
|
// ignoring case, is refused: the handler re-renders the create form with the
|
|
// message on the name field and HTTP 422, never a raw constraint name
|
|
// (db-error-presentation; design D1/D4). A deleted workspace frees its name,
|
|
// so the same name is accepted again afterwards.
|
|
func TestCreateWorkspace_DuplicateNameRenders422OnTheNameField(t *testing.T) {
|
|
database := testDB(t)
|
|
orgID := wcOrg(t, database, "WSPartial Collision Org")
|
|
|
|
sctx, authCfg := wcSession(t, orgID)
|
|
h := newWcHandler(t, database, authCfg)
|
|
|
|
post := func(name string) *httptest.ResponseRecorder {
|
|
form := url.Values{"name": {name}}
|
|
req := httptest.NewRequestWithContext(sctx, http.MethodPost, "/partials/workspaces", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
rec := httptest.NewRecorder()
|
|
h.CreateWorkspace(rec, req)
|
|
return rec
|
|
}
|
|
|
|
if rec := post("Support"); !strings.Contains(rec.Header().Get("HX-Trigger"), "showSuccessToast") {
|
|
t.Fatalf("expected the first creation to succeed, got HX-Trigger=%q, body: %s", rec.Header().Get("HX-Trigger"), rec.Body.String())
|
|
}
|
|
|
|
rec := post("support")
|
|
if rec.Code != http.StatusUnprocessableEntity {
|
|
t.Errorf("status = %d, want %d", rec.Code, http.StatusUnprocessableEntity)
|
|
}
|
|
body := rec.Body.String()
|
|
if !strings.Contains(body, "A workspace with this name already exists in this organization.") {
|
|
t.Errorf("expected the duplicate-name field message, got: %s", body)
|
|
}
|
|
if !strings.Contains(body, "is-invalid") || !strings.Contains(body, "invalid-feedback") {
|
|
t.Errorf("expected the message to render on the name field, got: %s", body)
|
|
}
|
|
for _, leak := range []string{"uq_workspaces_org_id_name_ci", "SQLSTATE", "23505"} {
|
|
if strings.Contains(body, leak) {
|
|
t.Errorf("response leaked driver text %q: %s", leak, body)
|
|
}
|
|
}
|
|
|
|
workspaces, err := organization.New(database).GetWorkspacesByOrgID(context.Background(), orgID)
|
|
if err != nil {
|
|
t.Fatalf("list workspaces: %v", err)
|
|
}
|
|
if len(workspaces) != 1 {
|
|
t.Fatalf("workspaces = %d, want 1 (the duplicate was never created)", len(workspaces))
|
|
}
|
|
|
|
// The index is partial on live rows: deleting frees the name.
|
|
if _, err := database.ExecContext(context.Background(),
|
|
`UPDATE core.workspaces SET status = 'deleted' WHERE org_id = $1`, orgID); err != nil {
|
|
t.Fatalf("soft-delete: %v", err)
|
|
}
|
|
if rec := post("Support"); !strings.Contains(rec.Header().Get("HX-Trigger"), "showSuccessToast") {
|
|
t.Fatalf("expected the name to be reusable after deletion, got HX-Trigger=%q, body: %s", rec.Header().Get("HX-Trigger"), rec.Body.String())
|
|
}
|
|
}
|