Files
member-console/internal/provisioning/workspace_creation_test.go
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

213 lines
7.0 KiB
Go

package provisioning_test
// Tests for CreateWorkspaceWithPrimaryAssignment (schema-hardening tasks 2.1 +
// 2.5): the function both AutoProvision and the member "create workspace"
// handler now share. AutoProvision's existing tests already exercise the
// "no pool yet" (create) branch end to end; these tests focus on the
// "pool already exists" (resolve) branch the member handler relies on, and on
// the atomicity guarantee itself.
import (
"context"
"database/sql"
"errors"
"testing"
"github.com/jackc/pgx/v5/pgconn"
"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/provisioning"
"github.com/google/uuid"
)
// wcOrg creates a committed organization (no pool) for these tests.
func wcOrg(t *testing.T, database *sql.DB, name string) string {
t.Helper()
ctx := context.Background()
tx, err := database.BeginTx(ctx, 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, "wc-u-"+uuid.NewString())
if err != nil {
t.Fatalf("user: %v", err)
}
person, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
UserID: user.UserID, DisplayName: name,
PrimaryEmail: "wc-" + 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
}
// A second workspace for the same org resolves the org's existing default
// pool instead of creating a second one -- the branch AutoProvision itself
// never exercises (it always provisions a brand-new org) but the member
// "create workspace" handler relies on every time.
func TestCreateWorkspaceWithPrimaryAssignment_ResolvesExistingPool(t *testing.T) {
database := testDB(t)
ctx := context.Background()
orgID := wcOrg(t, database, "WC Resolve Org")
tx1, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
res1, err := provisioning.CreateWorkspaceWithPrimaryAssignment(ctx, tx1, orgID, "First")
if err != nil {
t.Fatalf("first create: %v", err)
}
if err := tx1.Commit(); err != nil {
t.Fatalf("commit first: %v", err)
}
if res1.Pool.PoolID == "" {
t.Fatal("expected a pool to be created for the org's first workspace")
}
tx2, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
res2, err := provisioning.CreateWorkspaceWithPrimaryAssignment(ctx, tx2, orgID, "Second")
if err != nil {
t.Fatalf("second create: %v", err)
}
if err := tx2.Commit(); err != nil {
t.Fatalf("commit second: %v", err)
}
if res2.Pool.PoolID != res1.Pool.PoolID {
t.Errorf("second workspace got pool %s, want the org's existing default pool %s", res2.Pool.PoolID, res1.Pool.PoolID)
}
if res2.Workspace.WorkspaceID == res1.Workspace.WorkspaceID {
t.Fatal("expected two distinct workspaces")
}
if !res2.PoolAssignment.IsPrimary {
t.Error("expected the second assignment to be primary too (each workspace gets its own primary assignment)")
}
entQ := entitlements.New(database)
pools, err := entQ.GetResourcePoolsByOrgID(ctx, orgID)
if err != nil {
t.Fatalf("list pools: %v", err)
}
if len(pools) != 1 {
t.Errorf("org has %d pools after two workspace creations, want exactly 1 (resolution, not a second creation)", len(pools))
}
}
// A failure partway through (here: the default-pool insert collides with a
// suspended default pool the resolution query cannot see) rolls back the
// whole transaction -- no orphan workspace row survives with no pool
// assignment.
func TestCreateWorkspaceWithPrimaryAssignment_FailureRollsBackWorkspaceInsert(t *testing.T) {
database := testDB(t)
ctx := context.Background()
orgID := wcOrg(t, database, "WC Rollback Org")
// Poison the default-pool creation branch: GetDefaultPoolByOrgID filters
// status = 'active', so a suspended default pool is invisible to
// resolution but still collides on uq_resource_pools_one_default_per_org
// (migration 00010, which is partial on pool_type only) when the
// function falls through to CreateResourcePool.
if _, err := database.ExecContext(ctx,
`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)
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
_, err = provisioning.CreateWorkspaceWithPrimaryAssignment(ctx, tx, orgID, "Doomed")
if err == nil {
t.Fatal("expected the pool-creation collision to fail")
}
if rbErr := tx.Rollback(); rbErr != nil {
t.Fatalf("rollback: %v", rbErr)
}
oq := organization.New(database)
workspaces, err := oq.GetWorkspacesByOrgID(ctx, 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))
}
}
// A second workspace whose name matches an existing live one in the same
// organization, ignoring case, is refused by uq_workspaces_org_id_name_ci
// (design D1/D4): the name is the workspace's only handle, so it has to be
// unambiguous where a member reads it. The index is partial on live rows, so
// a deleted workspace hands its name back.
func TestCreateWorkspaceWithPrimaryAssignment_DuplicateNameRefused(t *testing.T) {
database := testDB(t)
ctx := context.Background()
orgID := wcOrg(t, database, "WC Collision Org")
create := func(name string) error {
tx, err := database.BeginTx(ctx, nil)
if err != nil {
t.Fatal(err)
}
defer tx.Rollback()
if _, err := provisioning.CreateWorkspaceWithPrimaryAssignment(ctx, tx, orgID, name); err != nil {
return err
}
return tx.Commit()
}
if err := create("Engineering"); err != nil {
t.Fatalf("first create: %v", err)
}
// Case-insensitive: "engineering" is the same name to a reader.
err := create("engineering")
if err == nil {
t.Fatal("expected the colliding-name creation to be refused")
}
var pgErr *pgconn.PgError
if !errors.As(err, &pgErr) || pgErr.ConstraintName != "uq_workspaces_org_id_name_ci" {
t.Fatalf("want a uq_workspaces_org_id_name_ci violation, got %v", err)
}
// A deleted workspace frees its name.
if _, err := database.ExecContext(ctx,
`UPDATE core.workspaces SET status = 'deleted' WHERE org_id = $1 AND lower(name) = 'engineering'`,
orgID); err != nil {
t.Fatalf("soft-delete: %v", err)
}
if err := create("Engineering"); err != nil {
t.Fatalf("recreate after delete: %v", err)
}
workspaces, err := organization.New(database).GetWorkspacesByOrgID(ctx, orgID)
if err != nil {
t.Fatalf("list workspaces: %v", err)
}
if len(workspaces) != 2 {
t.Fatalf("workspaces = %d, want 2 (one deleted, one live)", len(workspaces))
}
}