Files
pulse/internal/cloudcp/registry/registry_test.go
T
2026-04-28 17:02:04 +01:00

1498 lines
41 KiB
Go

package registry
import (
"database/sql"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"time"
_ "modernc.org/sqlite"
)
func newTestRegistry(t *testing.T) *TenantRegistry {
t.Helper()
dir := t.TempDir()
reg, err := NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
return reg
}
func TestNewTenantRegistryDoesNotCreateLegacyEntitlementColumn(t *testing.T) {
reg := newTestRegistry(t)
hasLegacyColumn, err := reg.tenantsHasColumn("entitlement_refresh_token")
if err != nil {
t.Fatalf("tenantsHasColumn(entitlement_refresh_token): %v", err)
}
if hasLegacyColumn {
t.Fatal("new registry should not create tenants.entitlement_refresh_token")
}
}
func TestNewTenantRegistryMigratesLegacyTenantEntitlementRefreshTokens(t *testing.T) {
dir := t.TempDir()
dbPath := filepath.Join(dir, "tenants.db")
db, err := sql.Open("sqlite", dbPath)
if err != nil {
t.Fatalf("sql.Open: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(`
CREATE TABLE tenants (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL DEFAULT '',
email TEXT NOT NULL DEFAULT '',
display_name TEXT NOT NULL DEFAULT '',
state TEXT NOT NULL DEFAULT 'provisioning',
entitlement_refresh_token TEXT NOT NULL DEFAULT '',
stripe_customer_id TEXT NOT NULL DEFAULT '',
stripe_subscription_id TEXT NOT NULL DEFAULT '',
stripe_price_id TEXT NOT NULL DEFAULT '',
plan_version TEXT NOT NULL DEFAULT '',
container_id TEXT NOT NULL DEFAULT '',
current_image_digest TEXT NOT NULL DEFAULT '',
desired_image_digest TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL,
last_health_check INTEGER,
health_check_ok INTEGER NOT NULL DEFAULT 0
);
INSERT INTO tenants (
id, account_id, email, display_name, state, entitlement_refresh_token,
stripe_customer_id, stripe_subscription_id, stripe_price_id,
plan_version, container_id, current_image_digest, desired_image_digest,
created_at, updated_at, last_health_check, health_check_ok
) VALUES (
't-LEGACY001', 'a_legacy_1', 'legacy@example.com', 'Legacy Tenant', 'active', 'etr_legacy_one',
'cus_legacy_1', 'sub_legacy_1', 'price_legacy_1',
'cloud_v1', '', '', '',
1710000000, 1710000000, NULL, 1
);
`); err != nil {
t.Fatalf("seed legacy registry: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close seeded legacy registry: %v", err)
}
reg, err := NewTenantRegistry(dir)
if err != nil {
t.Fatalf("NewTenantRegistry: %v", err)
}
t.Cleanup(func() { _ = reg.Close() })
hasLegacyColumn, err := reg.tenantsHasColumn("entitlement_refresh_token")
if err != nil {
t.Fatalf("tenantsHasColumn(entitlement_refresh_token): %v", err)
}
if hasLegacyColumn {
t.Fatal("legacy entitlement_refresh_token column should be removed during migration")
}
entitlement, err := reg.GetHostedEntitlementByRefreshToken("etr_legacy_one")
if err != nil {
t.Fatalf("GetHostedEntitlementByRefreshToken: %v", err)
}
if entitlement == nil {
t.Fatal("expected hosted entitlement backfilled from legacy tenant column")
}
if entitlement.TenantID != "t-LEGACY001" {
t.Fatalf("TenantID=%q, want %q", entitlement.TenantID, "t-LEGACY001")
}
tenant, err := reg.Get("t-LEGACY001")
if err != nil {
t.Fatalf("Get legacy tenant: %v", err)
}
if tenant == nil {
t.Fatal("expected legacy tenant to exist")
}
if tenant.PlanVersion != "cloud_starter" {
t.Fatalf("legacy tenant PlanVersion=%q, want %q", tenant.PlanVersion, "cloud_starter")
}
}
func TestGenerateTenantID(t *testing.T) {
id, err := GenerateTenantID()
if err != nil {
t.Fatalf("GenerateTenantID: %v", err)
}
if !strings.HasPrefix(id, "t-") {
t.Errorf("expected prefix t-, got %q", id)
}
if len(id) != 12 { // "t-" + 10 chars
t.Errorf("expected length 12, got %d (%q)", len(id), id)
}
// Uniqueness
seen := make(map[string]bool)
for i := 0; i < 100; i++ {
id, err := GenerateTenantID()
if err != nil {
t.Fatal(err)
}
if seen[id] {
t.Fatalf("duplicate tenant ID: %s", id)
}
seen[id] = true
}
}
func TestGenerateTenantID_CrockfordCharset(t *testing.T) {
for i := 0; i < 50; i++ {
id, err := GenerateTenantID()
if err != nil {
t.Fatal(err)
}
suffix := id[2:] // strip "t-"
for _, c := range suffix {
if !strings.ContainsRune(crockfordBase32, c) {
t.Errorf("character %q not in Crockford base32 alphabet (id=%s)", c, id)
}
}
}
}
func TestGenerateAccountID(t *testing.T) {
id, err := GenerateAccountID()
if err != nil {
t.Fatalf("GenerateAccountID: %v", err)
}
if !strings.HasPrefix(id, "a_") {
t.Errorf("expected prefix a_, got %q", id)
}
if len(id) != 12 { // "a_" + 10 chars
t.Errorf("expected length 12, got %d (%q)", len(id), id)
}
}
func TestGenerateUserID(t *testing.T) {
id, err := GenerateUserID()
if err != nil {
t.Fatalf("GenerateUserID: %v", err)
}
if !strings.HasPrefix(id, "u_") {
t.Errorf("expected prefix u_, got %q", id)
}
if len(id) != 12 { // "u_" + 10 chars
t.Errorf("expected length 12, got %d (%q)", len(id), id)
}
}
func TestCRUD(t *testing.T) {
reg := newTestRegistry(t)
tenant := &Tenant{
ID: "t-TEST00001",
Email: "test@example.com",
DisplayName: "Test Tenant",
State: TenantStateProvisioning,
StripeCustomerID: "cus_test123",
PlanVersion: "stripe",
}
// Create
if err := reg.Create(tenant); err != nil {
t.Fatalf("Create: %v", err)
}
if tenant.CreatedAt.IsZero() {
t.Error("CreatedAt should be set")
}
// Get
got, err := reg.Get("t-TEST00001")
if err != nil {
t.Fatalf("Get: %v", err)
}
if got == nil {
t.Fatal("Get returned nil")
}
if got.Email != "test@example.com" {
t.Errorf("Email = %q, want %q", got.Email, "test@example.com")
}
if got.State != TenantStateProvisioning {
t.Errorf("State = %q, want %q", got.State, TenantStateProvisioning)
}
// Get not found
notFound, err := reg.Get("t-NONEXIST1")
if err != nil {
t.Fatalf("Get not found: %v", err)
}
if notFound != nil {
t.Error("expected nil for non-existent tenant")
}
// GetByStripeCustomerID
got2, err := reg.GetByStripeCustomerID("cus_test123")
if err != nil {
t.Fatalf("GetByStripeCustomerID: %v", err)
}
if got2 == nil || got2.ID != "t-TEST00001" {
t.Error("GetByStripeCustomerID should find the tenant")
}
// Update
got.State = TenantStateActive
got.ContainerID = "abc123"
if err := reg.Update(got); err != nil {
t.Fatalf("Update: %v", err)
}
got3, err := reg.Get("t-TEST00001")
if err != nil {
t.Fatalf("Get after update: %v", err)
}
if got3.State != TenantStateActive {
t.Errorf("State after update = %q, want %q", got3.State, TenantStateActive)
}
if got3.ContainerID != "abc123" {
t.Errorf("ContainerID = %q, want %q", got3.ContainerID, "abc123")
}
// Update not found
phantom := &Tenant{ID: "t-NONEXIST1"}
if err := reg.Update(phantom); err == nil {
t.Error("Update non-existent tenant should error")
}
// Delete
if err := reg.Delete("t-TEST00001"); err != nil {
t.Fatalf("Delete: %v", err)
}
deleted, err := reg.Get("t-TEST00001")
if err != nil {
t.Fatalf("Get after delete: %v", err)
}
if deleted != nil {
t.Fatalf("expected nil tenant after delete, got %+v", deleted)
}
// Delete not found
if err := reg.Delete("t-NONEXIST1"); err == nil {
t.Fatal("Delete non-existent tenant should error")
}
}
func TestAccountCRUD(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
a := &Account{
ID: accountID,
Kind: AccountKindMSP,
DisplayName: "Test MSP",
}
// Create
if err := reg.CreateAccount(a); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if a.CreatedAt.IsZero() {
t.Error("CreatedAt should be set")
}
if a.UpdatedAt.IsZero() {
t.Error("UpdatedAt should be set")
}
// Get
got, err := reg.GetAccount(accountID)
if err != nil {
t.Fatalf("GetAccount: %v", err)
}
if got == nil {
t.Fatal("GetAccount returned nil")
}
if got.Kind != AccountKindMSP {
t.Errorf("Kind = %q, want %q", got.Kind, AccountKindMSP)
}
if got.DisplayName != "Test MSP" {
t.Errorf("DisplayName = %q, want %q", got.DisplayName, "Test MSP")
}
// Update
got.DisplayName = "Renamed MSP"
if err := reg.UpdateAccount(got); err != nil {
t.Fatalf("UpdateAccount: %v", err)
}
got2, err := reg.GetAccount(accountID)
if err != nil {
t.Fatalf("GetAccount after update: %v", err)
}
if got2.DisplayName != "Renamed MSP" {
t.Errorf("DisplayName after update = %q, want %q", got2.DisplayName, "Renamed MSP")
}
// List
accounts, err := reg.ListAccounts()
if err != nil {
t.Fatalf("ListAccounts: %v", err)
}
if len(accounts) != 1 {
t.Fatalf("expected 1 account, got %d", len(accounts))
}
if accounts[0].ID != accountID {
t.Errorf("accounts[0].ID = %q, want %q", accounts[0].ID, accountID)
}
}
func TestDeleteAccountRemovesAccountOwnedMetadata(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
account := &Account{
ID: accountID,
Kind: AccountKindMSP,
DisplayName: "Disposable Proof Account",
}
if err := reg.CreateAccount(account); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateUser(&User{ID: userID, Email: "proof@example.com"}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if err := reg.CreateMembership(&AccountMembership{AccountID: accountID, UserID: userID, Role: MemberRoleOwner}); err != nil {
t.Fatalf("CreateMembership: %v", err)
}
invitationID, err := GenerateAccountInvitationID()
if err != nil {
t.Fatal(err)
}
if err := reg.UpsertInvitation(&AccountInvitation{ID: invitationID, AccountID: accountID, Email: "invitee@example.com", Role: MemberRoleTech}); err != nil {
t.Fatalf("UpsertInvitation: %v", err)
}
if err := reg.CreateStripeAccount(&StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_proof_delete",
PlanVersion: "cloud",
SubscriptionState: "active",
UpdatedAt: time.Now().UTC().Unix(),
}); err != nil {
t.Fatalf("CreateStripeAccount: %v", err)
}
if err := reg.DeleteAccount(accountID); err != nil {
t.Fatalf("DeleteAccount: %v", err)
}
got, err := reg.GetAccount(accountID)
if err != nil {
t.Fatalf("GetAccount after delete: %v", err)
}
if got != nil {
t.Fatalf("expected account to be deleted, got %+v", got)
}
membership, err := reg.GetMembership(accountID, userID)
if err != nil {
t.Fatalf("GetMembership after account delete: %v", err)
}
if membership != nil {
t.Fatalf("expected account membership to be deleted, got %+v", membership)
}
invitations, err := reg.ListInvitationsByAccount(accountID)
if err != nil {
t.Fatalf("ListInvitationsByAccount after account delete: %v", err)
}
if len(invitations) != 0 {
t.Fatalf("expected account invitations to be deleted, got %d", len(invitations))
}
stripeAccount, err := reg.GetStripeAccount(accountID)
if err != nil {
t.Fatalf("GetStripeAccount after account delete: %v", err)
}
if stripeAccount != nil {
t.Fatalf("expected stripe account to be deleted, got %+v", stripeAccount)
}
user, err := reg.GetUser(userID)
if err != nil {
t.Fatalf("GetUser after account delete: %v", err)
}
if user == nil {
t.Fatal("DeleteAccount should not remove the user identity")
}
}
func TestDeleteAccountRefusesAccountWithTenants(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account With Workspace"}); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
if err := reg.Create(&Tenant{ID: "t-ACCOUNT001", AccountID: accountID, Email: "owner@example.com", DisplayName: "Workspace"}); err != nil {
t.Fatalf("Create tenant: %v", err)
}
if err := reg.DeleteAccount(accountID); err == nil {
t.Fatal("DeleteAccount should refuse accounts that still own tenants")
}
got, err := reg.GetAccount(accountID)
if err != nil {
t.Fatalf("GetAccount after refused delete: %v", err)
}
if got == nil {
t.Fatal("account should remain after refused delete")
}
}
func TestUserCRUD(t *testing.T) {
reg := newTestRegistry(t)
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
u := &User{
ID: userID,
Email: "user@example.com",
}
// Create
if err := reg.CreateUser(u); err != nil {
t.Fatalf("CreateUser: %v", err)
}
if u.CreatedAt.IsZero() {
t.Error("CreatedAt should be set")
}
// Get by ID
got, err := reg.GetUser(userID)
if err != nil {
t.Fatalf("GetUser: %v", err)
}
if got == nil {
t.Fatal("GetUser returned nil")
}
if got.Email != "user@example.com" {
t.Errorf("Email = %q, want %q", got.Email, "user@example.com")
}
if got.LastLoginAt != nil {
t.Errorf("LastLoginAt = %v, want nil", got.LastLoginAt)
}
if got.SessionVersion != 1 {
t.Errorf("SessionVersion = %d, want 1", got.SessionVersion)
}
// Get by email
got2, err := reg.GetUserByEmail("user@example.com")
if err != nil {
t.Fatalf("GetUserByEmail: %v", err)
}
if got2 == nil || got2.ID != userID {
t.Fatalf("GetUserByEmail returned %+v, want id=%q", got2, userID)
}
// Update last login
before := time.Now().UTC()
if err := reg.UpdateUserLastLogin(userID); err != nil {
t.Fatalf("UpdateUserLastLogin: %v", err)
}
after := time.Now().UTC()
got3, err := reg.GetUser(userID)
if err != nil {
t.Fatalf("GetUser after last login update: %v", err)
}
if got3.LastLoginAt == nil {
t.Fatal("LastLoginAt should be set")
}
ll := *got3.LastLoginAt
if ll.Before(before.Add(-2*time.Second)) || ll.After(after.Add(2*time.Second)) {
t.Fatalf("LastLoginAt=%s out of expected range [%s, %s]", ll, before, after)
}
}
func TestUserSessionVersionRevocation(t *testing.T) {
reg := newTestRegistry(t)
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateUser(&User{
ID: userID,
Email: "revoke@example.com",
}); err != nil {
t.Fatalf("CreateUser: %v", err)
}
v1, err := reg.GetUserSessionVersion(userID)
if err != nil {
t.Fatalf("GetUserSessionVersion: %v", err)
}
if v1 != 1 {
t.Fatalf("initial session version = %d, want 1", v1)
}
v2, err := reg.RevokeUserSessions(userID)
if err != nil {
t.Fatalf("RevokeUserSessions: %v", err)
}
if v2 != 2 {
t.Fatalf("revoked session version = %d, want 2", v2)
}
v3, err := reg.GetUserSessionVersion(userID)
if err != nil {
t.Fatalf("GetUserSessionVersion after revoke: %v", err)
}
if v3 != 2 {
t.Fatalf("session version after revoke = %d, want 2", v3)
}
}
func TestMembershipCRUD(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateUser(&User{ID: userID, Email: "member@example.com"}); err != nil {
t.Fatal(err)
}
m := &AccountMembership{
AccountID: accountID,
UserID: userID,
Role: MemberRoleOwner,
}
// Create
if err := reg.CreateMembership(m); err != nil {
t.Fatalf("CreateMembership: %v", err)
}
if m.CreatedAt.IsZero() {
t.Error("CreatedAt should be set")
}
// Get
got, err := reg.GetMembership(accountID, userID)
if err != nil {
t.Fatalf("GetMembership: %v", err)
}
if got == nil {
t.Fatal("GetMembership returned nil")
}
if got.Role != MemberRoleOwner {
t.Errorf("Role = %q, want %q", got.Role, MemberRoleOwner)
}
// List by account
members, err := reg.ListMembersByAccount(accountID)
if err != nil {
t.Fatalf("ListMembersByAccount: %v", err)
}
if len(members) != 1 {
t.Fatalf("expected 1 member, got %d", len(members))
}
if members[0].UserID != userID {
t.Errorf("members[0].UserID = %q, want %q", members[0].UserID, userID)
}
// List accounts by user
accounts, err := reg.ListAccountsByUser(userID)
if err != nil {
t.Fatalf("ListAccountsByUser: %v", err)
}
if len(accounts) != 1 || accounts[0] != accountID {
t.Fatalf("accounts=%v, want [%q]", accounts, accountID)
}
// Update role
if err := reg.UpdateMembershipRole(accountID, userID, MemberRoleAdmin); err != nil {
t.Fatalf("UpdateMembershipRole: %v", err)
}
got2, err := reg.GetMembership(accountID, userID)
if err != nil {
t.Fatalf("GetMembership after role update: %v", err)
}
if got2.Role != MemberRoleAdmin {
t.Errorf("Role after update = %q, want %q", got2.Role, MemberRoleAdmin)
}
// Delete
if err := reg.DeleteMembership(accountID, userID); err != nil {
t.Fatalf("DeleteMembership: %v", err)
}
got3, err := reg.GetMembership(accountID, userID)
if err != nil {
t.Fatalf("GetMembership after delete: %v", err)
}
if got3 != nil {
t.Fatalf("expected nil membership after delete, got %+v", got3)
}
}
func TestCreateMembership_RequiresExistingAccountAndUser(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
err = reg.CreateMembership(&AccountMembership{
AccountID: accountID,
UserID: userID,
Role: MemberRoleOwner,
})
if err == nil {
t.Fatal("expected CreateMembership to fail when account/user do not exist")
}
}
func TestInvitationCRUDAndAccessSubjects(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateUser(&User{ID: userID, Email: "owner@example.com"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateMembership(&AccountMembership{
AccountID: accountID,
UserID: userID,
Role: MemberRoleOwner,
}); err != nil {
t.Fatal(err)
}
invitation := &AccountInvitation{
AccountID: accountID,
Email: "invitee@example.com",
Role: MemberRoleTech,
InvitedBy: "owner@example.com",
}
if err := reg.UpsertInvitation(invitation); err != nil {
t.Fatalf("UpsertInvitation: %v", err)
}
if invitation.ID == "" {
t.Fatal("expected invitation ID to be generated")
}
gotInvitation, err := reg.GetInvitationByAccountAndEmail(accountID, "invitee@example.com")
if err != nil {
t.Fatalf("GetInvitationByAccountAndEmail: %v", err)
}
if gotInvitation == nil {
t.Fatal("expected invitation to be stored")
}
if gotInvitation.Role != MemberRoleTech {
t.Fatalf("invitation role = %q, want %q", gotInvitation.Role, MemberRoleTech)
}
if err := reg.UpdateInvitationRole(gotInvitation.ID, MemberRoleAdmin, "owner@example.com"); err != nil {
t.Fatalf("UpdateInvitationRole: %v", err)
}
gotInvitation, err = reg.GetInvitation(gotInvitation.ID)
if err != nil {
t.Fatalf("GetInvitation: %v", err)
}
if gotInvitation == nil || gotInvitation.Role != MemberRoleAdmin {
t.Fatalf("updated invitation = %+v, want role=%q", gotInvitation, MemberRoleAdmin)
}
subjects, err := reg.ListAccessSubjectsByAccount(accountID)
if err != nil {
t.Fatalf("ListAccessSubjectsByAccount: %v", err)
}
if len(subjects) != 2 {
t.Fatalf("expected 2 access subjects, got %d", len(subjects))
}
var sawActive bool
var sawPending bool
for _, subject := range subjects {
if subject == nil {
continue
}
switch subject.State {
case AccountAccessStateActive:
sawActive = true
if subject.UserID != userID {
t.Fatalf("active subject user_id = %q, want %q", subject.UserID, userID)
}
case AccountAccessStatePending:
sawPending = true
if subject.SubjectID == "" {
t.Fatal("pending invitation missing subject ID")
}
if subject.Email != "invitee@example.com" {
t.Fatalf("pending email = %q, want %q", subject.Email, "invitee@example.com")
}
}
}
if !sawActive || !sawPending {
t.Fatalf("expected both active and pending access subjects, got %+v", subjects)
}
}
func TestAcceptInvitationsForUser(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
userID, err := GenerateUserID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateUser(&User{ID: userID, Email: "invitee@example.com"}); err != nil {
t.Fatal(err)
}
if err := reg.UpsertInvitation(&AccountInvitation{
AccountID: accountID,
Email: "invitee@example.com",
Role: MemberRoleAdmin,
}); err != nil {
t.Fatalf("UpsertInvitation: %v", err)
}
if err := reg.AcceptInvitationsForUser("invitee@example.com", userID); err != nil {
t.Fatalf("AcceptInvitationsForUser: %v", err)
}
membership, err := reg.GetMembership(accountID, userID)
if err != nil {
t.Fatalf("GetMembership: %v", err)
}
if membership == nil {
t.Fatal("expected invitation to become membership")
}
if membership.Role != MemberRoleAdmin {
t.Fatalf("membership.Role = %q, want %q", membership.Role, MemberRoleAdmin)
}
invitation, err := reg.GetInvitationByAccountAndEmail(accountID, "invitee@example.com")
if err != nil {
t.Fatalf("GetInvitationByAccountAndEmail: %v", err)
}
if invitation != nil {
t.Fatalf("expected invitation to be deleted after acceptance, got %+v", invitation)
}
}
func TestList(t *testing.T) {
reg := newTestRegistry(t)
// Empty list
tenants, err := reg.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(tenants) != 0 {
t.Errorf("expected 0 tenants, got %d", len(tenants))
}
// Add two tenants
for _, id := range []string{"t-LIST00001", "t-LIST00002"} {
if err := reg.Create(&Tenant{
ID: id,
Email: id + "@example.com",
State: TenantStateActive,
}); err != nil {
t.Fatal(err)
}
}
tenants, err = reg.List()
if err != nil {
t.Fatalf("List: %v", err)
}
if len(tenants) != 2 {
t.Errorf("expected 2 tenants, got %d", len(tenants))
}
}
func TestListByState(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.Create(&Tenant{ID: "t-STATE0001", State: TenantStateActive}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-STATE0002", State: TenantStateSuspended}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-STATE0003", State: TenantStateActive}); err != nil {
t.Fatal(err)
}
active, err := reg.ListByState(TenantStateActive)
if err != nil {
t.Fatal(err)
}
if len(active) != 2 {
t.Errorf("expected 2 active, got %d", len(active))
}
suspended, err := reg.ListByState(TenantStateSuspended)
if err != nil {
t.Fatal(err)
}
if len(suspended) != 1 {
t.Errorf("expected 1 suspended, got %d", len(suspended))
}
}
func TestListByAccountID(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account"}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-ACCNT0001", AccountID: accountID, State: TenantStateActive}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-ACCNT0002", AccountID: accountID, State: TenantStateActive}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-ACCNT0003", State: TenantStateActive}); err != nil {
t.Fatal(err)
}
tenants, err := reg.ListByAccountID(accountID)
if err != nil {
t.Fatalf("ListByAccountID: %v", err)
}
if len(tenants) != 2 {
t.Fatalf("expected 2 tenants, got %d", len(tenants))
}
seen := make(map[string]bool)
for _, tnt := range tenants {
seen[tnt.ID] = true
if tnt.AccountID != accountID {
t.Errorf("tenant %s AccountID=%q, want %q", tnt.ID, tnt.AccountID, accountID)
}
}
if !seen["t-ACCNT0001"] || !seen["t-ACCNT0002"] {
t.Fatalf("expected tenants t-ACCNT0001 and t-ACCNT0002, got %+v", tenants)
}
}
func TestCreateWithAccountWorkspaceLimitEnforcesActiveCount(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{ID: accountID, Kind: AccountKindMSP, DisplayName: "Account"}); err != nil {
t.Fatal(err)
}
if err := reg.CreateWithAccountWorkspaceLimit(&Tenant{ID: "t-LIMIT001", AccountID: accountID, State: TenantStateActive}, 1); err != nil {
t.Fatalf("first create: %v", err)
}
err = reg.CreateWithAccountWorkspaceLimit(&Tenant{ID: "t-LIMIT002", AccountID: accountID, State: TenantStateProvisioning}, 1)
var limitErr *WorkspaceLimitExceededError
if !errors.As(err, &limitErr) {
t.Fatalf("second create error = %v, want WorkspaceLimitExceededError", err)
}
if limitErr.Current != 1 || limitErr.Limit != 1 {
t.Fatalf("limit error = %+v, want current=1 limit=1", limitErr)
}
first, err := reg.Get("t-LIMIT001")
if err != nil {
t.Fatalf("load first tenant: %v", err)
}
first.State = TenantStateDeleted
if err := reg.Update(first); err != nil {
t.Fatalf("mark first tenant deleted: %v", err)
}
if err := reg.CreateWithAccountWorkspaceLimit(&Tenant{ID: "t-LIMIT003", AccountID: accountID, State: TenantStateProvisioning}, 1); err != nil {
t.Fatalf("deleted tenant should free an active workspace slot: %v", err)
}
}
func TestCountByState(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.Create(&Tenant{ID: "t-CNT000001", State: TenantStateActive}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-CNT000002", State: TenantStateActive}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{ID: "t-CNT000003", State: TenantStateCanceled}); err != nil {
t.Fatal(err)
}
counts, err := reg.CountByState()
if err != nil {
t.Fatal(err)
}
if counts[TenantStateActive] != 2 {
t.Errorf("active count = %d, want 2", counts[TenantStateActive])
}
if counts[TenantStateCanceled] != 1 {
t.Errorf("canceled count = %d, want 1", counts[TenantStateCanceled])
}
}
func TestHealthSummary(t *testing.T) {
reg := newTestRegistry(t)
now := time.Now().UTC()
if err := reg.Create(&Tenant{
ID: "t-HLTH00001", State: TenantStateActive,
HealthCheckOK: true, LastHealthCheck: &now,
}); err != nil {
t.Fatal(err)
}
if err := reg.Create(&Tenant{
ID: "t-HLTH00002", State: TenantStateActive,
HealthCheckOK: false, LastHealthCheck: &now,
}); err != nil {
t.Fatal(err)
}
// Suspended tenant should not count
if err := reg.Create(&Tenant{
ID: "t-HLTH00003", State: TenantStateSuspended,
HealthCheckOK: false,
}); err != nil {
t.Fatal(err)
}
healthy, unhealthy, err := reg.HealthSummary()
if err != nil {
t.Fatal(err)
}
if healthy != 1 {
t.Errorf("healthy = %d, want 1", healthy)
}
if unhealthy != 1 {
t.Errorf("unhealthy = %d, want 1", unhealthy)
}
}
func TestPing(t *testing.T) {
reg := newTestRegistry(t)
if err := reg.Ping(); err != nil {
t.Errorf("Ping: %v", err)
}
}
func TestNewTenantRegistry_InvalidDir(t *testing.T) {
// Read-only path that doesn't exist
_, err := NewTenantRegistry("/proc/nonexistent/path")
if err == nil {
// On macOS /proc doesn't exist, so MkdirAll will fail
// On Linux with /proc it would also fail
// But skip if somehow it works (unlikely)
if _, statErr := os.Stat("/proc/nonexistent/path"); statErr != nil {
t.Log("Skipping: path creation succeeded unexpectedly")
}
}
}
func TestStripeAccountCRUD(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{
ID: accountID,
Kind: AccountKindMSP,
DisplayName: "Test MSP",
}); err != nil {
t.Fatal(err)
}
trialEnds := time.Now().UTC().Add(7 * 24 * time.Hour).Unix()
periodEnd := time.Now().UTC().Add(30 * 24 * time.Hour).Unix()
graceStarted := time.Now().UTC().Add(-2 * time.Hour).Unix()
sa := &StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_test_123",
StripeSubscriptionID: "sub_test_123",
StripeSubItemWorkspacesID: "si_workspaces_123",
PlanVersion: "msp_hosted_v1",
SubscriptionState: "past_due",
GraceStartedAt: &graceStarted,
TrialEndsAt: &trialEnds,
CurrentPeriodEnd: &periodEnd,
}
// Create
if err := reg.CreateStripeAccount(sa); err != nil {
t.Fatalf("CreateStripeAccount: %v", err)
}
// Get by account id
got, err := reg.GetStripeAccount(accountID)
if err != nil {
t.Fatalf("GetStripeAccount: %v", err)
}
if got == nil {
t.Fatal("GetStripeAccount returned nil")
}
if got.StripeCustomerID != "cus_test_123" {
t.Errorf("StripeCustomerID = %q, want %q", got.StripeCustomerID, "cus_test_123")
}
if got.StripeSubscriptionID != "sub_test_123" {
t.Errorf("StripeSubscriptionID = %q, want %q", got.StripeSubscriptionID, "sub_test_123")
}
if got.GraceStartedAt == nil || *got.GraceStartedAt != graceStarted {
t.Fatalf("GraceStartedAt = %v, want %d", got.GraceStartedAt, graceStarted)
}
// Get by customer id
got2, err := reg.GetStripeAccountByCustomerID("cus_test_123")
if err != nil {
t.Fatalf("GetStripeAccountByCustomerID: %v", err)
}
if got2 == nil || got2.AccountID != accountID {
t.Fatalf("expected accountID %q, got %#v", accountID, got2)
}
// Update
got2.SubscriptionState = "active"
got2.GraceStartedAt = nil
got2.PlanVersion = "msp_hosted_v2"
got2.StripeSubscriptionID = "sub_test_456"
if err := reg.UpdateStripeAccount(got2); err != nil {
t.Fatalf("UpdateStripeAccount: %v", err)
}
got3, err := reg.GetStripeAccount(accountID)
if err != nil {
t.Fatalf("GetStripeAccount after update: %v", err)
}
if got3.SubscriptionState != "active" {
t.Errorf("SubscriptionState = %q, want %q", got3.SubscriptionState, "active")
}
if got3.PlanVersion != "msp_hosted_v2" {
t.Errorf("PlanVersion = %q, want %q", got3.PlanVersion, "msp_hosted_v2")
}
if got3.StripeSubscriptionID != "sub_test_456" {
t.Errorf("StripeSubscriptionID = %q, want %q", got3.StripeSubscriptionID, "sub_test_456")
}
if got3.GraceStartedAt != nil {
t.Fatalf("GraceStartedAt = %v, want nil", got3.GraceStartedAt)
}
if got3.UpdatedAt == 0 {
t.Error("UpdatedAt should be set")
}
}
func TestHostedEntitlementLookupAndIssue(t *testing.T) {
reg := newTestRegistry(t)
tenant := &Tenant{
ID: "t-REFRESH01",
Email: "owner@example.com",
State: TenantStateActive,
AccountID: "a_refresh_1",
}
if err := reg.Create(tenant); err != nil {
t.Fatalf("Create: %v", err)
}
now := time.Now().UTC()
stored, firstIssue, err := reg.StoreOrIssueHostedEntitlement(tenant.ID, "etr_paid_one", now)
if err != nil {
t.Fatalf("StoreOrIssueHostedEntitlement(first): %v", err)
}
if !firstIssue {
t.Fatal("first token issue should report firstIssue=true")
}
if stored != "etr_paid_one" {
t.Fatalf("stored token = %q, want %q", stored, "etr_paid_one")
}
loaded, err := reg.GetHostedEntitlementByRefreshToken("etr_paid_one")
if err != nil {
t.Fatalf("GetHostedEntitlementByRefreshToken: %v", err)
}
if loaded == nil || loaded.TenantID != tenant.ID {
t.Fatalf("expected tenant %q, got %#v", tenant.ID, loaded)
}
if loaded.Kind != HostedEntitlementKindPaid {
t.Fatalf("kind=%q, want %q", loaded.Kind, HostedEntitlementKindPaid)
}
if loaded.IssuedAt.Unix() != now.Unix() {
t.Fatalf("issued_at=%v, want %v", loaded.IssuedAt, now)
}
stored, firstIssue, err = reg.StoreOrIssueHostedEntitlement(tenant.ID, "etr_paid_two", now.Add(time.Minute))
if err != nil {
t.Fatalf("StoreOrIssueHostedEntitlement(second): %v", err)
}
if firstIssue {
t.Fatal("second token issue should report firstIssue=false")
}
if stored != "etr_paid_one" {
t.Fatalf("stored token on second issue = %q, want %q", stored, "etr_paid_one")
}
if err := reg.MarkHostedEntitlementRefreshed(paidHostedEntitlementID(tenant.ID), now.Add(2*time.Minute)); err != nil {
t.Fatalf("MarkHostedEntitlementRefreshed: %v", err)
}
loaded, err = reg.GetHostedEntitlementByRefreshToken("etr_paid_one")
if err != nil {
t.Fatalf("GetHostedEntitlementByRefreshToken(after refresh): %v", err)
}
if loaded.LastRefreshedAt == nil || loaded.LastRefreshedAt.Unix() != now.Add(2*time.Minute).Unix() {
t.Fatalf("last_refreshed_at=%v, want %v", loaded.LastRefreshedAt, now.Add(2*time.Minute))
}
if err := reg.RevokeHostedEntitlement(tenant.ID, now.Add(3*time.Minute)); err != nil {
t.Fatalf("RevokeHostedEntitlement: %v", err)
}
loaded, err = reg.GetHostedEntitlementByRefreshToken("etr_paid_one")
if err != nil {
t.Fatalf("GetHostedEntitlementByRefreshToken(after revoke): %v", err)
}
if loaded.RevokedAt == nil || loaded.RevokedAt.Unix() != now.Add(3*time.Minute).Unix() {
t.Fatalf("revoked_at=%v, want %v", loaded.RevokedAt, now.Add(3*time.Minute))
}
stored, firstIssue, err = reg.StoreOrIssueHostedEntitlement(tenant.ID, "etr_paid_three", now.Add(4*time.Minute))
if err != nil {
t.Fatalf("StoreOrIssueHostedEntitlement(after revoke): %v", err)
}
if !firstIssue {
t.Fatal("reissue after revoke should report firstIssue=true")
}
if stored != "etr_paid_three" {
t.Fatalf("stored token after revoke = %q, want %q", stored, "etr_paid_three")
}
listed, err := reg.ListHostedEntitlements()
if err != nil {
t.Fatalf("ListHostedEntitlements: %v", err)
}
if len(listed) != 1 {
t.Fatalf("len(ListHostedEntitlements) = %d, want 1", len(listed))
}
if listed[0].ID != paidHostedEntitlementID(tenant.ID) || listed[0].RefreshToken != "etr_paid_three" {
t.Fatalf("listed entitlement = %#v, want current paid entitlement", listed[0])
}
}
func TestTenantRegistryCanonicalizesTenantPlanVersion(t *testing.T) {
reg := newTestRegistry(t)
tenant := &Tenant{
ID: "t-CANON001",
AccountID: "a_canon_1",
DisplayName: "Canonical Tenant",
State: TenantStateActive,
PlanVersion: " cloud_v1 ",
}
if err := reg.Create(tenant); err != nil {
t.Fatalf("Create: %v", err)
}
if tenant.PlanVersion != "cloud_starter" {
t.Fatalf("tenant.PlanVersion after create=%q, want %q", tenant.PlanVersion, "cloud_starter")
}
loaded, err := reg.Get(tenant.ID)
if err != nil {
t.Fatalf("Get: %v", err)
}
if loaded == nil {
t.Fatal("expected tenant to exist")
}
if loaded.PlanVersion != "cloud_starter" {
t.Fatalf("loaded.PlanVersion=%q, want %q", loaded.PlanVersion, "cloud_starter")
}
loaded.PlanVersion = "starter"
if err := reg.Update(loaded); err != nil {
t.Fatalf("Update: %v", err)
}
if loaded.PlanVersion != "cloud_starter" {
t.Fatalf("tenant.PlanVersion after update=%q, want %q", loaded.PlanVersion, "cloud_starter")
}
}
func TestTenantRegistryCanonicalizesStripeAccountPlanVersion(t *testing.T) {
reg := newTestRegistry(t)
accountID, err := GenerateAccountID()
if err != nil {
t.Fatal(err)
}
if err := reg.CreateAccount(&Account{
ID: accountID,
Kind: AccountKindIndividual,
DisplayName: "Canonical Account",
}); err != nil {
t.Fatalf("CreateAccount: %v", err)
}
sa := &StripeAccount{
AccountID: accountID,
StripeCustomerID: "cus_canon_123",
PlanVersion: "cloud_v1",
SubscriptionState: "active",
}
if err := reg.CreateStripeAccount(sa); err != nil {
t.Fatalf("CreateStripeAccount: %v", err)
}
if sa.PlanVersion != "cloud_starter" {
t.Fatalf("stripe account PlanVersion after create=%q, want %q", sa.PlanVersion, "cloud_starter")
}
loaded, err := reg.GetStripeAccount(accountID)
if err != nil {
t.Fatalf("GetStripeAccount: %v", err)
}
if loaded == nil {
t.Fatal("expected stripe account to exist")
}
if loaded.PlanVersion != "cloud_starter" {
t.Fatalf("loaded stripe account PlanVersion=%q, want %q", loaded.PlanVersion, "cloud_starter")
}
loaded.PlanVersion = "starter"
if err := reg.UpdateStripeAccount(loaded); err != nil {
t.Fatalf("UpdateStripeAccount: %v", err)
}
if loaded.PlanVersion != "cloud_starter" {
t.Fatalf("stripe account PlanVersion after update=%q, want %q", loaded.PlanVersion, "cloud_starter")
}
}
func TestCreateStripeAccount_RequiresExistingAccount(t *testing.T) {
reg := newTestRegistry(t)
sa := &StripeAccount{
AccountID: "a_missing123",
StripeCustomerID: "cus_missing_123",
PlanVersion: "msp_hosted_v1",
SubscriptionState: "trial",
}
if err := reg.CreateStripeAccount(sa); err == nil {
t.Fatal("expected CreateStripeAccount to fail when account does not exist")
}
}
func TestStripeEventIdempotency(t *testing.T) {
reg := newTestRegistry(t)
already, err := reg.RecordStripeEvent("evt_test_123", "customer.subscription.updated")
if err != nil {
t.Fatalf("RecordStripeEvent: %v", err)
}
if already {
t.Fatalf("expected alreadyProcessed=false on first insert")
}
already2, err := reg.RecordStripeEvent("evt_test_123", "customer.subscription.updated")
if err != nil {
t.Fatalf("RecordStripeEvent duplicate: %v", err)
}
if !already2 {
t.Fatalf("expected alreadyProcessed=true on duplicate insert")
}
}
func TestStripeEventRetryAfterFailure(t *testing.T) {
reg := newTestRegistry(t)
const eventID = "evt_retry_123"
const eventType = "customer.subscription.updated"
already, err := reg.RecordStripeEvent(eventID, eventType)
if err != nil {
t.Fatalf("RecordStripeEvent first: %v", err)
}
if already {
t.Fatalf("expected first delivery to be processable")
}
if err := reg.MarkStripeEventProcessed(eventID, "temporary failure"); err != nil {
t.Fatalf("MarkStripeEventProcessed failure: %v", err)
}
already, err = reg.RecordStripeEvent(eventID, eventType)
if err != nil {
t.Fatalf("RecordStripeEvent retry: %v", err)
}
if already {
t.Fatalf("expected retry after failure to be processable")
}
already, err = reg.RecordStripeEvent(eventID, eventType)
if err != nil {
t.Fatalf("RecordStripeEvent duplicate while in-flight: %v", err)
}
if !already {
t.Fatalf("expected duplicate during in-flight processing to be skipped")
}
if err := reg.MarkStripeEventProcessed(eventID, ""); err != nil {
t.Fatalf("MarkStripeEventProcessed success: %v", err)
}
already, err = reg.RecordStripeEvent(eventID, eventType)
if err != nil {
t.Fatalf("RecordStripeEvent duplicate after success: %v", err)
}
if !already {
t.Fatalf("expected duplicate after success to be skipped")
}
}
func TestStripeEventReclaimsStaleInFlight(t *testing.T) {
reg := newTestRegistry(t)
const eventID = "evt_stale_123"
const eventType = "invoice.payment_failed"
already, err := reg.RecordStripeEvent(eventID, eventType)
if err != nil {
t.Fatalf("RecordStripeEvent first: %v", err)
}
if already {
t.Fatalf("expected first delivery to be processable")
}
staleStartedAt := time.Now().UTC().Add(-(time.Duration(stripeEventProcessingLeaseSeconds) + 30) * time.Second).Unix()
if _, err := reg.db.Exec(`UPDATE stripe_events SET processing_started_at = ?, processed_at = NULL, processing_error = NULL WHERE stripe_event_id = ?`, staleStartedAt, eventID); err != nil {
t.Fatalf("make event stale: %v", err)
}
already, err = reg.RecordStripeEvent(eventID, eventType)
if err != nil {
t.Fatalf("RecordStripeEvent stale reclaim: %v", err)
}
if already {
t.Fatalf("expected stale in-flight event to be reclaimed for processing")
}
}
func TestStripeEventPrunesExpiredRows(t *testing.T) {
reg := newTestRegistry(t)
now := time.Now().UTC().Unix()
stale := now - stripeEventRetentionSeconds - 60
recent := now - stripeEventRetentionSeconds/2
for _, stmt := range []struct {
id string
eventType string
receivedAt int64
processingStarted any
processedAt any
processingError any
}{
{
id: "evt_stale_processed",
eventType: "customer.subscription.updated",
receivedAt: stale,
processingStarted: nil,
processedAt: stale,
processingError: nil,
},
{
id: "evt_stale_pending",
eventType: "invoice.payment_failed",
receivedAt: stale,
processingStarted: nil,
processedAt: nil,
processingError: nil,
},
{
id: "evt_recent_processed",
eventType: "checkout.session.completed",
receivedAt: recent,
processingStarted: nil,
processedAt: recent,
processingError: nil,
},
} {
if _, err := reg.db.Exec(`
INSERT INTO stripe_events (
stripe_event_id, event_type, received_at, processing_started_at, processed_at, processing_error
) VALUES (?, ?, ?, ?, ?, ?)`,
stmt.id, stmt.eventType, stmt.receivedAt, stmt.processingStarted, stmt.processedAt, stmt.processingError,
); err != nil {
t.Fatalf("insert seed stripe event %s: %v", stmt.id, err)
}
}
already, err := reg.RecordStripeEvent("evt_new", "customer.subscription.updated")
if err != nil {
t.Fatalf("RecordStripeEvent: %v", err)
}
if already {
t.Fatal("expected new event to remain processable")
}
assertStripeEventExists := func(eventID string, want bool) {
t.Helper()
var count int
if err := reg.db.QueryRow(`SELECT COUNT(*) FROM stripe_events WHERE stripe_event_id = ?`, eventID).Scan(&count); err != nil {
t.Fatalf("query stripe event %s: %v", eventID, err)
}
if got := count == 1; got != want {
t.Fatalf("stripe event %s exists=%v, want %v", eventID, got, want)
}
}
assertStripeEventExists("evt_stale_processed", false)
assertStripeEventExists("evt_stale_pending", false)
assertStripeEventExists("evt_recent_processed", true)
assertStripeEventExists("evt_new", true)
}