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