@rebasepro/server-postgresql 0.6.1 → 0.7.0

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 (75) hide show
  1. package/package.json +24 -18
  2. package/src/PostgresBackendDriver.ts +65 -44
  3. package/src/PostgresBootstrapper.ts +34 -2
  4. package/src/auth/ensure-tables.ts +56 -1
  5. package/src/auth/services.ts +94 -1
  6. package/src/cli-errors.ts +162 -0
  7. package/src/cli-helpers.ts +183 -0
  8. package/src/cli.ts +198 -251
  9. package/src/schema/auth-default-policies.ts +90 -0
  10. package/src/schema/auth-schema.ts +25 -2
  11. package/src/schema/doctor.ts +2 -4
  12. package/src/schema/generate-drizzle-schema-logic.ts +4 -3
  13. package/src/schema/generate-drizzle-schema.ts +3 -5
  14. package/src/schema/generate-postgres-ddl-logic.ts +524 -0
  15. package/src/schema/generate-postgres-ddl.ts +116 -0
  16. package/src/services/EntityPersistService.ts +10 -8
  17. package/src/services/entityService.ts +28 -3
  18. package/src/utils/pg-error-utils.ts +16 -0
  19. package/src/utils/table-classification.ts +16 -0
  20. package/src/websocket.ts +9 -0
  21. package/test/auth-default-policies.test.ts +89 -0
  22. package/test/cli-helpers-extended.test.ts +324 -0
  23. package/test/cli-helpers.test.ts +59 -0
  24. package/test/connection.test.ts +292 -0
  25. package/test/databasePoolManager.test.ts +289 -0
  26. package/test/doctor-extended.test.ts +443 -0
  27. package/test/e2e/db-e2e.test.ts +293 -0
  28. package/test/e2e/pg-setup.ts +79 -0
  29. package/test/entity-persist-composite-keys.test.ts +451 -0
  30. package/test/generate-postgres-ddl-edge-cases.test.ts +716 -0
  31. package/test/generate-postgres-ddl.test.ts +300 -0
  32. package/test/mfa-service.test.ts +544 -0
  33. package/test/pg-error-utils.test.ts +50 -1
  34. package/test/realtimeService-channels.test.ts +696 -0
  35. package/test/unmapped-tables-safety.test.ts +55 -342
  36. package/vitest.e2e.config.ts +10 -0
  37. package/build-errors.txt +0 -37
  38. package/dist/PostgresAdapter.d.ts +0 -6
  39. package/dist/PostgresBackendDriver.d.ts +0 -110
  40. package/dist/PostgresBootstrapper.d.ts +0 -46
  41. package/dist/auth/ensure-tables.d.ts +0 -10
  42. package/dist/auth/services.d.ts +0 -231
  43. package/dist/cli.d.ts +0 -1
  44. package/dist/collections/PostgresCollectionRegistry.d.ts +0 -47
  45. package/dist/connection.d.ts +0 -65
  46. package/dist/data-transformer.d.ts +0 -55
  47. package/dist/databasePoolManager.d.ts +0 -20
  48. package/dist/history/HistoryService.d.ts +0 -71
  49. package/dist/history/ensure-history-table.d.ts +0 -7
  50. package/dist/index.d.ts +0 -14
  51. package/dist/index.es.js +0 -10803
  52. package/dist/index.es.js.map +0 -1
  53. package/dist/interfaces.d.ts +0 -18
  54. package/dist/schema/auth-schema.d.ts +0 -2149
  55. package/dist/schema/doctor-cli.d.ts +0 -2
  56. package/dist/schema/doctor.d.ts +0 -52
  57. package/dist/schema/generate-drizzle-schema-logic.d.ts +0 -2
  58. package/dist/schema/generate-drizzle-schema.d.ts +0 -1
  59. package/dist/schema/introspect-db-inference.d.ts +0 -5
  60. package/dist/schema/introspect-db-logic.d.ts +0 -118
  61. package/dist/schema/introspect-db.d.ts +0 -1
  62. package/dist/schema/test-schema.d.ts +0 -24
  63. package/dist/services/BranchService.d.ts +0 -47
  64. package/dist/services/EntityFetchService.d.ts +0 -214
  65. package/dist/services/EntityPersistService.d.ts +0 -40
  66. package/dist/services/RelationService.d.ts +0 -98
  67. package/dist/services/entity-helpers.d.ts +0 -38
  68. package/dist/services/entityService.d.ts +0 -110
  69. package/dist/services/index.d.ts +0 -4
  70. package/dist/services/realtimeService.d.ts +0 -220
  71. package/dist/types.d.ts +0 -3
  72. package/dist/utils/drizzle-conditions.d.ts +0 -138
  73. package/dist/utils/pg-array-null-patch.d.ts +0 -16
  74. package/dist/utils/pg-error-utils.d.ts +0 -55
  75. package/dist/websocket.d.ts +0 -11
