@wildo-ai/platform-infra-providers 1.1.2

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 (62) hide show
  1. package/LICENSE +34 -0
  2. package/dist/esm/aws-s3/aws-s3.infra.module.d.ts +63 -0
  3. package/dist/esm/aws-s3/aws-s3.infra.module.d.ts.map +1 -0
  4. package/dist/esm/aws-s3/aws-s3.infra.module.js +110 -0
  5. package/dist/esm/aws-s3/aws-s3.infra.module.js.map +1 -0
  6. package/dist/esm/clamav-self/clamav-self.infra.module.d.ts +30 -0
  7. package/dist/esm/clamav-self/clamav-self.infra.module.d.ts.map +1 -0
  8. package/dist/esm/clamav-self/clamav-self.infra.module.js +61 -0
  9. package/dist/esm/clamav-self/clamav-self.infra.module.js.map +1 -0
  10. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts +26 -0
  11. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts.map +1 -0
  12. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js +54 -0
  13. package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js.map +1 -0
  14. package/dist/esm/index.d.ts +88 -0
  15. package/dist/esm/index.d.ts.map +1 -0
  16. package/dist/esm/index.js +125 -0
  17. package/dist/esm/index.js.map +1 -0
  18. package/dist/esm/minio-self/minio-self.infra.module.d.ts +64 -0
  19. package/dist/esm/minio-self/minio-self.infra.module.d.ts.map +1 -0
  20. package/dist/esm/minio-self/minio-self.infra.module.js +105 -0
  21. package/dist/esm/minio-self/minio-self.infra.module.js.map +1 -0
  22. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts +52 -0
  23. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts.map +1 -0
  24. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js +201 -0
  25. package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js.map +1 -0
  26. package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts +81 -0
  27. package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts.map +1 -0
  28. package/dist/esm/mongodb-self/mongodb-self.infra.module.js +311 -0
  29. package/dist/esm/mongodb-self/mongodb-self.infra.module.js.map +1 -0
  30. package/dist/esm/postgres-self/postgres-self.infra.module.d.ts +70 -0
  31. package/dist/esm/postgres-self/postgres-self.infra.module.d.ts.map +1 -0
  32. package/dist/esm/postgres-self/postgres-self.infra.module.js +328 -0
  33. package/dist/esm/postgres-self/postgres-self.infra.module.js.map +1 -0
  34. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts +88 -0
  35. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts.map +1 -0
  36. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js +473 -0
  37. package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js.map +1 -0
  38. package/dist/esm/redis-self/redis-self.infra.module.d.ts +89 -0
  39. package/dist/esm/redis-self/redis-self.infra.module.d.ts.map +1 -0
  40. package/dist/esm/redis-self/redis-self.infra.module.js +331 -0
  41. package/dist/esm/redis-self/redis-self.infra.module.js.map +1 -0
  42. package/dist/tsconfig.build.tsbuildinfo +1 -0
  43. package/package.json +52 -0
  44. package/src/aws-s3/__tests__/aws-s3.infra.module.test.ts +67 -0
  45. package/src/aws-s3/aws-s3.infra.module.ts +120 -0
  46. package/src/clamav-self/__tests__/clamav-self.infra.module.test.ts +65 -0
  47. package/src/clamav-self/clamav-self.infra.module.ts +72 -0
  48. package/src/gotenberg-self/__tests__/gotenberg-self.infra.module.test.ts +65 -0
  49. package/src/gotenberg-self/gotenberg-self.infra.module.ts +65 -0
  50. package/src/index.ts +133 -0
  51. package/src/minio-self/__tests__/minio-self.infra.module.test.ts +70 -0
  52. package/src/minio-self/minio-self.infra.module.ts +116 -0
  53. package/src/mongodb-atlas/__tests__/mongodb-atlas.infra.module.test.ts +198 -0
  54. package/src/mongodb-atlas/mongodb-atlas.infra.module.ts +236 -0
  55. package/src/mongodb-self/__tests__/mongodb-self.infra.module.test.ts +419 -0
  56. package/src/mongodb-self/mongodb-self.infra.module.ts +384 -0
  57. package/src/postgres-self/__tests__/postgres-self.infra.module.test.ts +344 -0
  58. package/src/postgres-self/postgres-self.infra.module.ts +377 -0
  59. package/src/rabbitmq-self/__tests__/rabbitmq-self.infra.module.test.ts +499 -0
  60. package/src/rabbitmq-self/rabbitmq-self.infra.module.ts +531 -0
  61. package/src/redis-self/__tests__/redis-self.infra.module.test.ts +341 -0
  62. package/src/redis-self/redis-self.infra.module.ts +372 -0
