Files
member-console/test/e2e/plan-management/e2e_test.go
T
cgalo5758 b7447bea28 Isolate tests in per-purpose databases
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.
2026-08-01 04:13:57 -05:00

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