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.
213 lines
7.0 KiB
Go
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))
|
|
}
|
|
}
|