@ankhorage/infra 0.3.1 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +2 -2
  3. package/dist/adapters/minikube/auth/supabase/index.d.ts.map +1 -1
  4. package/dist/adapters/minikube/auth/supabase/index.js +12 -161
  5. package/dist/adapters/minikube/auth/supabase/index.js.map +1 -1
  6. package/dist/adapters/minikube/auth/supabase/profile.d.ts +21 -0
  7. package/dist/adapters/minikube/auth/supabase/profile.d.ts.map +1 -0
  8. package/dist/adapters/minikube/auth/supabase/profile.js +452 -0
  9. package/dist/adapters/minikube/auth/supabase/profile.js.map +1 -0
  10. package/dist/adapters/minikube/base/index.d.ts.map +1 -1
  11. package/dist/adapters/minikube/base/index.js +849 -53
  12. package/dist/adapters/minikube/base/index.js.map +1 -1
  13. package/dist/{cli.d.ts → cli/bin.d.ts} +3 -3
  14. package/dist/cli/bin.d.ts.map +1 -0
  15. package/dist/{cli.js → cli/bin.js} +3 -3
  16. package/dist/cli/bin.js.map +1 -0
  17. package/dist/readme-usage.js +1 -1
  18. package/dist/readme-usage.js.map +1 -1
  19. package/package.json +2 -2
  20. package/src/adapters/minikube/auth/supabase/index.test.ts +152 -18
  21. package/src/adapters/minikube/auth/supabase/index.ts +16 -203
  22. package/src/adapters/minikube/auth/supabase/profile.integration.test.ts +860 -0
  23. package/src/adapters/minikube/auth/supabase/profile.ts +526 -0
  24. package/src/adapters/minikube/base/index.test.ts +100 -6
  25. package/src/adapters/minikube/base/index.ts +879 -53
  26. package/src/{cli.ts → cli/bin.ts} +3 -3
  27. package/src/{cli.test.ts → cli/cli.test.ts} +3 -3
  28. package/src/index.test.ts +13 -2
  29. package/src/package.test.ts +1 -1
  30. package/src/readme-usage.ts +1 -1
  31. package/dist/cli.d.ts.map +0 -1
  32. package/dist/cli.js.map +0 -1