@@ -0,0 +1,544 @@
1
+ import { NodePgDatabase } from "drizzle-orm/node-postgres";
2
+ import { MfaService } from "../src/auth/services";
3
+
4
+ // Mock the drizzle-orm functions — same pattern as auth-services.test.ts
5
+ jest.mock("drizzle-orm", () => {
6
+ const actual = jest.requireActual("drizzle-orm");
7
+ return {
8
+ ...actual,
9
+ eq: jest.fn((field, value) => ({ field, value, type: "eq" })),
10
+ sql: Object.assign(
11
+ jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
12
+ strings,
13
+ values,
14
+ type: "sql"
15
+ })),
16
+ {
17
+ raw: jest.fn((val: string) => ({ val, type: "sql-raw" })),
18
+ join: jest.fn((parts: unknown[], separator: unknown) => ({
19
+ parts,
20
+ separator,
21
+ type: "sql-join"
22
+ }))
23
+ }
24
+ ),
25
+ relations: jest.fn(() => ({}))
26
+ };
27
+ });
28
+
29
+ describe("MfaService", () => {
30
+ let db: jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
31
+ let mockExecute: jest.Mock;
32
+ let mfaService: MfaService;
33
+
34
+ beforeEach(() => {
35
+ mockExecute = jest.fn().mockResolvedValue({ rows: [] });
36
+
37
+ db = {
38
+ insert: jest.fn(),
39
+ select: jest.fn(),
40
+ update: jest.fn(),
41
+ delete: jest.fn(),
42
+ execute: mockExecute
43
+ } as unknown as jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
44
+
45
+ mfaService = new MfaService(db);
46
+ });
47
+
48
+ // =========================================================================
49
+ // createMfaFactor
50
+ // =========================================================================
51
+ describe("createMfaFactor", () => {
52
+ it("should insert and return factor with all mapped fields", async () => {
53
+ const now = new Date().toISOString();
54
+ mockExecute.mockResolvedValueOnce({
55
+ rows: [
56
+ {
57
+ id: "factor-1",
58
+ user_id: "user-123",
59
+ factor_type: "totp",
60
+ friendly_name: "My Authenticator",
61
+ verified: false,
62
+ created_at: now,
63
+ updated_at: now
64
+ }
65
+ ]
66
+ });
67
+
68
+ const result = await mfaService.createMfaFactor(
69
+ "user-123",
70
+ "totp",
71
+ "encrypted-secret",
72
+ "My Authenticator"
73
+ );
74
+
75
+ expect(mockExecute).toHaveBeenCalledTimes(1);
76
+ expect(result).toEqual({
77
+ id: "factor-1",
78
+ userId: "user-123",
79
+ factorType: "totp",
80
+ friendlyName: "My Authenticator",
81
+ verified: false,
82
+ createdAt: expect.any(Date),
83
+ updatedAt: expect.any(Date)
84
+ });
85
+ });
86
+
87
+ it("should map null friendly_name to undefined", async () => {
88
+ const now = new Date().toISOString();
89
+ mockExecute.mockResolvedValueOnce({
90
+ rows: [
91
+ {
92
+ id: "factor-2",
93
+ user_id: "user-123",
94
+ factor_type: "totp",
95
+ friendly_name: null,
96
+ verified: false,
97
+ created_at: now,
98
+ updated_at: now
99
+ }
100
+ ]
101
+ });
102
+
103
+ const result = await mfaService.createMfaFactor(
104
+ "user-123",
105
+ "totp",
106
+ "encrypted-secret"
107
+ );
108
+
109
+ expect(result.friendlyName).toBeUndefined();
110
+ });
111
+ });
112
+
113
+ // =========================================================================
114
+ // getMfaFactors
115
+ // =========================================================================
116
+ describe("getMfaFactors", () => {
117
+ it("should return all factors for a user", async () => {
118
+ const now = new Date().toISOString();
119
+ mockExecute.mockResolvedValueOnce({
120
+ rows: [
121
+ {
122
+ id: "factor-1",
123
+ user_id: "user-123",
124
+ factor_type: "totp",
125
+ friendly_name: "App1",
126
+ verified: true,
127
+ created_at: now,
128
+ updated_at: now
129
+ },
130
+ {
131
+ id: "factor-2",
132
+ user_id: "user-123",
133
+ factor_type: "totp",
134
+ friendly_name: null,
135
+ verified: false,
136
+ created_at: now,
137
+ updated_at: now
138
+ }
139
+ ]
140
+ });
141
+
142
+ const result = await mfaService.getMfaFactors("user-123");
143
+
144
+ expect(mockExecute).toHaveBeenCalledTimes(1);
145
+ expect(result).toHaveLength(2);
146
+ expect(result[0]).toEqual({
147
+ id: "factor-1",
148
+ userId: "user-123",
149
+ factorType: "totp",
150
+ friendlyName: "App1",
151
+ verified: true,
152
+ createdAt: expect.any(Date),
153
+ updatedAt: expect.any(Date)
154
+ });
155
+ expect(result[1].friendlyName).toBeUndefined();
156
+ });
157
+
158
+ it("should return empty array when user has no factors", async () => {
159
+ mockExecute.mockResolvedValueOnce({ rows: [] });
160
+
161
+ const result = await mfaService.getMfaFactors("user-no-factors");
162
+
163
+ expect(result).toEqual([]);
164
+ });
165
+ });
166
+
167
+ // =========================================================================
168
+ // getMfaFactorById
169
+ // =========================================================================
170
+ describe("getMfaFactorById", () => {
171
+ it("should return factor with secretEncrypted when found", async () => {
172
+ const now = new Date().toISOString();
173
+ mockExecute.mockResolvedValueOnce({
174
+ rows: [
175
+ {
176
+ id: "factor-1",
177
+ user_id: "user-123",
178
+ factor_type: "totp",
179
+ secret_encrypted: "enc-secret",
180
+ friendly_name: "My TOTP",
181
+ verified: true,
182
+ created_at: now,
183
+ updated_at: now
184
+ }
185
+ ]
186
+ });
187
+
188
+ const result = await mfaService.getMfaFactorById("factor-1");
189
+
190
+ expect(result).not.toBeNull();
191
+ expect(result).toEqual({
192
+ id: "factor-1",
193
+ userId: "user-123",
194
+ factorType: "totp",
195
+ secretEncrypted: "enc-secret",
196
+ friendlyName: "My TOTP",
197
+ verified: true,
198
+ createdAt: expect.any(Date),
199
+ updatedAt: expect.any(Date)
200
+ });
201
+ });
202
+
203
+ it("should return null when factor not found", async () => {
204
+ mockExecute.mockResolvedValueOnce({ rows: [] });
205
+
206
+ const result = await mfaService.getMfaFactorById("nonexistent");
207
+
208
+ expect(result).toBeNull();
209
+ });
210
+ });
211
+
212
+ // =========================================================================
213
+ // verifyMfaFactor
214
+ // =========================================================================
215
+ describe("verifyMfaFactor", () => {
216
+ it("should execute UPDATE setting verified=TRUE", async () => {
217
+ mockExecute.mockResolvedValueOnce({ rows: [] });
218
+
219
+ await mfaService.verifyMfaFactor("factor-1");
220
+
221
+ expect(mockExecute).toHaveBeenCalledTimes(1);
222
+ });
223
+ });
224
+
225
+ // =========================================================================
226
+ // deleteMfaFactor
227
+ // =========================================================================
228
+ describe("deleteMfaFactor", () => {
229
+ it("should execute DELETE matching factorId AND userId", async () => {
230
+ mockExecute.mockResolvedValueOnce({ rows: [] });
231
+
232
+ await mfaService.deleteMfaFactor("factor-1", "user-123");
233
+
234
+ expect(mockExecute).toHaveBeenCalledTimes(1);
235
+ });
236
+ });
237
+
238
+ // =========================================================================
239
+ // createMfaChallenge
240
+ // =========================================================================
241
+ describe("createMfaChallenge", () => {
242
+ it("should create a challenge and return mapped fields", async () => {
243
+ const now = new Date().toISOString();
244
+ mockExecute.mockResolvedValueOnce({
245
+ rows: [
246
+ {
247
+ id: "challenge-1",
248
+ factor_id: "factor-1",
249
+ created_at: now,
250
+ verified_at: null,
251
+ ip_address: "192.168.1.1"
252
+ }
253
+ ]
254
+ });
255
+
256
+ const result = await mfaService.createMfaChallenge(
257
+ "factor-1",
258
+ "192.168.1.1"
259
+ );
260
+
261
+ expect(mockExecute).toHaveBeenCalledTimes(1);
262
+ expect(result).toEqual({
263
+ id: "challenge-1",
264
+ factorId: "factor-1",
265
+ createdAt: expect.any(Date),
266
+ verifiedAt: undefined,
267
+ ipAddress: "192.168.1.1"
268
+ });
269
+ });
270
+
271
+ it("should pass 5-minute expiration to the SQL query", async () => {
272
+ const frozenTime = 1700000000000;
273
+ jest.spyOn(Date, "now").mockReturnValue(frozenTime);
274
+
275
+ const nowIso = new Date(frozenTime).toISOString();
276
+ mockExecute.mockResolvedValueOnce({
277
+ rows: [
278
+ {
279
+ id: "challenge-2",
280
+ factor_id: "factor-1",
281
+ created_at: nowIso,
282
+ verified_at: null,
283
+ ip_address: null
284
+ }
285
+ ]
286
+ });
287
+
288
+ await mfaService.createMfaChallenge("factor-1");
289
+
290
+ // The sql tagged template mock receives interpolated values.
291
+ // Find the Date argument passed — it should be exactly 5 min after frozenTime.
292
+ const executeArg = mockExecute.mock.calls[0][0];
293
+ const fiveMinMs = 5 * 60 * 1000;
294
+ const expectedExpiry = new Date(frozenTime + fiveMinMs);
295
+
296
+ // The values array from the mocked sql`` call contains the interpolated params.
297
+ // Values order: [factorId, ipAddress, expiresAt]
298
+ const allValues: unknown[] = executeArg.values ?? [];
299
+ const dateValues = allValues.filter(
300
+ (v: unknown): v is Date => v instanceof Date
301
+ );
302
+ expect(dateValues).toHaveLength(1);
303
+ expect(dateValues[0].getTime()).toBe(expectedExpiry.getTime());
304
+
305
+ jest.restoreAllMocks();
306
+ });
307
+
308
+ it("should handle missing ipAddress by passing null", async () => {
309
+ const now = new Date().toISOString();
310
+ mockExecute.mockResolvedValueOnce({
311
+ rows: [
312
+ {
313
+ id: "challenge-3",
314
+ factor_id: "factor-1",
315
+ created_at: now,
316
+ verified_at: null,
317
+ ip_address: null
318
+ }
319
+ ]
320
+ });
321
+
322
+ const result = await mfaService.createMfaChallenge("factor-1");
323
+
324
+ expect(result.ipAddress).toBeUndefined();
325
+ });
326
+ });
327
+
328
+ // =========================================================================
329
+ // getMfaChallengeById
330
+ // =========================================================================
331
+ describe("getMfaChallengeById", () => {
332
+ it("should return challenge when found", async () => {
333
+ const now = new Date().toISOString();
334
+ mockExecute.mockResolvedValueOnce({
335
+ rows: [
336
+ {
337
+ id: "challenge-1",
338
+ factor_id: "factor-1",
339
+ created_at: now,
340
+ verified_at: null,
341
+ ip_address: "10.0.0.1"
342
+ }
343
+ ]
344
+ });
345
+
346
+ const result = await mfaService.getMfaChallengeById("challenge-1");
347
+
348
+ expect(result).toEqual({
349
+ id: "challenge-1",
350
+ factorId: "factor-1",
351
+ createdAt: expect.any(Date),
352
+ verifiedAt: undefined,
353
+ ipAddress: "10.0.0.1"
354
+ });
355
+ });
356
+
357
+ it("should return null when challenge not found (expired or verified)", async () => {
358
+ mockExecute.mockResolvedValueOnce({ rows: [] });
359
+
360
+ const result = await mfaService.getMfaChallengeById("nonexistent");
361
+
362
+ expect(result).toBeNull();
363
+ });
364
+
365
+ it("should map verified_at when present", async () => {
366
+ const now = new Date().toISOString();
367
+ mockExecute.mockResolvedValueOnce({
368
+ rows: [
369
+ {
370
+ id: "challenge-1",
371
+ factor_id: "factor-1",
372
+ created_at: now,
373
+ verified_at: now,
374
+ ip_address: null
375
+ }
376
+ ]
377
+ });
378
+
379
+ const result = await mfaService.getMfaChallengeById("challenge-1");
380
+
381
+ expect(result).not.toBeNull();
382
+ expect(result!.verifiedAt).toEqual(expect.any(Date));
383
+ });
384
+ });
385
+
386
+ // =========================================================================
387
+ // verifyMfaChallenge
388
+ // =========================================================================
389
+ describe("verifyMfaChallenge", () => {
390
+ it("should execute UPDATE setting verified_at=NOW()", async () => {
391
+ mockExecute.mockResolvedValueOnce({ rows: [] });
392
+
393
+ await mfaService.verifyMfaChallenge("challenge-1");
394
+
395
+ expect(mockExecute).toHaveBeenCalledTimes(1);
396
+ });
397
+ });
398
+
399
+ // =========================================================================
400
+ // createRecoveryCodes
401
+ // =========================================================================
402
+ describe("createRecoveryCodes", () => {
403
+ it("should delete existing codes before inserting new ones", async () => {
404
+ // First call: DELETE existing codes
405
+ // Then one INSERT per code hash
406
+ mockExecute
407
+ .mockResolvedValueOnce({ rows: [] }) // DELETE
408
+ .mockResolvedValueOnce({ rows: [] }) // INSERT code 1
409
+ .mockResolvedValueOnce({ rows: [] }) // INSERT code 2
410
+ .mockResolvedValueOnce({ rows: [] }); // INSERT code 3
411
+
412
+ await mfaService.createRecoveryCodes("user-123", [
413
+ "hash-1",
414
+ "hash-2",
415
+ "hash-3"
416
+ ]);
417
+
418
+ // 1 DELETE + 3 INSERTs = 4 execute calls
419
+ expect(mockExecute).toHaveBeenCalledTimes(4);
420
+ });
421
+
422
+ it("should handle empty code array (just delete)", async () => {
423
+ mockExecute.mockResolvedValueOnce({ rows: [] }); // DELETE only
424
+
425
+ await mfaService.createRecoveryCodes("user-123", []);
426
+
427
+ expect(mockExecute).toHaveBeenCalledTimes(1);
428
+ });
429
+ });
430
+
431
+ // =========================================================================
432
+ // useRecoveryCode
433
+ // =========================================================================
434
+ describe("useRecoveryCode", () => {
435
+ it("should return true when a matching unused code is found and updated", async () => {
436
+ mockExecute.mockResolvedValueOnce({
437
+ rows: [{ id: "code-1" }]
438
+ });
439
+
440
+ const result = await mfaService.useRecoveryCode("user-123", "hash-abc");
441
+
442
+ expect(result).toBe(true);
443
+ });
444
+
445
+ it("should return false when no matching unused code exists", async () => {
446
+ mockExecute.mockResolvedValueOnce({ rows: [] });
447
+
448
+ const result = await mfaService.useRecoveryCode("user-123", "wrong-hash");
449
+
450
+ expect(result).toBe(false);
451
+ });
452
+ });
453
+
454
+ // =========================================================================
455
+ // getUnusedRecoveryCodeCount
456
+ // =========================================================================
457
+ describe("getUnusedRecoveryCodeCount", () => {
458
+ it("should return the count of unused codes", async () => {
459
+ mockExecute.mockResolvedValueOnce({
460
+ rows: [{ count: 5 }]
461
+ });
462
+
463
+ const result = await mfaService.getUnusedRecoveryCodeCount("user-123");
464
+
465
+ expect(result).toBe(5);
466
+ });
467
+
468
+ it("should return 0 when no unused codes exist", async () => {
469
+ mockExecute.mockResolvedValueOnce({
470
+ rows: [{ count: 0 }]
471
+ });
472
+
473
+ const result = await mfaService.getUnusedRecoveryCodeCount("user-123");
474
+
475
+ expect(result).toBe(0);
476
+ });
477
+ });
478
+
479
+ // =========================================================================
480
+ // deleteAllRecoveryCodes
481
+ // =========================================================================
482
+ describe("deleteAllRecoveryCodes", () => {
483
+ it("should execute DELETE for user recovery codes", async () => {
484
+ mockExecute.mockResolvedValueOnce({ rows: [] });
485
+
486
+ await mfaService.deleteAllRecoveryCodes("user-123");
487
+
488
+ expect(mockExecute).toHaveBeenCalledTimes(1);
489
+ });
490
+ });
491
+
492
+ // =========================================================================
493
+ // hasVerifiedMfaFactors
494
+ // =========================================================================
495
+ describe("hasVerifiedMfaFactors", () => {
496
+ it("should return true when user has verified factors", async () => {
497
+ mockExecute.mockResolvedValueOnce({
498
+ rows: [{ count: 2 }]
499
+ });
500
+
501
+ const result = await mfaService.hasVerifiedMfaFactors("user-123");
502
+
503
+ expect(result).toBe(true);
504
+ });
505
+
506
+ it("should return false when user has no verified factors", async () => {
507
+ mockExecute.mockResolvedValueOnce({
508
+ rows: [{ count: 0 }]
509
+ });
510
+
511
+ const result = await mfaService.hasVerifiedMfaFactors("user-no-mfa");
512
+
513
+ expect(result).toBe(false);
514
+ });
515
+
516
+ it("should return true when count is exactly 1", async () => {
517
+ mockExecute.mockResolvedValueOnce({
518
+ rows: [{ count: 1 }]
519
+ });
520
+
521
+ const result = await mfaService.hasVerifiedMfaFactors("user-123");
522
+
523
+ expect(result).toBe(true);
524
+ });
525
+ });
526
+
527
+ // =========================================================================
528
+ // Custom schema name
529
+ // =========================================================================
530
+ describe("custom schema name", () => {
531
+ it("should use custom schema name in qualified table names", async () => {
532
+ const customService = new MfaService(db, "custom_schema");
533
+ mockExecute.mockResolvedValueOnce({ rows: [{ count: 0 }] });
534
+
535
+ await customService.hasVerifiedMfaFactors("user-123");
536
+
537
+ // Verify the sql template uses the custom schema
538
+ const executeCall = mockExecute.mock.calls[0][0];
539
+ // The sql.raw call should have been called with a string containing "custom_schema"
540
+ // We verify indirectly that execute was called (the qualify method formats it)
541
+ expect(mockExecute).toHaveBeenCalledTimes(1);
542
+ });
543
+ });
544
+ });
@@ -1,5 +1,5 @@
1
1
  import { describe, it, expect } from "@jest/globals";
