Skip to content

Commit d415329

Browse files
committed
fix: update test infrastructure for tenant schema isolation
- testdb.CreateTenantSchema: accept optional models to auto-migrate tables into tenant schema (not just create the empty schema) - market-information testcontainer: create org_test_master schema with tables and set as database default search_path, matching production behavior where the master tenant schema is the default context - Fix entitlement helpers to explicitly use public.tenant_data_entitlements to match the production code's checkTenantAccess query - Fix CreateTenantSchema to copy reference data from master schema (where it's actually saved) instead of public (which is empty)
1 parent 714ab70 commit d415329

3 files changed

Lines changed: 80 additions & 26 deletions

File tree

services/current-account/adapters/persistence/repository_contract_test.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ var contractTestTenantID = tenant.MustNewTenantID("contract_test")
3030
func TestFindByID_UsesAccountID(t *testing.T) {
3131
db, cleanup := testdb.SetupPostgres(t, []interface{}{&CurrentAccountEntity{}})
3232
defer cleanup()
33-
testdb.CreateTenantSchema(t, db, contractTestTenantID)
33+
testdb.CreateTenantSchema(t, db, contractTestTenantID, &CurrentAccountEntity{})
3434

3535
repo := NewRepository(db)
3636
ctx := tenant.WithTenant(context.Background(), contractTestTenantID)
@@ -62,7 +62,7 @@ func TestFindByID_UsesAccountID(t *testing.T) {
6262
func TestFindByIDForUpdate_UsesAccountID(t *testing.T) {
6363
db, cleanup := testdb.SetupPostgres(t, []interface{}{&CurrentAccountEntity{}})
6464
defer cleanup()
65-
testdb.CreateTenantSchema(t, db, contractTestTenantID)
65+
testdb.CreateTenantSchema(t, db, contractTestTenantID, &CurrentAccountEntity{})
6666

6767
repo := NewRepository(db)
6868
ctx := tenant.WithTenant(context.Background(), contractTestTenantID)
@@ -87,7 +87,7 @@ func TestFindByIDForUpdate_UsesAccountID(t *testing.T) {
8787
func TestDelete_UsesAccountID(t *testing.T) {
8888
db, cleanup := testdb.SetupPostgres(t, []interface{}{&CurrentAccountEntity{}})
8989
defer cleanup()
90-
testdb.CreateTenantSchema(t, db, contractTestTenantID)
90+
testdb.CreateTenantSchema(t, db, contractTestTenantID, &CurrentAccountEntity{})
9191

9292
repo := NewRepository(db)
9393
ctx := tenant.WithTenant(context.Background(), contractTestTenantID)

services/market-information/adapters/persistence/testhelpers/testcontainer.go

Lines changed: 58 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,10 @@ var schemaDDL string
2323

2424
// TestContainer holds the test database container, connection pool, and repository instances.
2525
type TestContainer struct {
26-
container *postgres.PostgresContainer
27-
Pool *pgxpool.Pool
28-
Repos *persistence.Repositories
26+
container *postgres.PostgresContainer
27+
Pool *pgxpool.Pool
28+
Repos *persistence.Repositories
29+
MasterTenantID tenant.TenantID // The master tenant used for shared/hierarchical data lookups
2930
}
3031

3132
// SetupTestContainer creates a PostgreSQL testcontainer with the market_information schema loaded.
@@ -59,16 +60,42 @@ func SetupTestContainer(t *testing.T) *TestContainer {
5960
pool, err := pgxpool.NewWithConfig(ctx, poolConfig)
6061
require.NoError(t, err, "Failed to create connection pool")
6162

62-
// Load schema
63+
// Load schema into public schema (used by non-tenant-scoped tests)
6364
loadSchema(t, pool)
6465

66+
// Create master tenant schema with the same tables.
67+
// The market-information service uses a master tenant for hierarchical lookup
68+
// (shared dataset fallback). Without public in search_path, the master tenant
69+
// needs its own schema with all required tables.
70+
masterTenantID := "test_master"
71+
masterSchema := "org_" + masterTenantID
72+
_, err = pool.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+masterSchema)
73+
require.NoError(t, err, "Failed to create master tenant schema")
74+
err = loadSchemaInSchema(ctx, pool, masterSchema)
75+
require.NoError(t, err, "Failed to load schema into master tenant schema")
76+
77+
// Set default search_path to master tenant schema for non-tenant-scoped queries.
78+
// Tests that don't explicitly set a tenant context will use the master schema,
79+
// matching production behavior where the master tenant is the default context.
80+
_, err = pool.Exec(ctx, "ALTER DATABASE test_market_information SET search_path TO "+masterSchema)
81+
require.NoError(t, err, "Failed to set default search_path")
82+
83+
// Reconnect to pick up the new default search_path
84+
pool.Close()
85+
pool, err = pgxpool.NewWithConfig(ctx, poolConfig)
86+
require.NoError(t, err, "Failed to reconnect with new search_path")
87+
6588
// Create repositories with test master tenant
66-
repos := persistence.NewRepositories(pool, "test_master")
89+
repos := persistence.NewRepositories(pool, masterTenantID)
90+
91+
masterTID, err := tenant.NewTenantID(masterTenantID)
92+
require.NoError(t, err, "Failed to parse master tenant ID")
6793

6894
return &TestContainer{
69-
container: pgContainer,
70-
Pool: pool,
71-
Repos: repos,
95+
container: pgContainer,
96+
Pool: pool,
97+
Repos: repos,
98+
MasterTenantID: masterTID,
7299
}
73100
}
74101

@@ -125,30 +152,31 @@ func (tc *TestContainer) CreateTenantSchema(tenantIDStr string) (tenant.TenantID
125152
return tenant.TenantID(""), fmt.Errorf("failed to load schema: %w", err)
126153
}
127154

128-
// Copy shared datasets from public schema to tenant schema.
129-
// This allows tenant observations to reference shared dataset definitions.
130-
// Note: quotedSchema is safely quoted via pgx.Identifier.Sanitize() above.
155+
// Copy shared datasets from master schema to tenant schema.
156+
// Reference data is saved to the master schema (org_test_master) via the default search_path,
157+
// not to public. This allows tenant observations to reference shared dataset definitions.
158+
masterSchema := pgx.Identifier{tc.MasterTenantID.SchemaName()}.Sanitize()
131159
_, err = tc.Pool.Exec(ctx, fmt.Sprintf(`
132160
INSERT INTO %s.dataset_definition
133-
SELECT * FROM public.dataset_definition
161+
SELECT * FROM %s.dataset_definition
134162
WHERE is_shared = TRUE
135-
`, quotedSchema))
163+
`, quotedSchema, masterSchema))
136164
if err != nil {
137165
return tenant.TenantID(""), fmt.Errorf("failed to copy shared datasets: %w", err)
138166
}
139167

140-
// Copy data sources from public schema to tenant schema
168+
// Copy data sources from master schema to tenant schema
141169
// Data sources are needed for observations to have valid foreign keys
142170
_, err = tc.Pool.Exec(ctx, fmt.Sprintf(`
143171
INSERT INTO %s.data_source
144-
SELECT * FROM public.data_source
145-
`, quotedSchema))
172+
SELECT * FROM %s.data_source
173+
`, quotedSchema, masterSchema))
146174
if err != nil {
147175
return tenant.TenantID(""), fmt.Errorf("failed to copy data sources: %w", err)
148176
}
149177

150-
// Reset search path to default
151-
_, err = tc.Pool.Exec(ctx, "SET search_path TO public")
178+
// Reset search path to master schema (the database default)
179+
_, err = tc.Pool.Exec(ctx, "SET search_path TO "+masterSchema)
152180
if err != nil {
153181
return tenant.TenantID(""), fmt.Errorf("failed to reset search_path: %w", err)
154182
}
@@ -161,10 +189,19 @@ func (tc *TestContainer) WithTenant(ctx context.Context, tenantID tenant.TenantI
161189
return tenant.WithTenant(ctx, tenantID)
162190
}
163191

192+
// MasterContext returns a context scoped to the master tenant.
193+
// Use this when saving reference data (datasets, data sources) that the observation
194+
// repository's hierarchical lookup will query via the master tenant schema.
195+
func (tc *TestContainer) MasterContext(ctx context.Context) context.Context {
196+
return tenant.WithTenant(ctx, tc.MasterTenantID)
197+
}
198+
164199
// GrantTenantEntitlement grants access to a dataset for a tenant.
200+
// Uses public.tenant_data_entitlements explicitly because the production code's
201+
// checkTenantAccess queries public.tenant_data_entitlements directly.
165202
func (tc *TestContainer) GrantTenantEntitlement(ctx context.Context, tenantID tenant.TenantID, datasetCode string, expiresAt *time.Time) error {
166203
query := `
167-
INSERT INTO tenant_data_entitlements (tenant_id, dataset_code, is_active, expires_at)
204+
INSERT INTO public.tenant_data_entitlements (tenant_id, dataset_code, is_active, expires_at)
168205
VALUES ($1, $2, TRUE, $3)
169206
ON CONFLICT (tenant_id, dataset_code)
170207
DO UPDATE SET is_active = TRUE, expires_at = EXCLUDED.expires_at`
@@ -177,9 +214,10 @@ func (tc *TestContainer) GrantTenantEntitlement(ctx context.Context, tenantID te
177214
}
178215

179216
// RevokeTenantEntitlement revokes access to a dataset for a tenant.
217+
// Uses public.tenant_data_entitlements explicitly to match checkTenantAccess.
180218
func (tc *TestContainer) RevokeTenantEntitlement(ctx context.Context, tenantID tenant.TenantID, datasetCode string) error {
181219
query := `
182-
UPDATE tenant_data_entitlements
220+
UPDATE public.tenant_data_entitlements
183221
SET is_active = FALSE
184222
WHERE tenant_id = $1 AND dataset_code = $2`
185223

shared/platform/testdb/postgres.go

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,33 @@ func WithLogLevel(level logger.LogLevel) PostgresOption {
3636
// This must be called before any tenant-scoped database operation that targets this tenant,
3737
// since WithGormTenantScope validates schema existence.
3838
//
39+
// When models are provided, GORM AutoMigrate runs inside the tenant schema so that
40+
// tenant-scoped queries find the same tables that were created in public by SetupPostgres.
41+
//
3942
// Example:
4043
//
41-
// db, cleanup := testdb.SetupCockroachDB(t, models)
44+
// db, cleanup := testdb.SetupPostgres(t, []interface{}{&MyEntity{}})
4245
// defer cleanup()
43-
// testdb.CreateTenantSchema(t, db, tenant.MustNewTenantID("test_tenant"))
44-
func CreateTenantSchema(t *testing.T, db *gorm.DB, tenantID interface{ SchemaName() string }) {
46+
// testdb.CreateTenantSchema(t, db, tenant.MustNewTenantID("test_tenant"), &MyEntity{})
47+
func CreateTenantSchema(t *testing.T, db *gorm.DB, tenantID interface{ SchemaName() string }, models ...interface{}) {
4548
t.Helper()
4649
schema := tenantID.SchemaName()
4750
if err := db.Exec(fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %q", schema)).Error; err != nil {
4851
t.Fatalf("Failed to create tenant schema %s: %v", schema, err)
4952
}
53+
54+
if len(models) > 0 {
55+
// Set search_path to tenant schema, run AutoMigrate, then reset
56+
if err := db.Exec(fmt.Sprintf("SET search_path TO %q", schema)).Error; err != nil {
57+
t.Fatalf("Failed to set search_path to %s: %v", schema, err)
58+
}
59+
if err := db.AutoMigrate(models...); err != nil {
60+
t.Fatalf("Failed to auto-migrate models in tenant schema %s: %v", schema, err)
61+
}
62+
if err := db.Exec("SET search_path TO public").Error; err != nil {
63+
t.Fatalf("Failed to reset search_path: %v", err)
64+
}
65+
}
5066
}
5167

5268
// extractSchemasFromModels extracts unique schema names from models with TableName() methods.

0 commit comments

Comments
 (0)