@@ -0,0 +1,860 @@
1
+ import { execFile as execFileCallback } from 'node:child_process';
2
+ import { chmod, mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { promisify } from 'node:util';
6
+
7
+ import { describe, expect, test } from 'bun:test';
8
+
9
+ import { generateInfrastructure } from '../../../../index';
10
+ import type { GeneratedInfrastructureFile, InfraManifestInput } from '../../../../types';
11
+
12
+ const execFile = promisify(execFileCallback);
13
+ const INTEGRATION_TIMEOUT_MS = 300_000;
14
+ const integrationTest = process.env.ANKH_SUPABASE_INTEGRATION === '1' ? test : test.skip;
15
+
16
+ describe('generated Supabase profile reconciliation integration', () => {
17
+ integrationTest(
18
+ 'drops stale managed columns while preserving custom columns',
19
+ async () => {
20
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
21
+
22
+ try {
23
+ await writeGeneratedFiles(
24
+ appRoot,
25
+ generateInfrastructure(createManifest({ fields: ['email', 'displayName'] }), {
26
+ namespaceHint,
27
+ }).files,
28
+ );
29
+
30
+ await runLocalSupabaseEnv(minikubeRoot);
31
+ await runSupabaseSql(
32
+ minikubeRoot,
33
+ 'alter table public.profiles add column if not exists custom_note text;',
34
+ );
35
+
36
+ await writeGeneratedFiles(
37
+ appRoot,
38
+ generateInfrastructure(createManifest({ fields: ['email', 'avatarUrl'] }), {
39
+ namespaceHint,
40
+ }).files,
41
+ );
42
+ await runLocalSupabaseEnv(minikubeRoot);
43
+
44
+ await runSupabaseSql(
45
+ minikubeRoot,
46
+ `do $$
47
+ begin
48
+ if not exists (
49
+ select 1 from information_schema.columns
50
+ where table_schema = 'public' and table_name = 'profiles' and column_name = 'custom_note'
51
+ ) then
52
+ raise exception 'custom column was not preserved';
53
+ end if;
54
+
55
+ if exists (
56
+ select 1 from information_schema.columns
57
+ where table_schema = 'public' and table_name = 'profiles' and column_name = 'display_name'
58
+ ) then
59
+ raise exception 'stale managed column display_name was not removed';
60
+ end if;
61
+
62
+ if not exists (
63
+ select 1 from information_schema.columns
64
+ where table_schema = 'public' and table_name = 'profiles' and column_name = 'avatar_url'
65
+ ) then
66
+ raise exception 'new managed column avatar_url was not added';
67
+ end if;
68
+ end;
69
+ $$;`,
70
+ );
71
+
72
+ await runSupabaseSql(
73
+ minikubeRoot,
74
+ `do $$
75
+ begin
76
+ if not exists (
77
+ select 1
78
+ from ankhorage_internal.generated_schema_state
79
+ where artifact_key = 'auth.profile'
80
+ and table_name = 'profiles'
81
+ ) then
82
+ raise exception 'missing applied profile state';
83
+ end if;
84
+ end;
85
+ $$;`,
86
+ );
87
+ } finally {
88
+ await execFile('supabase', ['--workdir', minikubeRoot, 'stop', '--no-backup']).catch(() => {
89
+ // Best-effort cleanup for gated local integration runs.
90
+ });
91
+ await rm(appRoot, { recursive: true, force: true });
92
+ }
93
+ },
94
+ INTEGRATION_TIMEOUT_MS,
95
+ );
96
+
97
+ integrationTest(
98
+ 'is idempotent for unchanged generated reconciliation',
99
+ async () => {
100
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
101
+
102
+ try {
103
+ await writeGeneratedFiles(
104
+ appRoot,
105
+ generateInfrastructure(createManifest({ fields: ['email', 'displayName'] }), {
106
+ namespaceHint,
107
+ }).files,
108
+ );
109
+
110
+ await runLocalSupabaseEnv(minikubeRoot);
111
+ await runLocalSupabaseEnv(minikubeRoot);
112
+
113
+ await runSupabaseSql(
114
+ minikubeRoot,
115
+ `do $$
116
+ begin
117
+ if not exists (
118
+ select 1
119
+ from ankhorage_internal.generated_schema_state
120
+ where artifact_key = 'auth.profile'
121
+ and table_name = 'profiles'
122
+ ) then
123
+ raise exception 'missing applied profile state';
124
+ end if;
125
+ end;
126
+ $$;`,
127
+ );
128
+ } finally {
129
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
130
+ }
131
+ },
132
+ INTEGRATION_TIMEOUT_MS,
133
+ );
134
+
135
+ integrationTest(
136
+ 'rejects profile table rename once generated state exists',
137
+ async () => {
138
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
139
+
140
+ try {
141
+ await writeGeneratedFiles(
142
+ appRoot,
143
+ generateInfrastructure(createManifest({ table: 'profiles', fields: ['email'] }), {
144
+ namespaceHint,
145
+ }).files,
146
+ );
147
+ await runLocalSupabaseEnv(minikubeRoot);
148
+
149
+ await writeGeneratedFiles(
150
+ appRoot,
151
+ generateInfrastructure(createManifest({ table: 'members', fields: ['email'] }), {
152
+ namespaceHint,
153
+ }).files,
154
+ );
155
+
156
+ const failure = await expectLocalSupabaseEnvFailure(minikubeRoot);
157
+ expect(failure).toContain('profile.table changed from');
158
+ } finally {
159
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
160
+ }
161
+ },
162
+ INTEGRATION_TIMEOUT_MS,
163
+ );
164
+
165
+ integrationTest(
166
+ 'rejects profile table removal once generated state exists',
167
+ async () => {
168
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
169
+
170
+ try {
171
+ await writeGeneratedFiles(
172
+ appRoot,
173
+ generateInfrastructure(createManifest({ table: 'profiles', fields: ['email'] }), {
174
+ namespaceHint,
175
+ }).files,
176
+ );
177
+ await runLocalSupabaseEnv(minikubeRoot);
178
+
179
+ await writeGeneratedFiles(
180
+ appRoot,
181
+ generateInfrastructure(createManifest({ table: undefined, fields: ['email'] }), {
182
+ namespaceHint,
183
+ }).files,
184
+ );
185
+
186
+ const failure = await expectLocalSupabaseEnvFailure(minikubeRoot);
187
+ expect(failure).toContain('auth.profile.table was removed');
188
+ } finally {
189
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
190
+ }
191
+ },
192
+ INTEGRATION_TIMEOUT_MS,
193
+ );
194
+
195
+ integrationTest(
196
+ 'stops startup on generated SQL failure',
197
+ async () => {
198
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
199
+
200
+ try {
201
+ await writeGeneratedFiles(
202
+ appRoot,
203
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
204
+ );
205
+ await writeFile(
206
+ path.join(minikubeRoot, 'supabase/generated/auth_profiles.sql'),
207
+ 'begin;\nselect definitely_missing_function();\ncommit;\n',
208
+ 'utf8',
209
+ );
210
+
211
+ const failure = await expectLocalSupabaseEnvFailure(minikubeRoot);
212
+ expect(failure).toContain('Generated profile reconciliation failed.');
213
+ expect(failure).toContain('auth_profiles.sql');
214
+ expect(failure).toContain('Exit status:');
215
+ } finally {
216
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
217
+ }
218
+ },
219
+ INTEGRATION_TIMEOUT_MS,
220
+ );
221
+
222
+ integrationTest(
223
+ 'preserves unrelated custom foreign keys to auth.users',
224
+ async () => {
225
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
226
+
227
+ try {
228
+ await writeGeneratedFiles(
229
+ appRoot,
230
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
231
+ );
232
+ await runLocalSupabaseEnv(minikubeRoot);
233
+ await runSupabaseSql(
234
+ minikubeRoot,
235
+ `alter table public.profiles add column if not exists manager_id uuid;
236
+ alter table public.profiles
237
+ add constraint profiles_manager_id_auth_users_fkey
238
+ foreign key (manager_id) references auth.users(id) on delete set null;`,
239
+ );
240
+
241
+ await runLocalSupabaseEnv(minikubeRoot);
242
+ await runSupabaseSql(
243
+ minikubeRoot,
244
+ `do $$
245
+ begin
246
+ if not exists (
247
+ select 1
248
+ from pg_constraint
249
+ where conrelid = 'public.profiles'::regclass
250
+ and conname = 'profiles_manager_id_auth_users_fkey'
251
+ ) then
252
+ raise exception 'custom auth.users foreign key was not preserved';
253
+ end if;
254
+ end;
255
+ $$;`,
256
+ );
257
+ } finally {
258
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
259
+ }
260
+ },
261
+ INTEGRATION_TIMEOUT_MS,
262
+ );
263
+
264
+ integrationTest(
265
+ 'status reports stale checksum drift',
266
+ async () => {
267
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
268
+
269
+ try {
270
+ await writeGeneratedFiles(
271
+ appRoot,
272
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
273
+ );
274
+ await runLocalSupabaseEnv(minikubeRoot);
275
+ await runSupabaseSql(
276
+ minikubeRoot,
277
+ `update ankhorage_internal.generated_schema_state
278
+ set content_hash = repeat('0', 64)
279
+ where artifact_key = 'auth.profile';`,
280
+ );
281
+
282
+ const failure = await expectGeneratedStatusFailure(minikubeRoot);
283
+ expect(failure).toContain('profile reconciliation: pending or stale');
284
+ } finally {
285
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
286
+ }
287
+ },
288
+ INTEGRATION_TIMEOUT_MS,
289
+ );
290
+
291
+ integrationTest(
292
+ 'status reports extra permissive profile policy drift',
293
+ async () => {
294
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
295
+
296
+ try {
297
+ await writeGeneratedFiles(
298
+ appRoot,
299
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
300
+ );
301
+ await runLocalSupabaseEnv(minikubeRoot);
302
+ await runSupabaseSql(
303
+ minikubeRoot,
304
+ `create policy profiles_select_everything
305
+ on public.profiles
306
+ for select
307
+ to authenticated
308
+ using (true);`,
309
+ );
310
+
311
+ const failure = await expectGeneratedStatusFailure(minikubeRoot);
312
+ expect(failure).toContain('profile schema: drift detected');
313
+ } finally {
314
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
315
+ }
316
+ },
317
+ INTEGRATION_TIMEOUT_MS,
318
+ );
319
+
320
+ integrationTest(
321
+ 'status reports authenticated insert and delete privilege drift',
322
+ async () => {
323
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
324
+
325
+ try {
326
+ await writeGeneratedFiles(
327
+ appRoot,
328
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
329
+ );
330
+ await runLocalSupabaseEnv(minikubeRoot);
331
+ await runSupabaseSql(
332
+ minikubeRoot,
333
+ 'grant insert, delete on table public.profiles to authenticated;',
334
+ );
335
+
336
+ const failure = await expectGeneratedStatusFailure(minikubeRoot);
337
+ expect(failure).toContain('profile schema: drift detected');
338
+ } finally {
339
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
340
+ }
341
+ },
342
+ INTEGRATION_TIMEOUT_MS,
343
+ );
344
+
345
+ integrationTest(
346
+ 'status reports column-scoped privilege drift',
347
+ async () => {
348
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
349
+
350
+ try {
351
+ await writeGeneratedFiles(
352
+ appRoot,
353
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
354
+ );
355
+ await runLocalSupabaseEnv(minikubeRoot);
356
+ await runSupabaseSql(
357
+ minikubeRoot,
358
+ `grant select (email), insert (email), update (email)
359
+ on table public.profiles to anon;
360
+ grant insert (email)
361
+ on table public.profiles to authenticated;`,
362
+ );
363
+
364
+ const failure = await expectGeneratedStatusFailure(minikubeRoot);
365
+ expect(failure).toContain('profile schema: drift detected');
366
+ } finally {
367
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
368
+ }
369
+ },
370
+ INTEGRATION_TIMEOUT_MS,
371
+ );
372
+
373
+ integrationTest(
374
+ 'status reports column-scoped references privilege drift',
375
+ async () => {
376
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
377
+
378
+ try {
379
+ await writeGeneratedFiles(
380
+ appRoot,
381
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
382
+ );
383
+ await runLocalSupabaseEnv(minikubeRoot);
384
+ await runSupabaseSql(
385
+ minikubeRoot,
386
+ 'grant references (email) on table public.profiles to anon;',
387
+ );
388
+
389
+ const anonFailure = await expectGeneratedStatusFailure(minikubeRoot);
390
+ expect(anonFailure).toContain('profile schema: drift detected');
391
+
392
+ await runSupabaseSql(
393
+ minikubeRoot,
394
+ `revoke references (email) on table public.profiles from anon;
395
+ grant references (email) on table public.profiles to authenticated;`,
396
+ );
397
+
398
+ const authenticatedFailure = await expectGeneratedStatusFailure(minikubeRoot);
399
+ expect(authenticatedFailure).toContain('profile schema: drift detected');
400
+ } finally {
401
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
402
+ }
403
+ },
404
+ INTEGRATION_TIMEOUT_MS,
405
+ );
406
+
407
+ integrationTest(
408
+ 'status reports custom-column update privilege drift',
409
+ async () => {
410
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
411
+
412
+ try {
413
+ await writeGeneratedFiles(
414
+ appRoot,
415
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
416
+ );
417
+ await runLocalSupabaseEnv(minikubeRoot);
418
+ await runSupabaseSql(
419
+ minikubeRoot,
420
+ `alter table public.profiles add column if not exists custom_note text;
421
+ grant update (custom_note) on table public.profiles to authenticated;`,
422
+ );
423
+
424
+ const failure = await expectGeneratedStatusFailure(minikubeRoot);
425
+ expect(failure).toContain('profile schema: drift detected');
426
+ } finally {
427
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
428
+ }
429
+ },
430
+ INTEGRATION_TIMEOUT_MS,
431
+ );
432
+
433
+ integrationTest(
434
+ 'enforces trigger-created profiles, own-profile RLS, and cascade delete',
435
+ async () => {
436
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
437
+ const firstUserId = '11111111-1111-4111-8111-111111111111';
438
+ const secondUserId = '22222222-2222-4222-8222-222222222222';
439
+
440
+ try {
441
+ await writeGeneratedFiles(
442
+ appRoot,
443
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
444
+ );
445
+ await runLocalSupabaseEnv(minikubeRoot);
446
+ await runSupabaseSql(
447
+ minikubeRoot,
448
+ `insert into auth.users (
449
+ instance_id,
450
+ id,
451
+ aud,
452
+ role,
453
+ email,
454
+ encrypted_password,
455
+ email_confirmed_at,
456
+ raw_app_meta_data,
457
+ raw_user_meta_data,
458
+ created_at,
459
+ updated_at
460
+ )
461
+ values
462
+ (
463
+ '00000000-0000-0000-0000-000000000000',
464
+ '${firstUserId}',
465
+ 'authenticated',
466
+ 'authenticated',
467
+ 'first@example.test',
468
+ 'not-a-real-password-hash',
469
+ now(),
470
+ '{"provider":"email","providers":["email"]}'::jsonb,
471
+ '{}'::jsonb,
472
+ now(),
473
+ now()
474
+ ),
475
+ (
476
+ '00000000-0000-0000-0000-000000000000',
477
+ '${secondUserId}',
478
+ 'authenticated',
479
+ 'authenticated',
480
+ 'second@example.test',
481
+ 'not-a-real-password-hash',
482
+ now(),
483
+ '{"provider":"email","providers":["email"]}'::jsonb,
484
+ '{}'::jsonb,
485
+ now(),
486
+ now()
487
+ );
488
+
489
+ do $$
490
+ begin
491
+ if not exists (
492
+ select 1 from public.profiles
493
+ where id = '${firstUserId}'::uuid and email = 'first@example.test'
494
+ ) then
495
+ raise exception 'trigger did not create first profile row';
496
+ end if;
497
+
498
+ if not exists (
499
+ select 1 from public.profiles
500
+ where id = '${secondUserId}'::uuid and email = 'second@example.test'
501
+ ) then
502
+ raise exception 'trigger did not create second profile row';
503
+ end if;
504
+ end;
505
+ $$;
506
+
507
+ begin;
508
+ set local role authenticated;
509
+ set local request.jwt.claim.sub = '${firstUserId}';
510
+ do $$
511
+ declare
512
+ visible_profiles integer;
513
+ begin
514
+ select count(*) into visible_profiles from public.profiles;
515
+ if visible_profiles <> 1 then
516
+ raise exception 'RLS exposed % profile rows to first user', visible_profiles;
517
+ end if;
518
+
519
+ if not exists (select 1 from public.profiles where id = '${firstUserId}'::uuid) then
520
+ raise exception 'RLS hid first user own profile';
521
+ end if;
522
+
523
+ if exists (select 1 from public.profiles where id = '${secondUserId}'::uuid) then
524
+ raise exception 'RLS exposed second user profile to first user';
525
+ end if;
526
+
527
+ update public.profiles
528
+ set email = 'first-updated@example.test'
529
+ where id = '${firstUserId}'::uuid;
530
+
531
+ if not exists (
532
+ select 1 from public.profiles
533
+ where id = '${firstUserId}'::uuid and email = 'first-updated@example.test'
534
+ ) then
535
+ raise exception 'RLS did not allow own profile update';
536
+ end if;
537
+ end;
538
+ $$;
539
+ commit;
540
+
541
+ delete from auth.users where id = '${firstUserId}'::uuid;
542
+
543
+ do $$
544
+ begin
545
+ if exists (select 1 from public.profiles where id = '${firstUserId}'::uuid) then
546
+ raise exception 'profile row survived auth.users cascade delete';
547
+ end if;
548
+
549
+ if not exists (select 1 from public.profiles where id = '${secondUserId}'::uuid) then
550
+ raise exception 'cascade delete removed unrelated profile row';
551
+ end if;
552
+ end;
553
+ $$;`,
554
+ );
555
+ } finally {
556
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
557
+ }
558
+ },
559
+ INTEGRATION_TIMEOUT_MS,
560
+ );
561
+
562
+ integrationTest(
563
+ 'reports and applies pending immutable migrations',
564
+ async () => {
565
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
566
+
567
+ try {
568
+ await writeGeneratedFiles(
569
+ appRoot,
570
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
571
+ );
572
+ await runLocalSupabaseEnv(minikubeRoot);
573
+ await mkdir(path.join(minikubeRoot, 'supabase/migrations'), { recursive: true });
574
+ await writeFile(
575
+ path.join(minikubeRoot, 'supabase/migrations/20260711000000_create_pending_marker.sql'),
576
+ 'create table public.pending_migration_marker (id uuid primary key default gen_random_uuid());\n',
577
+ 'utf8',
578
+ );
579
+
580
+ const pendingStatus = await expectGeneratedStatusFailure(minikubeRoot);
581
+ expect(pendingStatus).toContain('immutable migrations: pending');
582
+
583
+ await runLocalSupabaseEnv(minikubeRoot);
584
+ await runSupabaseSql(
585
+ minikubeRoot,
586
+ `do $$
587
+ begin
588
+ if to_regclass('public.pending_migration_marker') is null then
589
+ raise exception 'pending immutable migration was not applied';
590
+ end if;
591
+ end;
592
+ $$;`,
593
+ );
594
+ } finally {
595
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
596
+ }
597
+ },
598
+ INTEGRATION_TIMEOUT_MS,
599
+ );
600
+
601
+ integrationTest(
602
+ 'status reports malformed primary key and foreign key drift',
603
+ async () => {
604
+ const { appRoot, minikubeRoot, namespaceHint } = await createIntegrationProject();
605
+
606
+ try {
607
+ await writeGeneratedFiles(
608
+ appRoot,
609
+ generateInfrastructure(createManifest({ fields: ['email'] }), { namespaceHint }).files,
610
+ );
611
+ await runLocalSupabaseEnv(minikubeRoot);
612
+ await runSupabaseSql(
613
+ minikubeRoot,
614
+ `alter table public.profiles drop constraint profiles_pkey;
615
+ alter table public.profiles add column if not exists pk_extra uuid not null default gen_random_uuid();
616
+ alter table public.profiles add constraint profiles_pkey primary key (id, pk_extra);`,
617
+ );
618
+
619
+ const malformedPkStatus = await expectGeneratedStatusFailure(minikubeRoot);
620
+ expect(malformedPkStatus).toContain('profile schema: drift detected');
621
+
622
+ await runSupabaseSql(
623
+ minikubeRoot,
624
+ `alter table public.profiles drop constraint profiles_pkey;
625
+ alter table public.profiles drop column pk_extra;
626
+ alter table public.profiles add constraint profiles_pkey primary key (id);`,
627
+ );
628
+ await runLocalSupabaseEnv(minikubeRoot);
629
+ await runSupabaseSql(
630
+ minikubeRoot,
631
+ `do $$
632
+ declare
633
+ fk_name text;
634
+ begin
635
+ select c.conname
636
+ into fk_name
637
+ from pg_constraint c
638
+ where c.conrelid = 'public.profiles'::regclass
639
+ and c.contype = 'f'
640
+ and c.confrelid = 'auth.users'::regclass
641
+ and c.conkey = array[
642
+ (
643
+ select a.attnum
644
+ from pg_attribute a
645
+ where a.attrelid = 'public.profiles'::regclass
646
+ and a.attname = 'id'
647
+ and not a.attisdropped
648
+ )
649
+ ]::smallint[]
650
+ limit 1;
651
+
652
+ if fk_name is null then
653
+ raise exception 'managed id foreign key was not found before drift injection';
654
+ end if;
655
+
656
+ execute format('alter table public.profiles drop constraint %I', fk_name);
657
+ end;
658
+ $$;
659
+
660
+ alter table public.profiles
661
+ add constraint profiles_id_auth_users_restrict_fkey
662
+ foreign key (id) references auth.users(id) on delete restrict;`,
663
+ );
664
+
665
+ const malformedFkStatus = await expectGeneratedStatusFailure(minikubeRoot);
666
+ expect(malformedFkStatus).toContain('profile schema: drift detected');
667
+ } finally {
668
+ await cleanupIntegrationProject(appRoot, minikubeRoot);
669
+ }
670
+ },
671
+ INTEGRATION_TIMEOUT_MS,
672
+ );
673
+
674
+ integrationTest('rejects public.users at generation time', () => {
675
+ expect(() =>
676
+ generateInfrastructure(createManifest({ table: 'users', fields: ['email'] })),
677
+ ).toThrow('Invalid Supabase profile table identifier "users"');
678
+ });
679
+ });
680
+
681
+ async function createIntegrationProject(): Promise<{
682
+ appRoot: string;
683
+ minikubeRoot: string;
684
+ namespaceHint: string;
685
+ }> {
686
+ const appRoot = await mkdtemp(path.join(tmpdir(), 'ankh-infra-supabase-'));
687
+ const namespaceHint = `profile-${Date.now().toString(36)}`;
688
+ return {
689
+ appRoot,
690
+ minikubeRoot: path.join(appRoot, 'infra/minikube'),
691
+ namespaceHint,
692
+ };
693
+ }
694
+
695
+ async function cleanupIntegrationProject(appRoot: string, minikubeRoot: string): Promise<void> {
696
+ await execFile('supabase', ['--workdir', minikubeRoot, 'stop', '--no-backup']).catch(() => {
697
+ // Best-effort cleanup for gated local integration runs.
698
+ });
699
+ await rm(appRoot, { recursive: true, force: true });
700
+ }
701
+
702
+ async function writeGeneratedFiles(
703
+ appRoot: string,
704
+ files: readonly GeneratedInfrastructureFile[],
705
+ ): Promise<void> {
706
+ for (const file of files) {
707
+ const target = path.join(appRoot, file.path);
708
+ await mkdir(path.dirname(target), { recursive: true });
709
+ await writeFile(target, file.content, 'utf8');
710
+ if (file.executable) {
711
+ await chmod(target, 0o755);
712
+ }
713
+ }
714
+ }
715
+
716
+ async function runLocalSupabaseEnv(minikubeRoot: string): Promise<void> {
717
+ await execFile(path.join(minikubeRoot, 'scripts/supabase-local-env.sh'), {
718
+ cwd: minikubeRoot,
719
+ env: { ...process.env, SUPABASE_PROJECT_DIR: minikubeRoot },
720
+ });
721
+ }
722
+
723
+ async function expectLocalSupabaseEnvFailure(minikubeRoot: string): Promise<string> {
724
+ try {
725
+ await runLocalSupabaseEnv(minikubeRoot);
726
+ } catch (error) {
727
+ const failure = error as { stdout?: string; stderr?: string };
728
+ return `${failure.stdout ?? ''}\n${failure.stderr ?? ''}`;
729
+ }
730
+
731
+ throw new Error('Expected local Supabase bootstrap to fail');
732
+ }
733
+
734
+ async function expectGeneratedStatusFailure(minikubeRoot: string): Promise<string> {
735
+ const fakeBin = path.join(minikubeRoot, 'fake-bin');
736
+ const fakeKubectl = path.join(fakeBin, 'kubectl');
737
+ await mkdir(fakeBin, { recursive: true });
738
+ await writeFile(
739
+ fakeKubectl,
740
+ `#!/usr/bin/env bash
741
+ set -euo pipefail
742
+
743
+ if [[ "\${1:-}" == "config" && "\${2:-}" == "current-context" ]]; then
744
+ echo fake-context
745
+ exit 0
746
+ fi
747
+
748
+ if [[ "\${1:-}" == "cluster-info" ]]; then
749
+ echo "Kubernetes control plane is running"
750
+ exit 0
751
+ fi
752
+
753
+ if [[ "\${1:-}" == "get" && "\${2:-}" == "namespace" ]]; then
754
+ exit 0
755
+ fi
756
+
757
+ if [[ "\${1:-}" == "get" && "\${2:-}" == "all" ]]; then
758
+ exit 0
759
+ fi
760
+
761
+ if [[ "\${1:-}" == "-n" && "\${3:-}" == "get" && "\${4:-}" == "configmap" ]]; then
762
+ exit 0
763
+ fi
764
+
765
+ if [[ "\${1:-}" == "-n" && "\${3:-}" == "get" && "\${4:-}" == "secret" ]]; then
766
+ exit 1
767
+ fi
768
+
769
+ exit 0
770
+ `,
771
+ 'utf8',
772
+ );
773
+ await chmod(fakeKubectl, 0o755);
774
+
775
+ try {
776
+ await execFile(path.join(minikubeRoot, 'scripts/status.sh'), {
777
+ cwd: minikubeRoot,
778
+ env: {
779
+ ...process.env,
780
+ PATH: `${fakeBin}:${process.env.PATH ?? ''}`,
781
+ SUPABASE_PROJECT_DIR: minikubeRoot,
782
+ },
783
+ });
784
+ } catch (error) {
785
+ const failure = error as { stdout?: string; stderr?: string };
786
+ return `${failure.stdout ?? ''}\n${failure.stderr ?? ''}`;
787
+ }
788
+
789
+ throw new Error('Expected generated status script to fail');
790
+ }
791
+
792
+ async function runSupabaseSql(minikubeRoot: string, sql: string): Promise<void> {
793
+ const { stdout } = await execFile('supabase', ['--workdir', minikubeRoot, 'status', '-o', 'env']);
794
+ const dbUrl = readStatusEnv(stdout, 'DB_URL');
795
+ const sqlFile = path.join(minikubeRoot, `integration-${Date.now().toString(36)}.sql`);
796
+ await writeFile(sqlFile, sql, 'utf8');
797
+ await execFile('psql', [dbUrl, '-v', 'ON_ERROR_STOP=1', '-q', '-f', sqlFile]);
798
+ await rm(sqlFile, { force: true });
799
+ }
800
+
801
+ function readStatusEnv(source: string, key: string): string {
802
+ const line = source
803
+ .split('\n')
804
+ .find(
805
+ (entry) =>
806
+ entry === `${key}=` || entry.startsWith(`${key}=`) || entry.startsWith(`export ${key}=`),
807
+ );
808
+ if (!line) {
809
+ throw new Error(`Missing ${key} from supabase status output`);
810
+ }
811
+ return line
812
+ .replace(/^export /, '')
813
+ .slice(`${key}=`.length)
814
+ .replace(/^"|"$/g, '');
815
+ }
816
+
817
+ function createManifest(args: {
818
+ fields: readonly string[];
819
+ table?: string;
820
+ createStrategy?: 'trigger' | 'api';
821
+ }): InfraManifestInput {
822
+ const table = Object.hasOwn(args, 'table') ? args.table : 'profiles';
823
+ return {
824
+ deployment: {
825
+ target: 'minikube',
826
+ monitoring: false,
827
+ },
828
+ auth: {
829
+ scope: 'global',
830
+ provider: 'supabase',
831
+ authorization: {
832
+ kind: 'RBAC',
833
+ engine: 'native',
834
+ },
835
+ ...(table
836
+ ? {
837
+ profile: {
838
+ table,
839
+ fields: args.fields,
840
+ primaryKey: 'authUserId',
841
+ createStrategy: args.createStrategy ?? 'trigger',
842
+ updateStrategy: 'api',
843
+ },
844
+ }
845
+ : {
846
+ profile: {
847
+ fields: args.fields,
848
+ },
849
+ }),
850
+ },
851
+ database: {
852
+ provider: 'supabase',
853
+ tier: 'dev',
854
+ },
855
+ networking: {
856
+ cdn: false,
857
+ },
858
+ plugins: [],
859
+ };
860
+ }