Guard cluster-global CREATE ROLE in all five migration streams with
pg_roles checks so multiple databases can migrate in one cluster, and
tolerate still-referenced roles on Down.
Add test/reset-test-db.sh to drop and recreate member_console_test and
member_console_e2e per run, emit their DSNs from bootstrap, and add a
make test target that resets then runs the suite serialized; parallel
unit packages sharing one database still interfered even after the e2e
split.
Fix customdomain_db_test.go, stale since 0affda7 and previously passing
only through pollution. Bootstrap and the Makefile carry small forward
references to the compose-profile knob introduced next.
Archives the test-db-isolation change.
525 lines
23 KiB
Go
525 lines
23 KiB
Go
package planmanagement_test
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"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/db"
|
|
"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/migrate"
|
|
"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"
|
|
_ "github.com/jackc/pgx/v5/stdlib"
|
|
)
|
|
|
|
func testDB(t *testing.T) *sql.DB {
|
|
t.Helper()
|
|
// E2E_DATABASE_URL, not TEST_DATABASE_URL: this suite commits fixtures
|
|
// (an org type's default plan ladder among them) that package tests
|
|
// assert the absence of, so it gets its own database. See
|
|
// test/reset-test-db.sh.
|
|
dsn := os.Getenv("E2E_DATABASE_URL")
|
|
if dsn == "" {
|
|
t.Skip("E2E_DATABASE_URL not set, skipping e2e test")
|
|
}
|
|
|
|
database, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("failed to open database: %v", err)
|
|
}
|
|
|
|
sources := migrate.Sources()
|
|
if err := db.RunMigrations(database, sources); err != nil {
|
|
t.Fatalf("failed to run migrations: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() { database.Close() })
|
|
return database
|
|
}
|
|
|
|
// TestMVPPlanManagement exercises the full M6 MVP checkpoint:
|
|
// 1. Operator configures a 3-tier ladder
|
|
// 2. Operator sets auto-provisioning at rank 0
|
|
// 3. Operator issues a manual trial grant at rank 1
|
|
// 4. Trial expires; its conferral ends and the default is reapplied at rank 0
|
|
// 5. Transition audit shows the full trajectory
|
|
func TestMVPPlanManagement(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
|
|
tx, err := database.BeginTx(ctx, 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)
|
|
|
|
// ---- 8.2 Configure 3-tier ladder ----
|
|
es0, _ := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "e2e-set-0-" + uuid.New().String()[:8]})
|
|
es1, _ := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "e2e-set-1-" + uuid.New().String()[:8]})
|
|
es2, _ := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "e2e-set-2-" + uuid.New().String()[:8]})
|
|
|
|
publicProd, _ := bq.CreateProduct(ctx, billing.CreateProductParams{Name: "Entry Tier", DisplayCategory: sql.NullString{}, IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es0.SetID), Valid: true}, LifecycleStatus: "published"})
|
|
standardProd, _ := bq.CreateProduct(ctx, billing.CreateProductParams{Name: "Standard Tier", DisplayCategory: sql.NullString{}, IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es1.SetID), Valid: true}, LifecycleStatus: "published"})
|
|
proProd, _ := bq.CreateProduct(ctx, billing.CreateProductParams{Name: "Pro Tier", DisplayCategory: sql.NullString{}, IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(es2.SetID), Valid: true}, LifecycleStatus: "published"})
|
|
|
|
ladder, _ := bq.CreatePlanLadder(ctx, billing.CreatePlanLadderParams{LadderKey: "mvp-ladder-" + uuid.New().String()[:8], Name: "MVP Ladder", IsActive: true})
|
|
// Append order sets the ranks: entry=0, standard=1, pro=2.
|
|
bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: publicProd.ProductID})
|
|
bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: standardProd.ProductID})
|
|
bq.CreatePlanLadderTier(ctx, billing.CreatePlanLadderTierParams{PlanLadderID: ladder.PlanLadderID, ProductID: proProd.ProductID})
|
|
|
|
tiers, _ := bq.ListTiersByLadder(ctx, ladder.PlanLadderID)
|
|
if len(tiers) != 3 {
|
|
t.Fatalf("expected 3 tiers, got %d", len(tiers))
|
|
}
|
|
|
|
// ---- 8.3 Set auto-provisioning at rank 0 ----
|
|
orgType := "e2e-" + uuid.New().String()[:4]
|
|
_, err = tx.ExecContext(ctx,
|
|
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id) VALUES ($1, $2, true, $3)`,
|
|
orgType, "E2E Type", ladder.PlanLadderID,
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("create org type: %v", err)
|
|
}
|
|
_ = publicProd // resolved at use-time via the ladder's rank-0 tier
|
|
|
|
user, _ := iq.CreateUser(ctx, "u-"+uuid.New().String())
|
|
person, _ := iq.CreatePerson(ctx, identity.CreatePersonParams{UserID: user.UserID, DisplayName: "E2E User", PrimaryEmail: "e2e-" + uuid.New().String()[:8] + "@example.com", PrimaryEmailVerified: true})
|
|
org, _ := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{Name: "E2E Org", Slug: "e2e-org-" + uuid.New().String()[:8], OrgType: orgType, OwnerPersonID: person.PersonID})
|
|
pool, _ := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{OrgID: org.OrgID, Name: "default", Slug: "default", PoolType: "default", IsAutoManaged: true})
|
|
|
|
// Auto-provision (simulates org creation)
|
|
actor := entitlements.Actor{ActorType: "system", Reason: "auto-provisioning on org creation"}
|
|
res0, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID, actor)
|
|
if err != nil {
|
|
t.Fatalf("auto-provision: %v", err)
|
|
}
|
|
if res0.AlreadyAtTier || res0.Outcome != "created" {
|
|
t.Fatalf("expected fresh default conferral, got %+v", res0)
|
|
}
|
|
|
|
// ---- 8.4 Issue manual trial grant at rank 1 ----
|
|
res1, err := entitlements.ConferGrantTx(ctx, tx, entitlements.ConferGrantInput{
|
|
ProductID: standardProd.ProductID,
|
|
OrgID: org.OrgID,
|
|
GrantedByPersonID: person.PersonID,
|
|
GrantReason: "evaluation",
|
|
Quantity: 1,
|
|
ValidUntil: sql.NullTime{Time: time.Now().Add(24 * time.Hour), Valid: true},
|
|
ActorType: "operator",
|
|
ActorID: uuid.NullUUID{UUID: uuid.MustParse(person.PersonID), Valid: true},
|
|
TransitionReason: "operator trial grant",
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("trial grant conferral: %v", err)
|
|
}
|
|
if res1.Outcome != "created" {
|
|
t.Fatalf("expected created, got %s", res1.Outcome)
|
|
}
|
|
grant := res1.Grant
|
|
|
|
// Verify supersession: pool is now at rank 1
|
|
attachments, _ := eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(attachments) != 1 || attachments[0].ProductID != standardProd.ProductID {
|
|
t.Fatalf("expected standard tier attachment after trial")
|
|
}
|
|
|
|
// ---- 8.5 Trial expires; conferral ends, default is reapplied at rank 0 ----
|
|
// Expiry is decree + position: mark the grant expired, end its conferral
|
|
// (nothing resurrects), then reapply the org-type default.
|
|
if _, err := eq.ExpireGrant(ctx, grant.GrantID); err != nil {
|
|
t.Fatalf("expire grant decree: %v", err)
|
|
}
|
|
ended, err := eq.EndConferral(ctx, entitlements.EndConferralParams{
|
|
GrantID: uuid.NullUUID{UUID: uuid.MustParse(grant.GrantID), Valid: true},
|
|
ActorType: "system",
|
|
Reason: sql.NullString{String: "grant-expiration:" + grant.GrantID, Valid: true},
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("end conferral: %v", err)
|
|
}
|
|
if len(ended) != 1 {
|
|
t.Fatalf("expected 1 ended provision, got %d", len(ended))
|
|
}
|
|
res2, err := entitlements.ReapplyDefaultsForPool(ctx, tx, pool.PoolID,
|
|
entitlements.Actor{ActorType: "system", Reason: "grant-expiration reapply"})
|
|
if err != nil {
|
|
t.Fatalf("reapply defaults after expiry: %v", err)
|
|
}
|
|
if res2.Outcome != "created" {
|
|
t.Fatalf("expected fresh default conferral after expiry, got %+v", res2)
|
|
}
|
|
|
|
// Verify default reactivated at rank 0
|
|
attachments, _ = eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(attachments) != 1 || attachments[0].ProductID != publicProd.ProductID {
|
|
t.Fatalf("expected entry tier attachment after expiry, got %+v", attachments)
|
|
}
|
|
|
|
// ---- 8.6 Verify all transitions visible in audit ----
|
|
// Trajectory: initiate (default) → end+upgrade (trial supersedes default)
|
|
// → end (trial expiry) → initiate (default reapplied). Rows written in the
|
|
// same statement share effective_at, so assert the multiset, not order.
|
|
transitions, _ := eq.ListTransitionsByPool(ctx, pool.PoolID)
|
|
if len(transitions) != 5 {
|
|
t.Fatalf("expected 5 transitions, got %d", len(transitions))
|
|
}
|
|
counts := map[string]int{}
|
|
for _, trn := range transitions {
|
|
counts[trn.TransitionType]++
|
|
}
|
|
if counts["initiate"] != 2 || counts["upgrade"] != 1 || counts["end"] != 2 {
|
|
t.Fatalf("unexpected transition mix: %v", counts)
|
|
}
|
|
}
|
|
|
|
// --- httptest UI harness (task 8.1) --------------------------------------
|
|
|
|
// httpHarness bundles the handler under test, the session manager, and the
|
|
// authenticated-operator context seeded for every request.
|
|
type httpHarness struct {
|
|
t *testing.T
|
|
handler *server.OperatorPartialsHandler
|
|
sm *scs.SessionManager
|
|
ctx context.Context
|
|
personID string
|
|
}
|
|
|
|
// newHTTPHarness constructs an OperatorPartialsHandler wired to the test
|
|
// database, plus a session manager seeded with an authenticated operator
|
|
// context. TemporalClient and StripeQ are nil — IssueGrant checks for nil
|
|
// before scheduling trial expiry, so trial registration is simply skipped.
|
|
func newHTTPHarness(t *testing.T, database *sql.DB, personID string) *httpHarness {
|
|
t.Helper()
|
|
|
|
sm := scs.New()
|
|
authCfg := &auth.Config{SessionManager: sm}
|
|
|
|
handler, err := server.NewOperatorPartialsHandler(server.OperatorPartialsConfig{
|
|
EntitlementsQ: entitlements.New(database),
|
|
BillingQ: billing.New(database),
|
|
StripeQ: nil,
|
|
Database: database,
|
|
IdentityQ: identity.New(database),
|
|
OrgQ: organization.New(database),
|
|
Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
|
AuthConfig: authCfg,
|
|
TemporalClient: nil,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("NewOperatorPartialsHandler: %v", err)
|
|
}
|
|
|
|
ctx, err := sm.Load(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("session Load: %v", err)
|
|
}
|
|
// These keys must stay in sync with internal/auth/auth.go sessionKey*
|
|
// string values. They are not exported constants but they are stable.
|
|
sm.Put(ctx, "authenticated", true)
|
|
sm.Put(ctx, "person_id", personID)
|
|
sm.Put(ctx, "roles", []string{server.OperatorRole})
|
|
|
|
return &httpHarness{t: t, handler: handler, sm: sm, ctx: ctx, personID: personID}
|
|
}
|
|
|
|
// doPost runs an in-process POST request against the given handler method.
|
|
// Form values are URL-encoded. Path parameters are applied via SetPathValue
|
|
// (mirrors what http.ServeMux would do for a registered route). The response
|
|
// body is returned as a string for assertion.
|
|
func (h *httpHarness) doPost(handler http.HandlerFunc, pathParams map[string]string, form url.Values) (int, string) {
|
|
h.t.Helper()
|
|
req := httptest.NewRequestWithContext(h.ctx, http.MethodPost, "/", strings.NewReader(form.Encode()))
|
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
for k, v := range pathParams {
|
|
req.SetPathValue(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
return rec.Code, rec.Body.String()
|
|
}
|
|
|
|
// doGet is the GET equivalent of doPost.
|
|
func (h *httpHarness) doGet(handler http.HandlerFunc, pathParams map[string]string) (int, string) {
|
|
h.t.Helper()
|
|
req := httptest.NewRequestWithContext(h.ctx, http.MethodGet, "/", nil)
|
|
for k, v := range pathParams {
|
|
req.SetPathValue(k, v)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
handler(rec, req)
|
|
return rec.Code, rec.Body.String()
|
|
}
|
|
|
|
// TestMVPPlanManagement_ViaHTTP honors task 8.1 — drives the operator UI
|
|
// handlers over httptest, covering: ladder CRUD, org-type backfill, trial
|
|
// grant issuance, and enrollment view. Complements TestMVPPlanManagement
|
|
// (primitive-level) by exercising form parsing, handler transactions, and
|
|
// view-model assembly.
|
|
//
|
|
// Rationale: existing operator unit tests (operator_enrollment_test.go et al.)
|
|
// test at the primitive level. This test is the first that drives a
|
|
// registered handler through its form-parsing and tx lifecycle.
|
|
func TestMVPPlanManagement_ViaHTTP(t *testing.T) {
|
|
database := testDB(t)
|
|
ctx := context.Background()
|
|
suffix := uuid.New().String()[:8]
|
|
|
|
// --- Seed baseline identity / catalog via primitives --------------------
|
|
// Handlers commit their own transactions, so we can't share a tx; we seed
|
|
// minimal data then drive the UI paths. Unique IDs prevent collision on
|
|
// reruns.
|
|
iq := identity.New(database)
|
|
oq := organization.New(database)
|
|
bq := billing.New(database)
|
|
eq := entitlements.New(database)
|
|
|
|
user, err := iq.CreateUser(ctx, "http-u-"+suffix)
|
|
if err != nil {
|
|
t.Fatalf("create user: %v", err)
|
|
}
|
|
operator, err := iq.CreatePerson(ctx, identity.CreatePersonParams{
|
|
UserID: user.UserID,
|
|
DisplayName: "HTTP Operator",
|
|
PrimaryEmail: "http-op-" + suffix + "@example.com",
|
|
PrimaryEmailVerified: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create person: %v", err)
|
|
}
|
|
|
|
// 3-tier products (use existing primitive paths; HTTP tests focus on ladder + enrollment).
|
|
setA, _ := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "http-set-A-" + suffix})
|
|
setB, _ := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "http-set-B-" + suffix})
|
|
setC, _ := eq.CreateEntitlementSet(ctx, entitlements.CreateEntitlementSetParams{Name: "http-set-C-" + suffix})
|
|
publicProd, _ := bq.CreateProduct(ctx, billing.CreateProductParams{Name: "HTTP Public", IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(setA.SetID), Valid: true}, LifecycleStatus: "published"})
|
|
standardProd, _ := bq.CreateProduct(ctx, billing.CreateProductParams{Name: "HTTP Standard", IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(setB.SetID), Valid: true}, LifecycleStatus: "published"})
|
|
proProd, _ := bq.CreateProduct(ctx, billing.CreateProductParams{Name: "HTTP Pro", IsActive: true, IsPublic: true, EntitlementSetID: uuid.NullUUID{UUID: uuid.MustParse(setC.SetID), Valid: true}, LifecycleStatus: "published"})
|
|
|
|
// --- Build the HTTP harness -------------------------------------------
|
|
h := newHTTPHarness(t, database, operator.PersonID)
|
|
|
|
// --- 1. POST /partials/operator/plan-ladders ---------------------------
|
|
ladderKey := "http-ladder-" + suffix
|
|
code, body := h.doPost(h.handler.CreatePlanLadder, nil, url.Values{
|
|
"ladder_key": {ladderKey},
|
|
"name": {"HTTP Ladder"},
|
|
"description": {"Driven by httptest"},
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("CreatePlanLadder status=%d body=%s", code, body)
|
|
}
|
|
ladder, err := bq.GetPlanLadderByKey(ctx, ladderKey)
|
|
if err != nil {
|
|
t.Fatalf("ladder not persisted: %v", err)
|
|
}
|
|
|
|
// --- 2. POST /partials/operator/plan-ladders/{id}/tiers (x3) -----------
|
|
for rank, prodID := range []string{publicProd.ProductID, standardProd.ProductID, proProd.ProductID} {
|
|
code, body := h.doPost(h.handler.CreatePlanLadderTier,
|
|
map[string]string{"ladderID": ladder.PlanLadderID},
|
|
url.Values{"product_id": {prodID}, "rank": {fmt.Sprintf("%d", rank)}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("CreatePlanLadderTier rank=%d status=%d body=%s", rank, code, body)
|
|
}
|
|
}
|
|
tiers, _ := bq.ListTiersByLadder(ctx, ladder.PlanLadderID)
|
|
if len(tiers) != 3 {
|
|
t.Fatalf("expected 3 tiers after HTTP creates, got %d", len(tiers))
|
|
}
|
|
|
|
// --- 3. Seed org-type + org + pool, then POST backfill ----------------
|
|
orgType := "http-t-" + suffix[:4]
|
|
if _, err := database.ExecContext(ctx,
|
|
`INSERT INTO core.org_types (org_type, display_name, is_active, default_plan_ladder_id) VALUES ($1, $2, true, $3)`,
|
|
orgType, "HTTP Type "+suffix, ladder.PlanLadderID,
|
|
); err != nil {
|
|
t.Fatalf("seed org type: %v", err)
|
|
}
|
|
_ = publicProd // resolved at use-time via the ladder's rank-0 tier
|
|
org, err := oq.CreateOrganization(ctx, organization.CreateOrganizationParams{
|
|
Name: "HTTP Org " + suffix, Slug: "http-org-" + suffix, OrgType: orgType, OwnerPersonID: operator.PersonID,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create org: %v", err)
|
|
}
|
|
pool, err := eq.CreateResourcePool(ctx, entitlements.CreateResourcePoolParams{
|
|
OrgID: org.OrgID, Name: "default", Slug: "default-" + suffix, PoolType: "default", IsAutoManaged: true,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("create pool: %v", err)
|
|
}
|
|
|
|
// The commit endpoint replaces the retired standalone backfill: the
|
|
// candidate is the already-saved ladder, the pool is plan-less (bucket 1),
|
|
// and the default disposition initiates it onto rank 0.
|
|
code, body = h.doPost(h.handler.CommitOrgTypeDefaultChange, map[string]string{"orgType": orgType},
|
|
url.Values{"default_plan_ladder_id": {ladder.PlanLadderID}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("CommitOrgTypeDefaultChange status=%d body=%s", code, body)
|
|
}
|
|
attachments, _ := eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(attachments) != 1 || attachments[0].ProductID != publicProd.ProductID {
|
|
t.Fatalf("backfill did not attach public tier: %+v", attachments)
|
|
}
|
|
|
|
// --- 4. POST .../grant/create (time-boxed upgrade) ---------------------
|
|
// The Doc 41 single issuance form takes a reason from the closed
|
|
// grant_reason domain (time-boxed grants use 'evaluation', never 'trial')
|
|
// plus a free-text description; free text can no longer reach the reason.
|
|
validUntil := time.Now().Add(24 * time.Hour).Format("2006-01-02T15:04")
|
|
code, body = h.doPost(h.handler.IssueGrant,
|
|
map[string]string{"orgID": org.OrgID},
|
|
url.Values{
|
|
"product_id": {standardProd.ProductID},
|
|
"valid_until": {validUntil},
|
|
"reason": {"evaluation"},
|
|
"description": {"httptest trial"},
|
|
})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("IssueGrant status=%d body=%s", code, body)
|
|
}
|
|
attachments, _ = eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(attachments) != 1 || attachments[0].ProductID != standardProd.ProductID {
|
|
t.Fatalf("trial did not supersede to standard: %+v", attachments)
|
|
}
|
|
|
|
// --- 5. GET .../enrollment renders transition history -----------------
|
|
code, body = h.doGet(h.handler.GetOrgEnrollment, map[string]string{"orgID": org.OrgID})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("GetOrgEnrollment status=%d body=%s", code, body)
|
|
}
|
|
// Quick structural check: the enrollment partial should contain the
|
|
// standard-tier product name in the active-tier cell and the
|
|
// trial-reason text somewhere in the transition history.
|
|
if !strings.Contains(body, "HTTP Standard") {
|
|
t.Errorf("enrollment view missing current tier; body:\n%s", body)
|
|
}
|
|
if !strings.Contains(body, "httptest trial") {
|
|
t.Errorf("enrollment view missing trial reason; body:\n%s", body)
|
|
}
|
|
|
|
// --- 6. POST .../pools/{poolID}/grant/extend (extend current tier) ----
|
|
code, body = h.doPost(h.handler.ExtendGrant,
|
|
map[string]string{"orgID": org.OrgID, "poolID": pool.PoolID},
|
|
url.Values{"reason": {"httptest extend"}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("ExtendGrant status=%d body=%s", code, body)
|
|
}
|
|
attachments, _ = eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(attachments) != 1 || attachments[0].ProductID != standardProd.ProductID {
|
|
t.Fatalf("extend did not keep standard tier: %+v", attachments)
|
|
}
|
|
transitions, _ := eq.ListTransitionsByPool(ctx, pool.PoolID)
|
|
// Extend is extend-as-replace: the extension grant's provision supersedes
|
|
// the incumbent at equal rank, recorded as a 'transfer' transition.
|
|
var hasTransfer bool
|
|
for _, trn := range transitions {
|
|
if trn.TransitionType == "transfer" {
|
|
hasTransfer = true
|
|
break
|
|
}
|
|
}
|
|
if !hasTransfer {
|
|
t.Fatalf("expected a transfer transition row after extend, got types: %v", transitions)
|
|
}
|
|
|
|
// --- 7. Verify full transition audit via primitive ---------------------
|
|
if len(transitions) < 3 {
|
|
t.Fatalf("expected ≥3 transitions (initiate+upgrade+transfer) after HTTP pass, got %d", len(transitions))
|
|
}
|
|
|
|
// --- 8. RevokeGrantAndTransition on the active plan-tier grant -----
|
|
// The composite-side revoke path uses RevokeGrantAndTransition (the
|
|
// simple RevokeGrant SHALL NOT be a UI affordance per the spec). The
|
|
// behavior contract (Doc 41): revoke decree + end_conferral by source,
|
|
// then the vacancy-guarded restoration — revoking the grant that holds
|
|
// the pool's only position on the org-type default ladder returns the
|
|
// pool to the rank-0 baseline via a system-authored default decree.
|
|
// The "active" grant for revocation is the one currently backing the
|
|
// live ladder attachment (extension grant, post step 6).
|
|
preRevokeAttachments, _ := eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(preRevokeAttachments) != 1 {
|
|
t.Fatalf("expected 1 active attachment before revoke, got %d", len(preRevokeAttachments))
|
|
}
|
|
liveProvision, err := eq.GetPoolProvisionByProvisionID(ctx, preRevokeAttachments[0].ProvisionID)
|
|
if err != nil {
|
|
t.Fatalf("get provision: %v", err)
|
|
}
|
|
activeGrantID := liveProvision.GrantID.UUID.String()
|
|
|
|
code, body = h.doPost(h.handler.RevokeGrantAndTransition,
|
|
map[string]string{"grantID": activeGrantID},
|
|
url.Values{"org_id": {org.OrgID}})
|
|
if code != http.StatusOK {
|
|
t.Fatalf("RevokeGrantAndTransition status=%d body=%s", code, body)
|
|
}
|
|
|
|
// After revoke: the extension grant's conferral is ended and the guarded
|
|
// restoration re-seats the pool on the rank-0 default (Public), backed by
|
|
// a fresh system-authored default grant.
|
|
postRevokeAttachments, _ := eq.GetActiveAttachmentsByPool(ctx, pool.PoolID)
|
|
if len(postRevokeAttachments) != 1 || postRevokeAttachments[0].ProductID != publicProd.ProductID {
|
|
t.Fatalf("expected the rank-0 default (Public) attachment after revoke, got %+v", postRevokeAttachments)
|
|
}
|
|
restored, err := eq.GetPoolProvisionByProvisionID(ctx, postRevokeAttachments[0].ProvisionID)
|
|
if err != nil {
|
|
t.Fatalf("get restored provision: %v", err)
|
|
}
|
|
var restoredReason string
|
|
if err := database.QueryRowContext(ctx,
|
|
`SELECT grant_reason FROM core.grants WHERE grant_id = $1`,
|
|
restored.GrantID.UUID.String()).Scan(&restoredReason); err != nil {
|
|
t.Fatalf("read restored grant reason: %v", err)
|
|
}
|
|
if restoredReason != "default" {
|
|
t.Fatalf("expected restoration backed by a 'default' decree, got %q", restoredReason)
|
|
}
|
|
|
|
// The trace records the revoke as 'end' (from_rank=1, to_rank NULL,
|
|
// operator actor) and the restoration as a separate fresh 'initiate'
|
|
// (to_rank=0, system actor) — two calls, never a single downgrade.
|
|
postRevokeTransitions, _ := eq.ListTransitionsByPool(ctx, pool.PoolID)
|
|
var sawEnd, sawRestore bool
|
|
for _, trn := range postRevokeTransitions {
|
|
if trn.TransitionType == "end" && trn.ActorType == "operator" &&
|
|
trn.FromRank.Valid && trn.FromRank.Int32 == 1 && !trn.ToRank.Valid {
|
|
sawEnd = true
|
|
}
|
|
// Pinned to the restored provision so the step-3 backfill's own
|
|
// initiate row can never satisfy this.
|
|
if trn.ProvisionID.Valid && trn.ProvisionID.UUID.String() == restored.ProvisionID &&
|
|
trn.TransitionType == "initiate" &&
|
|
!trn.FromRank.Valid && trn.ToRank.Valid && trn.ToRank.Int32 == 0 {
|
|
sawRestore = true
|
|
}
|
|
}
|
|
if !sawEnd {
|
|
t.Fatalf("expected an 'end' transition (operator, from_rank=1, to_rank NULL) after revoke; got %+v", postRevokeTransitions)
|
|
}
|
|
if !sawRestore {
|
|
t.Fatalf("expected a restoration 'initiate' transition (to_rank=0) on the restored provision after revoke; got %+v", postRevokeTransitions)
|
|
}
|
|
}
|