2
- import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage, sanitizeErrorForClient } from "../src/utils/pg-error-utils";
2
+ import { extractPgError, extractCauseMessage, pgErrorToFriendlyMessage, sanitizeErrorForClient, isRoleSwitchingPermissionError } from "../src/utils/pg-error-utils";
3
3
 
4
4
  // Suppress logger output during tests
5
5
  jest.mock("@rebasepro/server-core", () => ({
@@ -218,4 +218,53 @@ describe("pg-error-utils", () => {
218
218
  expect(result.message).toContain("Check server logs");
219
219
  });
220
220
  });
221
+
222
+ describe("isRoleSwitchingPermissionError", () => {
223
+ it("returns true for 42501 with 'permission denied to set role'", () => {
224
+ const pgError = Object.assign(new Error("permission denied to set role \"demo\""), {
225
+ code: "42501"
226
+ });
227
+ expect(isRoleSwitchingPermissionError(pgError)).toBe(true);
228
+ });
229
+
230
+ it("returns true for 42501 with 'must be member of role'", () => {
231
+ const pgError = Object.assign(new Error("must be member of role \"admin\""), {
232
+ code: "42501"
233
+ });
234
+ expect(isRoleSwitchingPermissionError(pgError)).toBe(true);
235
+ });
236
+
237
+ it("returns true when PG error is wrapped in Drizzle cause chain", () => {
238
+ const pgError = Object.assign(new Error("permission denied to set role \"viewer\""), {
239
+ code: "42501"
240
+ });
241
+ const drizzleError = new Error("Query failed");
242
+ (drizzleError as { cause?: unknown }).cause = pgError;
243
+ expect(isRoleSwitchingPermissionError(drizzleError)).toBe(true);
244
+ });
245
+
246
+ it("returns false for 42501 with 'permission denied for table' (table-level)", () => {
247
+ const pgError = Object.assign(new Error("permission denied for table clients"), {
248
+ code: "42501",
249
+ table: "clients"
250
+ });
251
+ expect(isRoleSwitchingPermissionError(pgError)).toBe(false);
252
+ });
253
+
254
+ it("returns false for non-42501 errors", () => {
255
+ const pgError = Object.assign(new Error("relation does not exist"), {
256
+ code: "42P01"
257
+ });
258
+ expect(isRoleSwitchingPermissionError(pgError)).toBe(false);
259
+ });
260
+
261
+ it("returns false for non-PG errors", () => {
262
+ expect(isRoleSwitchingPermissionError(new Error("something random"))).toBe(false);
263
+ });
264
+
265
+ it("returns false for null/undefined", () => {
266
+ expect(isRoleSwitchingPermissionError(null)).toBe(false);
267
+ expect(isRoleSwitchingPermissionError(undefined)).toBe(false);
268
+ });
269
+ });
221
270
  });