@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.
- package/LICENSE +34 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.d.ts +63 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.d.ts.map +1 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.js +110 -0
- package/dist/esm/aws-s3/aws-s3.infra.module.js.map +1 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.d.ts +30 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.d.ts.map +1 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.js +61 -0
- package/dist/esm/clamav-self/clamav-self.infra.module.js.map +1 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts +26 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.d.ts.map +1 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js +54 -0
- package/dist/esm/gotenberg-self/gotenberg-self.infra.module.js.map +1 -0
- package/dist/esm/index.d.ts +88 -0
- package/dist/esm/index.d.ts.map +1 -0
- package/dist/esm/index.js +125 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/minio-self/minio-self.infra.module.d.ts +64 -0
- package/dist/esm/minio-self/minio-self.infra.module.d.ts.map +1 -0
- package/dist/esm/minio-self/minio-self.infra.module.js +105 -0
- package/dist/esm/minio-self/minio-self.infra.module.js.map +1 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts +52 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.d.ts.map +1 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js +201 -0
- package/dist/esm/mongodb-atlas/mongodb-atlas.infra.module.js.map +1 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts +81 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.d.ts.map +1 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.js +311 -0
- package/dist/esm/mongodb-self/mongodb-self.infra.module.js.map +1 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.d.ts +70 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.d.ts.map +1 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.js +328 -0
- package/dist/esm/postgres-self/postgres-self.infra.module.js.map +1 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts +88 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.d.ts.map +1 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js +473 -0
- package/dist/esm/rabbitmq-self/rabbitmq-self.infra.module.js.map +1 -0
- package/dist/esm/redis-self/redis-self.infra.module.d.ts +89 -0
- package/dist/esm/redis-self/redis-self.infra.module.d.ts.map +1 -0
- package/dist/esm/redis-self/redis-self.infra.module.js +331 -0
- package/dist/esm/redis-self/redis-self.infra.module.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +52 -0
- package/src/aws-s3/__tests__/aws-s3.infra.module.test.ts +67 -0
- package/src/aws-s3/aws-s3.infra.module.ts +120 -0
- package/src/clamav-self/__tests__/clamav-self.infra.module.test.ts +65 -0
- package/src/clamav-self/clamav-self.infra.module.ts +72 -0
- package/src/gotenberg-self/__tests__/gotenberg-self.infra.module.test.ts +65 -0
- package/src/gotenberg-self/gotenberg-self.infra.module.ts +65 -0
- package/src/index.ts +133 -0
- package/src/minio-self/__tests__/minio-self.infra.module.test.ts +70 -0
- package/src/minio-self/minio-self.infra.module.ts +116 -0
- package/src/mongodb-atlas/__tests__/mongodb-atlas.infra.module.test.ts +198 -0
- package/src/mongodb-atlas/mongodb-atlas.infra.module.ts +236 -0
- package/src/mongodb-self/__tests__/mongodb-self.infra.module.test.ts +419 -0
- package/src/mongodb-self/mongodb-self.infra.module.ts +384 -0
- package/src/postgres-self/__tests__/postgres-self.infra.module.test.ts +344 -0
- package/src/postgres-self/postgres-self.infra.module.ts +377 -0
- package/src/rabbitmq-self/__tests__/rabbitmq-self.infra.module.test.ts +499 -0
- package/src/rabbitmq-self/rabbitmq-self.infra.module.ts +531 -0
- package/src/redis-self/__tests__/redis-self.infra.module.test.ts +341 -0
- 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
|
+
});
|