@@ -0,0 +1,499 @@
1
+ /**
2
+ * Tests for `RabbitmqSelfInfraProviderModule` (Slice 2 of
3
+ * `infra-provider-planner.md`).
4
+ *
5
+ * Goals:
6
+ * - Pin the `plan()` shape so a future regression that drops e.g.
7
+ * `RABBITMQ_ADMIN_USERNAME` from `secretEnvVars` fails loud.
8
+ * - Pin the orchestrator-pattern contract: `mintAppCredentials`
9
+ * accepts the password via `secretInputs` and echoes it back —
10
+ * proves the module is upsert-only, not generator.
11
+ * - Pin the username + permission-regex conventions against the
12
+ * existing `wildo-application-lifecycle.service.ts:434, 552`
13
+ * conventions so a future code change here surfaces as a regex
14
+ * diff in this test, not as a silent broker-permission drift.
15
+ */
16
+
17
+ import { describe, expect, it, vi } from 'vitest';
18
+ import {
19
+ AppCredentialMintingContext,
20
+ IRabbitMQManagementClient,
21
+ InfraCapability,
22
+ InfraPlatformAdminConsumer,
23
+ InfraProvider,
24
+ } from '@wildo-ai/platform-config-lib';
25
+ import { ApplicationRuntimePrincipalKind, buildRuntimePrincipalQueueSegment } from '@wildo-ai/platform-config-lib';
26
+ import { RabbitmqSelfInfraProviderModule } from '../rabbitmq-self.infra.module';
27
+
28
+ function makeAdminClientStub(): IRabbitMQManagementClient & {
29
+ putUserSpy: ReturnType<typeof vi.fn>;
30
+ setPermissionsSpy: ReturnType<typeof vi.fn>;
31
+ setTopicPermissionsSpy: ReturnType<typeof vi.fn>;
32
+ deleteUserSpy: ReturnType<typeof vi.fn>;
33
+ ensureVhostSpy: ReturnType<typeof vi.fn>;
34
+ } {
35
+ const putUserSpy = vi.fn().mockResolvedValue(undefined);
36
+ const setPermissionsSpy = vi.fn().mockResolvedValue(undefined);
37
+ const setTopicPermissionsSpy = vi.fn().mockResolvedValue(undefined);
38
+ const deleteUserSpy = vi.fn().mockResolvedValue(undefined);
39
+ const ensureVhostSpy = vi.fn().mockResolvedValue(undefined);
40
+ return {
41
+ putUser: putUserSpy,
42
+ setPermissions: setPermissionsSpy,
43
+ setTopicPermissions: setTopicPermissionsSpy,
44
+ deleteUser: deleteUserSpy,
45
+ ensureVhost: ensureVhostSpy,
46
+ putUserSpy,
47
+ setPermissionsSpy,
48
+ setTopicPermissionsSpy,
49
+ deleteUserSpy,
50
+ ensureVhostSpy,
51
+ };
52
+ }
53
+
54
+ describe('RabbitmqSelfInfraProviderModule.plan()', () => {
55
+ const contribution = RabbitmqSelfInfraProviderModule.plan({
56
+ environment: 'local',
57
+ runtime: 'docker-compose',
58
+ backingServiceKey: 'rabbitmq',
59
+ config: {},
60
+ });
61
+
62
+ it('declares the right provider + capability triple', () => {
63
+ expect(RabbitmqSelfInfraProviderModule.provider).toBe(InfraProvider.RABBITMQ_SELF);
64
+ expect(RabbitmqSelfInfraProviderModule.supportedCapabilities).toEqual([
65
+ InfraCapability.QUEUING_SYSTEM,
66
+ ]);
67
+ });
68
+
69
+ it('contributes the rabbitmq compose service + K8s manifest', () => {
70
+ expect(contribution.dockerComposeServices).toEqual(['rabbitmq']);
71
+ expect(contribution.k8sResources).toEqual([
72
+ 'templates/infra/k8s/internal/rabbitmq/deployment.yaml.hbs',
73
+ ]);
74
+ });
75
+
76
+ it('declares platformAdmin (audience-1) for apps-manager + companion only', () => {
77
+ expect(contribution.platformAdmin?.envVars).toEqual({
78
+ RABBITMQ_HOST: 'rabbitmq',
79
+ RABBITMQ_PORT: '5672',
80
+ RABBITMQ_MANAGEMENT_PORT: '15672',
81
+ });
82
+ expect(contribution.platformAdmin?.secretEnvVars).toEqual([
83
+ 'RABBITMQ_ADMIN_USERNAME',
84
+ 'RABBITMQ_ADMIN_PASSWORD',
85
+ ]);
86
+ // Audience-1 only — `crontabs-batches-manager` is platform-service-
87
+ // scoped (audience-2 / LD-8 / Slice 2.5), NOT platform-admin.
88
+ expect(contribution.platformAdmin?.consumers).toEqual([
89
+ InfraPlatformAdminConsumer.APPS_MANAGER,
90
+ InfraPlatformAdminConsumer.COMPANION,
91
+ ]);
92
+ });
93
+
94
+ it('declares appRuntime blob paths for the customer-app pod', () => {
95
+ expect(contribution.appRuntime?.perAppConfigBlobPaths).toEqual({
96
+ 'database.rabbitmq.host': 'rabbitmq',
97
+ 'database.rabbitmq.port': 5672,
98
+ });
99
+ expect(contribution.appRuntime?.perAppSecretBlobPaths).toEqual([
100
+ 'database.rabbitmq.username',
101
+ 'database.rabbitmq.password',
102
+ ]);
103
+ });
104
+ });
105
+
106
+ describe('RabbitmqSelfInfraProviderModule.mintAppCredentials()', () => {
107
+ const baseContext: AppCredentialMintingContext = {
108
+ appSlug: 'wonder-todos',
109
+ // The namespace = `<slug>-<envId>` per
110
+ // `buildApplicationNamespace`. The orchestrator computes it server-side
111
+ // and passes it; modules must use this for resources scoped by
112
+ // namespace (rabbit username, redis ACL keyPattern).
113
+ applicationNamespace: 'wonder-todos-local',
114
+ wildoApplicationId: '697702debf8daa1038db3936',
115
+ adminClient: undefined as unknown,
116
+ secretInputs: { 'database.rabbitmq.password': 'orchestrator-pw-9999' },
117
+ };
118
+
119
+ it('upserts user, permissions, and topic permissions with the right regex shape', async () => {
120
+ const admin = makeAdminClientStub();
121
+ const result = await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
122
+ ...baseContext,
123
+ adminClient: admin,
124
+ });
125
+
126
+ // Username is the application NAMESPACE (`<slug>-<envId>`), not the
127
+ // raw slug — matches the legacy convention at
128
+ // wildo-application-lifecycle.service.ts (the namespace is what
129
+ // `buildApplicationNamespace` produces). Password echoed verbatim
130
+ // from `secretInputs` proves the module is upsert-only.
131
+ expect(admin.putUserSpy).toHaveBeenCalledWith('wonder-todos-local', 'orchestrator-pw-9999', []);
132
+
133
+ // Permissions — vhost is '/', regex is the per-app queue/reply pattern
134
+ // disambiguated by `wildoApplicationId`. `write` widens to allow
135
+ // amq.default + empty string per the legacy convention.
136
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('/', 'wonder-todos-local', {
137
+ configure: '^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*',
138
+ write: '(^$|amq\\.default|^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*)',
139
+ read: '^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*',
140
+ });
141
+
142
+ // Topic permissions — same regex on amq.topic.
143
+ expect(admin.setTopicPermissionsSpy).toHaveBeenCalledWith('/', 'wonder-todos-local', {
144
+ exchange: 'amq.topic',
145
+ write: '^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*',
146
+ read: '^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*',
147
+ });
148
+
149
+ // Result: keyed by JSON paths into Wildo_Application_Secrets,
150
+ // password echoed verbatim (proves upsert-only).
151
+ expect(result).not.toBeNull();
152
+ expect(result!.secretBlobValues).toEqual({
153
+ 'database.rabbitmq.username': 'wonder-todos-local',
154
+ 'database.rabbitmq.password': 'orchestrator-pw-9999',
155
+ });
156
+ });
157
+
158
+ it('throws when the orchestrator did not supply a password', async () => {
159
+ const admin = makeAdminClientStub();
160
+ await expect(
161
+ RabbitmqSelfInfraProviderModule.mintAppCredentials!({
162
+ ...baseContext,
163
+ adminClient: admin,
164
+ secretInputs: {}, // deliberately empty
165
+ }),
166
+ ).rejects.toThrow(/secretInputs is missing 'database\.rabbitmq\.password'/);
167
+ // Module never touched the broker on the missing-input branch.
168
+ expect(admin.putUserSpy).not.toHaveBeenCalled();
169
+ });
170
+
171
+ it('escapes regex metacharacters in wildoApplicationId (defensive)', async () => {
172
+ const admin = makeAdminClientStub();
173
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
174
+ ...baseContext,
175
+ adminClient: admin,
176
+ // Hypothetical id with regex specials. Today's ids are Mongo
177
+ // ObjectIds (hex-only, no specials) but the contract only commits
178
+ // to "stable opaque string", so we guard.
179
+ wildoApplicationId: 'a.b*c',
180
+ });
181
+ expect(admin.setPermissionsSpy.mock.calls[0]![2]).toEqual({
182
+ configure: '^wildo\\.(queue|reply)\\.a\\.b\\*c\\..*',
183
+ write: '(^$|amq\\.default|^wildo\\.(queue|reply)\\.a\\.b\\*c\\..*)',
184
+ read: '^wildo\\.(queue|reply)\\.a\\.b\\*c\\..*',
185
+ });
186
+ });
187
+
188
+ it('Slice 2.5c-3: provisions on the default vhost when applicationConfigInputs.vhost is unset', async () => {
189
+ const admin = makeAdminClientStub();
190
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
191
+ ...baseContext,
192
+ adminClient: admin,
193
+ // No `applicationConfigInputs` → fall back to `/`.
194
+ });
195
+ expect(admin.ensureVhostSpy).not.toHaveBeenCalled();
196
+ expect(admin.setPermissionsSpy.mock.calls[0]![0]).toBe('/');
197
+ expect(admin.setTopicPermissionsSpy.mock.calls[0]![0]).toBe('/');
198
+ });
199
+
200
+ it('Slice 2.5c-3: ensures + provisions on the per-app vhost when applicationConfigInputs.vhost is set', async () => {
201
+ const admin = makeAdminClientStub();
202
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
203
+ ...baseContext,
204
+ adminClient: admin,
205
+ applicationConfigInputs: { vhost: 'wonder-todos' },
206
+ });
207
+ // ensureVhost called BEFORE permissions are set on it.
208
+ expect(admin.ensureVhostSpy).toHaveBeenCalledWith('wonder-todos');
209
+ expect(admin.setPermissionsSpy.mock.calls[0]![0]).toBe('wonder-todos');
210
+ expect(admin.setTopicPermissionsSpy.mock.calls[0]![0]).toBe('wonder-todos');
211
+ });
212
+
213
+ it('Slice 2.5c-3: empty-string vhost falls back to default `/` (defensive — operators sometimes emit empty values)', async () => {
214
+ const admin = makeAdminClientStub();
215
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
216
+ ...baseContext,
217
+ adminClient: admin,
218
+ applicationConfigInputs: { vhost: '' },
219
+ });
220
+ expect(admin.ensureVhostSpy).not.toHaveBeenCalled();
221
+ expect(admin.setPermissionsSpy.mock.calls[0]![0]).toBe('/');
222
+ });
223
+
224
+ // Per-app-vhost cron delivery (`.claude/plans/crontab-publisher-vhost-gap.md`).
225
+ it('per-app vhost: grants the crontabs publisher publish-only access on the app vhost, scoped to the app queue namespace', async () => {
226
+ const admin = makeAdminClientStub();
227
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
228
+ ...baseContext,
229
+ adminClient: admin,
230
+ applicationConfigInputs: { vhost: 'wonder-todos' },
231
+ });
232
+ // A second setPermissions call grants the GLOBAL crontabs publisher user
233
+ // publish access on THIS app's vhost — a per-app vhost is a hard isolation
234
+ // boundary it otherwise cannot reach. Scoped to the app's queue namespace,
235
+ // publish-only: `configure` (passive checkQueue probe) + `write`
236
+ // (amq.default), NO `read` (never consumes).
237
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('wonder-todos', 'platform_crontabs_batches_manager', {
238
+ configure: '^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*',
239
+ write: '(^$|amq\\.default|^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*)',
240
+ read: '',
241
+ });
242
+ // The publisher uses the default exchange, not amq.topic — no topic grant.
243
+ expect(admin.setTopicPermissionsSpy).not.toHaveBeenCalledWith(
244
+ 'wonder-todos',
245
+ 'platform_crontabs_batches_manager',
246
+ expect.anything(),
247
+ );
248
+ });
249
+
250
+ it('default vhost: NO extra crontabs grant (the publisher is already broadly granted on `/`)', async () => {
251
+ const admin = makeAdminClientStub();
252
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
253
+ ...baseContext,
254
+ adminClient: admin,
255
+ // default vhost `/`
256
+ });
257
+ expect(admin.setPermissionsSpy).not.toHaveBeenCalledWith(
258
+ '/',
259
+ 'platform_crontabs_batches_manager',
260
+ expect.anything(),
261
+ );
262
+ });
263
+ });
264
+
265
+ describe('RabbitmqSelfInfraProviderModule per-principal broker users (Step 2 / D17)', () => {
266
+ const baseContext: AppCredentialMintingContext = {
267
+ appSlug: 'wonder-todos',
268
+ applicationNamespace: 'wonder-todos-local',
269
+ wildoApplicationId: '697702debf8daa1038db3936',
270
+ adminClient: undefined as unknown,
271
+ secretInputs: { 'database.rabbitmq.password': 'orchestrator-pw-9999' },
272
+ };
273
+ const MINION = { kind: ApplicationRuntimePrincipalKind.MINION, name: 'marketingScrapper' } as const;
274
+ const APP_REGEX = '^wildo\\.(queue|reply)\\.697702debf8daa1038db3936\\..*';
275
+ const CONSUME_REGEX = '^(wildo\\.queue\\.697702debf8daa1038db3936\\.minion-tick\\.marketingScrapper(\\..*)?'
276
+ + '|wildo\\.reply\\.697702debf8daa1038db3936\\.minion-marketingScrapper\\..*)$';
277
+
278
+ it('admits ONLY the minion\'s tick queue and its OWN reply queue', async () => {
279
+ // The three axes are not interchangeable. `read` governs CONSUMING, and
280
+ // narrowing it is the containment that matters — otherwise a minion can
281
+ // drain the backend's job queue or read RPC replies addressed to it.
282
+ // `configure` and `write` stay app-wide because a minion legitimately
283
+ // declares a delay queue and publishes jobs; `publish` calls `sendToQueue`
284
+ // without asserting, so it needs `write` and nothing more.
285
+ const admin = makeAdminClientStub();
286
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({ ...baseContext, adminClient: admin, principal: MINION });
287
+
288
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('/', 'wonder-todos-local-minion-marketingScrapper', {
289
+ configure: APP_REGEX,
290
+ write: `(^$|amq\\.default|${APP_REGEX})`,
291
+ // Narrowed again, now that reply-queue names carry the owning runtime.
292
+ read: CONSUME_REGEX,
293
+ });
294
+ });
295
+
296
+ it('EXECUTES the consume regex against every queue the runtime actually meets', () => {
297
+ // Executed, not string-compared: the anchored `$` is what stops a SIBLING
298
+ // whose name merely starts with this one's from matching, and only running
299
+ // it proves that.
300
+ //
301
+ // The reply cases are the ones that matter most — they are what the first
302
+ // attempt at this narrowing got wrong, by reasoning about the tick consumer
303
+ // alone and never asking what else the RUNTIME consumes.
304
+ const re = new RegExp(CONSUME_REGEX.replace(/\\\\/g, '\\'));
305
+ const APP = '697702debf8daa1038db3936';
306
+
307
+ // allowed: its own tick queue, and its own reply queue
308
+ expect(re.test(`wildo.queue.${APP}.minion-tick.marketingScrapper`)).toBe(true);
309
+ expect(re.test(`wildo.reply.${APP}.minion-marketingScrapper.local-abc123`)).toBe(true);
310
+
311
+ // refused: the BACKEND's replies (historical segment-less layout)
312
+ expect(re.test(`wildo.reply.${APP}.local-abc123`)).toBe(false);
313
+ // refused: a sibling minion's replies and ticks
314
+ expect(re.test(`wildo.reply.${APP}.minion-other.local-abc123`)).toBe(false);
315
+ expect(re.test(`wildo.queue.${APP}.minion-tick.other`)).toBe(false);
316
+ // refused: the backend's job queue
317
+ expect(re.test(`wildo.queue.${APP}.jobs`)).toBe(false);
318
+ // refused: a name-PREFIX sibling, on both axes
319
+ expect(re.test(`wildo.queue.${APP}.minion-tick.marketingScrapperEvil`)).toBe(false);
320
+ expect(re.test(`wildo.reply.${APP}.minion-marketingScrapperEvil.local-abc123`)).toBe(false);
321
+ });
322
+
323
+ it('leaves the application backend completely unchanged', async () => {
324
+ const admin = makeAdminClientStub();
325
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({ ...baseContext, adminClient: admin });
326
+ expect(admin.putUserSpy).toHaveBeenCalledWith('wonder-todos-local', 'orchestrator-pw-9999', []);
327
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('/', 'wonder-todos-local', expect.objectContaining({ read: APP_REGEX }));
328
+ });
329
+
330
+ it('REVOKES the principal\'s own broker user, never the backend\'s', async () => {
331
+ // Same trap already caught on the Redis side: a revoke deriving the
332
+ // username differently deletes the BACKEND's user while believing it is
333
+ // removing a minion's.
334
+ const admin = makeAdminClientStub();
335
+ await RabbitmqSelfInfraProviderModule.revokeAppCredentials!({ ...baseContext, adminClient: admin, principal: MINION });
336
+ expect(admin.deleteUserSpy).toHaveBeenCalledWith('wonder-todos-local-minion-marketingScrapper');
337
+ expect(admin.deleteUserSpy).not.toHaveBeenCalledWith('wonder-todos-local');
338
+ });
339
+ });
340
+
341
+ describe('RabbitmqSelfInfraProviderModule.mintPlatformServiceCredentials() — Slice 2.5 / LD-8', () => {
342
+ it('upserts a SCOPED user with the service-namespaced regex (default policy)', async () => {
343
+ const admin = makeAdminClientStub();
344
+ const result = await RabbitmqSelfInfraProviderModule.mintPlatformServiceCredentials!({
345
+ serviceName: 'platform_apps_manager',
346
+ adminClient: admin,
347
+ secretInputs: { password: 'svc-pw-1234' },
348
+ });
349
+
350
+ expect(admin.putUserSpy).toHaveBeenCalledWith('platform_apps_manager', 'svc-pw-1234', []);
351
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('/', 'platform_apps_manager', {
352
+ configure: '^wildo\\.platform\\.apps_manager\\..*',
353
+ write: '(^$|amq\\.default|^wildo\\.platform\\.apps_manager\\..*)',
354
+ read: '^wildo\\.platform\\.apps_manager\\..*',
355
+ });
356
+ expect(admin.setTopicPermissionsSpy).toHaveBeenCalledWith('/', 'platform_apps_manager', {
357
+ exchange: 'amq.topic',
358
+ // Same dot-escaped regex as setPermissions — Slice 2.5 unifies
359
+ // the two-caller divergence on this variant.
360
+ write: '^wildo\\.platform\\.apps_manager\\..*',
361
+ read: '^wildo\\.platform\\.apps_manager\\..*',
362
+ });
363
+ expect(result).not.toBeNull();
364
+ expect(result!.username).toBe('platform_apps_manager');
365
+ });
366
+
367
+ it('uses the broader regex for platform_crontabs_batches_manager (special case)', async () => {
368
+ const admin = makeAdminClientStub();
369
+ await RabbitmqSelfInfraProviderModule.mintPlatformServiceCredentials!({
370
+ serviceName: 'platform_crontabs_batches_manager',
371
+ adminClient: admin,
372
+ secretInputs: { password: 'cron-pw-1234' },
373
+ });
374
+
375
+ // Crontab override — covers all per-app queue/reply names so it
376
+ // can fan out scheduled jobs onto every app's queues.
377
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('/', 'platform_crontabs_batches_manager', {
378
+ configure: '^wildo\\.(queue|reply|platform)\\..*',
379
+ write: '(^$|amq\\.default|^wildo\\.(queue|reply|platform)\\..*)',
380
+ read: '^wildo\\.(queue|reply|platform)\\..*',
381
+ });
382
+ });
383
+
384
+ it('uses the service-namespaced regex for platform_file_generator (default policy)', async () => {
385
+ const admin = makeAdminClientStub();
386
+ await RabbitmqSelfInfraProviderModule.mintPlatformServiceCredentials!({
387
+ serviceName: 'platform_file_generator',
388
+ adminClient: admin,
389
+ secretInputs: { password: 'fg-pw-1234' },
390
+ });
391
+
392
+ expect(admin.setPermissionsSpy).toHaveBeenCalledWith('/', 'platform_file_generator', {
393
+ configure: '^wildo\\.platform\\.file_generator\\..*',
394
+ write: '(^$|amq\\.default|^wildo\\.platform\\.file_generator\\..*)',
395
+ read: '^wildo\\.platform\\.file_generator\\..*',
396
+ });
397
+ });
398
+
399
+ it('throws when the orchestrator did not supply a password', async () => {
400
+ const admin = makeAdminClientStub();
401
+ await expect(
402
+ RabbitmqSelfInfraProviderModule.mintPlatformServiceCredentials!({
403
+ serviceName: 'platform_apps_manager',
404
+ adminClient: admin,
405
+ secretInputs: {},
406
+ }),
407
+ ).rejects.toThrow(/secretInputs is missing 'password'/);
408
+ expect(admin.putUserSpy).not.toHaveBeenCalled();
409
+ });
410
+ });
411
+
412
+ describe('RabbitmqSelfInfraProviderModule.revokeAppCredentials()', () => {
413
+ it('deletes the per-app user (no caller wires this in Slice 2 — F6 plan review)', async () => {
414
+ const admin = makeAdminClientStub();
415
+ await RabbitmqSelfInfraProviderModule.revokeAppCredentials!({
416
+ appSlug: 'wonder-todos',
417
+ applicationNamespace: 'wonder-todos-local',
418
+ wildoApplicationId: '697702debf8daa1038db3936',
419
+ adminClient: admin,
420
+ secretInputs: {},
421
+ });
422
+ expect(admin.deleteUserSpy).toHaveBeenCalledWith('wonder-todos-local');
423
+ });
424
+ });
425
+
426
+
427
+ /**
428
+ * The reply-queue segment must be derived from ONE source (2026-08-12).
429
+ *
430
+ * Before this, the broker permission hand-wrote `minion-<name>` while the minion's
431
+ * own AMQP adapter sanitised its runtime key, coupled only by a comment saying the
432
+ * two "mirror" each other. They agreed for `marketingScrapper` and would have
433
+ * diverged for any name carrying a character sanitisation rewrites — at which point
434
+ * the minion consumes a reply queue its permission does not match, gets
435
+ * `403 ACCESS_REFUSED`, and the error names a permission rather than the mismatch.
436
+ *
437
+ * These pins are written so a regression to a hand-written segment reddens even
438
+ * though the common case still passes.
439
+ */
440
+ describe('RabbitmqSelfInfraProviderModule — reply segment derives from the shared builder', () => {
441
+ const APP_ID = '697702debf8daa1038db3936';
442
+
443
+ /**
444
+ * Drive the REAL mint path and return the granted `read` pattern. Reaching the
445
+ * permission through `mintAppCredentials` rather than a test-only export is what
446
+ * keeps this a test of shipped behaviour.
447
+ */
448
+ const readPatternForMinion = async (minionName: string): Promise<RegExp> => {
449
+ const admin = makeAdminClientStub();
450
+ await RabbitmqSelfInfraProviderModule.mintAppCredentials!({
451
+ appSlug: 'wonder-todos',
452
+ applicationNamespace: 'wonder-todos-local',
453
+ wildoApplicationId: APP_ID,
454
+ principal: { kind: ApplicationRuntimePrincipalKind.MINION, name: minionName } as never,
455
+ adminClient: admin,
456
+ secretInputs: { 'database.rabbitmq.password': 'pw' },
457
+ } as never);
458
+ const permissions = admin.setPermissionsSpy.mock.calls[0]![2];
459
+ return new RegExp(permissions.read);
460
+ };
461
+
462
+ it('matches a reply queue named with the segment the ADAPTER would emit', async () => {
463
+ // The adapter builds its queue name as `wildo.reply.<appId>.<segment>.<podId>`
464
+ // with the segment from `buildRuntimePrincipalQueueSegment`. Constructing the
465
+ // name the same way is what makes this a coupling test rather than a
466
+ // restatement of the regex.
467
+ const segment = buildRuntimePrincipalQueueSegment(ApplicationRuntimePrincipalKind.MINION, 'marketingScrapper');
468
+ const pattern = await readPatternForMinion('marketingScrapper');
469
+ expect(pattern.test(`wildo.reply.${APP_ID}.${segment}.pod-7`)).toBe(true);
470
+ });
471
+
472
+ it('⚠️ matches for a name that SANITISATION REWRITES — the latent bug', async () => {
473
+ // `marketing.scrapper` sanitises to `marketing-scrapper` on the adapter side.
474
+ // The old hand-written permission escaped the RAW name, producing
475
+ // `minion-marketing\.scrapper`, which that queue name never matches. This is
476
+ // the case that was broken and silent.
477
+ const name = 'marketing.scrapper';
478
+ const segment = buildRuntimePrincipalQueueSegment(ApplicationRuntimePrincipalKind.MINION, name);
479
+ expect(segment).toBe('minion-marketing-scrapper');
480
+ const pattern = await readPatternForMinion(name);
481
+ expect(pattern.test(`wildo.reply.${APP_ID}.${segment}.pod-7`)).toBe(true);
482
+ });
483
+
484
+ it('still refuses the APPLICATION BACKEND\'s segment-less replies', async () => {
485
+ // The backend keeps the historical layout, and that is the whole reason a
486
+ // minion permission cannot reach its replies. If the builder ever returned a
487
+ // segment for the backend, this reddens.
488
+ expect(buildRuntimePrincipalQueueSegment(ApplicationRuntimePrincipalKind.APPLICATION_BACKEND, 'anything'))
489
+ .toBeUndefined();
490
+ const pattern = await readPatternForMinion('marketingScrapper');
491
+ expect(pattern.test(`wildo.reply.${APP_ID}.pod-7`)).toBe(false);
492
+ });
493
+
494
+ it('still refuses a SIBLING minion\'s replies', async () => {
495
+ const sibling = buildRuntimePrincipalQueueSegment(ApplicationRuntimePrincipalKind.MINION, 'otherMinion');
496
+ const pattern = await readPatternForMinion('marketingScrapper');
497
+ expect(pattern.test(`wildo.reply.${APP_ID}.${sibling}.pod-7`)).toBe(false);
498
+ });
499
+ });