@geekmidas/cloud 10.0.0-alpha.4 → 10.0.0-alpha.41
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/dist/index.cjs +3 -3
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +2 -3
- package/dist/utils/index.cjs +106 -5
- package/dist/utils/index.cjs.map +1 -0
- package/dist/utils/index.d.cts +62 -2
- package/dist/utils/index.d.cts.map +1 -0
- package/dist/utils/index.d.mts +62 -2
- package/dist/utils/index.d.mts.map +1 -0
- package/dist/utils/index.mjs +78 -2
- package/dist/utils/index.mjs.map +1 -0
- package/package.json +7 -7
- package/src/dokploy/Application.ts +52 -5
- package/src/dokploy/__tests__/Application.provider.spec.ts +206 -0
- package/src/sst/__tests__/api.spec.ts +231 -0
- package/src/sst/__tests__/fromManifest.integration.spec.ts +427 -0
- package/src/sst/__tests__/fromManifest.spec.ts +141 -5
- package/src/sst/__tests__/function.spec.ts +192 -0
- package/src/sst/__tests__/links.spec.ts +209 -0
- package/src/sst/aws/Api.ts +16 -1
- package/src/sst/aws/Cache.ts +4 -4
- package/src/sst/aws/Credential.ts +42 -9
- package/src/sst/aws/Database.ts +15 -1
- package/src/sst/aws/DatabaseBootstrap.ts +3 -0
- package/src/sst/aws/ExternalApi.ts +58 -0
- package/src/sst/aws/Queue.ts +31 -1
- package/src/sst/aws/bootstrap/handler.ts +10 -1
- package/src/sst/fromManifest.ts +90 -7
- package/dist/index-B5CZ1xVf.d.cts +0 -64
- package/dist/index-B5CZ1xVf.d.cts.map +0 -1
- package/dist/index-DhHRjduZ.d.mts +0 -64
- package/dist/index-DhHRjduZ.d.mts.map +0 -1
- package/dist/utils-B1a2UuEO.cjs +0 -129
- package/dist/utils-B1a2UuEO.cjs.map +0 -1
- package/dist/utils-DMOXJ27j.mjs +0 -83
- package/dist/utils-DMOXJ27j.mjs.map +0 -1
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ConstructManifest,
|
|
3
|
+
PUBLIC,
|
|
4
|
+
PUBLIC_PREFIX,
|
|
5
|
+
providedKeyFor,
|
|
6
|
+
schemeBase,
|
|
7
|
+
} from '@geekmidas/manifest';
|
|
8
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
9
|
+
import { DatabaseNeedsVpc } from '../aws/Database';
|
|
10
|
+
import { EmailNeedsSender } from '../aws/Email';
|
|
11
|
+
import { CacheIsAmbiguous, ProvidesMismatch } from '../errors';
|
|
12
|
+
import { describeRoutes, fromManifest } from '../fromManifest';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* A whole manifest through the adapter: every kind provisioned in order,
|
|
16
|
+
* each checked against the keys it declared, the site built against its API's
|
|
17
|
+
* address, and each surface told who calls it. SST's components are recording
|
|
18
|
+
* stubs (`test/sst-globals.ts`), so this runs without a deploy.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const stack = {} as never;
|
|
22
|
+
const vpc = { subnets: ['subnet-1'], securityGroups: ['sg-1'] } as never;
|
|
23
|
+
|
|
24
|
+
const keys = (id: string, kind: string, roles: string[]) =>
|
|
25
|
+
roles.map((role) => providedKeyFor(id, kind as never, role));
|
|
26
|
+
const SURFACE = ['url', 'trustedOrigins', 'cookieDomain'];
|
|
27
|
+
|
|
28
|
+
function manifest(): ConstructManifest {
|
|
29
|
+
return {
|
|
30
|
+
Uploads: {
|
|
31
|
+
kind: 'objects',
|
|
32
|
+
id: 'Uploads',
|
|
33
|
+
versioned: true,
|
|
34
|
+
provides: keys('Uploads', 'objects', ['url']),
|
|
35
|
+
},
|
|
36
|
+
Files: {
|
|
37
|
+
kind: 'file-server',
|
|
38
|
+
id: 'Files',
|
|
39
|
+
of: 'Uploads',
|
|
40
|
+
provides: keys('Files', 'file-server', ['url']),
|
|
41
|
+
},
|
|
42
|
+
Emails: {
|
|
43
|
+
kind: 'queue',
|
|
44
|
+
id: 'Emails',
|
|
45
|
+
fifo: true,
|
|
46
|
+
provides: keys('Emails', 'queue', ['publisherConnectionString']),
|
|
47
|
+
// The queue's one consumer, as the build folds it in.
|
|
48
|
+
worker: {
|
|
49
|
+
id: 'EmailsWorker',
|
|
50
|
+
handler: '.gkm/aws-lambda/queues/emails.handler',
|
|
51
|
+
dependencies: [{ target: 'Uploads', kind: 'objects' }],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
Users: {
|
|
55
|
+
kind: 'topic',
|
|
56
|
+
id: 'Users',
|
|
57
|
+
provides: keys('Users', 'topic', ['publisherConnectionString']),
|
|
58
|
+
},
|
|
59
|
+
Orders: {
|
|
60
|
+
kind: 'database',
|
|
61
|
+
id: 'Orders',
|
|
62
|
+
engine: 'postgres',
|
|
63
|
+
provides: keys('Orders', 'database', ['url']),
|
|
64
|
+
},
|
|
65
|
+
OrdersReader: {
|
|
66
|
+
kind: 'database-reader',
|
|
67
|
+
id: 'OrdersReader',
|
|
68
|
+
of: 'Orders',
|
|
69
|
+
provides: keys('OrdersReader', 'database-reader', ['url']),
|
|
70
|
+
},
|
|
71
|
+
Sessions: {
|
|
72
|
+
kind: 'cache',
|
|
73
|
+
id: 'Sessions',
|
|
74
|
+
provides: keys('Sessions', 'cache', ['url']),
|
|
75
|
+
},
|
|
76
|
+
Api: {
|
|
77
|
+
kind: 'rest-api',
|
|
78
|
+
id: 'Api',
|
|
79
|
+
path: 'apps/api',
|
|
80
|
+
endpoints: [
|
|
81
|
+
{
|
|
82
|
+
id: 'ListOrders',
|
|
83
|
+
handler: 'orders.handler',
|
|
84
|
+
method: 'GET',
|
|
85
|
+
path: '/orders',
|
|
86
|
+
dependencies: [{ target: 'Orders', kind: 'database' }],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
id: 'Health',
|
|
90
|
+
handler: 'health.handler',
|
|
91
|
+
method: 'GET',
|
|
92
|
+
path: '/health',
|
|
93
|
+
dependencies: [],
|
|
94
|
+
},
|
|
95
|
+
],
|
|
96
|
+
calls: [{ target: 'Auth', kind: 'rest-api' }],
|
|
97
|
+
provides: keys('Api', 'rest-api', SURFACE),
|
|
98
|
+
},
|
|
99
|
+
Auth: {
|
|
100
|
+
kind: 'rest-api',
|
|
101
|
+
id: 'Auth',
|
|
102
|
+
path: 'apps/auth',
|
|
103
|
+
endpoints: [],
|
|
104
|
+
provides: keys('Auth', 'rest-api', SURFACE),
|
|
105
|
+
},
|
|
106
|
+
Web: {
|
|
107
|
+
kind: 'site',
|
|
108
|
+
id: 'Web',
|
|
109
|
+
variant: 'static',
|
|
110
|
+
app: { path: 'apps/web' },
|
|
111
|
+
dependencies: [
|
|
112
|
+
{ target: 'Api', kind: 'rest-api' },
|
|
113
|
+
{ target: 'Users', kind: 'topic' },
|
|
114
|
+
],
|
|
115
|
+
provides: keys('Web', 'site', ['url']),
|
|
116
|
+
},
|
|
117
|
+
Mail: {
|
|
118
|
+
kind: 'email',
|
|
119
|
+
id: 'Mail',
|
|
120
|
+
provides: keys('Mail', 'email', ['url', 'from']),
|
|
121
|
+
},
|
|
122
|
+
AuthSecret: {
|
|
123
|
+
kind: 'secret',
|
|
124
|
+
id: 'AuthSecret',
|
|
125
|
+
provides: keys('AuthSecret', 'secret', ['value']),
|
|
126
|
+
},
|
|
127
|
+
} as unknown as ConstructManifest;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const overrides = {
|
|
131
|
+
Orders: { vpc },
|
|
132
|
+
Mail: { from: 'hello@shop.test', url: 'smtp://relay.test:587' },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const argsOf = (component: unknown) =>
|
|
136
|
+
(component as { args: Record<string, unknown> }).args;
|
|
137
|
+
|
|
138
|
+
/** A Pulumi-ish input as the value it resolves to. */
|
|
139
|
+
const settle = async (value: unknown): Promise<unknown> =>
|
|
140
|
+
typeof value === 'object' && value && 'apply' in value
|
|
141
|
+
? (value as { apply: (fn: (v: unknown) => unknown) => unknown }).apply(
|
|
142
|
+
(v) => v,
|
|
143
|
+
)
|
|
144
|
+
: value;
|
|
145
|
+
|
|
146
|
+
describe('fromManifest', () => {
|
|
147
|
+
afterEach(() => vi.restoreAllMocks());
|
|
148
|
+
|
|
149
|
+
it('provisions every declaration, each supplying exactly what it declared', () => {
|
|
150
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
151
|
+
|
|
152
|
+
const provisioned = fromManifest(stack, manifest(), overrides, {
|
|
153
|
+
cache: 'db',
|
|
154
|
+
email: 'smtp',
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
expect(Object.keys(provisioned).sort()).toEqual(
|
|
158
|
+
Object.keys(manifest()).sort(),
|
|
159
|
+
);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('maps each declaration’s options onto its component', () => {
|
|
163
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
164
|
+
|
|
165
|
+
const provisioned = fromManifest(stack, manifest(), overrides, {
|
|
166
|
+
cache: 'db',
|
|
167
|
+
email: 'smtp',
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// A bucket something serves lets CloudFront read it; its declared
|
|
171
|
+
// versioning is carried in this provider's word for it.
|
|
172
|
+
expect(argsOf(provisioned.Uploads)).toMatchObject({
|
|
173
|
+
versioning: true,
|
|
174
|
+
access: 'cloudfront',
|
|
175
|
+
});
|
|
176
|
+
expect(argsOf(provisioned.Emails)).toMatchObject({ fifo: true });
|
|
177
|
+
// The declared Postgres major, not whatever the default is that month.
|
|
178
|
+
expect(argsOf(provisioned.Orders)).toMatchObject({ vpc, version: '18' });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('subscribes each queue’s one consumer, linked to the queue and what it reaches', () => {
|
|
182
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
183
|
+
|
|
184
|
+
const provisioned = fromManifest(stack, manifest(), overrides, {
|
|
185
|
+
cache: 'db',
|
|
186
|
+
email: 'smtp',
|
|
187
|
+
});
|
|
188
|
+
const { subscribed } = provisioned.Emails as unknown as {
|
|
189
|
+
subscribed: { subscriber: { handler: string; link: unknown[] } }[];
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
expect(subscribed).toHaveLength(1);
|
|
193
|
+
expect(subscribed[0]?.subscriber.handler).toBe(
|
|
194
|
+
'.gkm/aws-lambda/queues/emails.handler',
|
|
195
|
+
);
|
|
196
|
+
expect(subscribed[0]?.subscriber.link).toEqual([
|
|
197
|
+
provisioned.Emails,
|
|
198
|
+
provisioned.Uploads,
|
|
199
|
+
]);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('builds the site against the addresses its edges resolve to', () => {
|
|
203
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
204
|
+
|
|
205
|
+
const provisioned = fromManifest(stack, manifest(), overrides, {
|
|
206
|
+
cache: 'db',
|
|
207
|
+
});
|
|
208
|
+
const env = argsOf(provisioned.Web).environment as Record<string, unknown>;
|
|
209
|
+
|
|
210
|
+
const apiKey = `${PUBLIC_PREFIX.static}${providedKeyFor('Api', 'rest-api', 'url')}`;
|
|
211
|
+
expect(env[apiKey]).toBe(provisioned.Api!.provides().url);
|
|
212
|
+
// A topic has nothing a browser may see, so the site gets nothing from it.
|
|
213
|
+
expect(PUBLIC.topic ?? []).toEqual([]);
|
|
214
|
+
expect(Object.keys(env)).toEqual([apiKey]);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
it('keeps a db-backed cache in the declared database, in its own table', async () => {
|
|
218
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
219
|
+
|
|
220
|
+
const provisioned = fromManifest(stack, manifest(), overrides, {
|
|
221
|
+
cache: 'db',
|
|
222
|
+
});
|
|
223
|
+
const url = String(await settle(provisioned.Sessions!.provides().url));
|
|
224
|
+
|
|
225
|
+
expect(url).toContain('db.stub.rds.amazonaws.com');
|
|
226
|
+
expect(url).toMatch(/sessions/i);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('tells each surface who calls it once everything exists', async () => {
|
|
230
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
231
|
+
|
|
232
|
+
const provisioned = fromManifest(stack, manifest(), overrides, {
|
|
233
|
+
cache: 'db',
|
|
234
|
+
});
|
|
235
|
+
const origin = (id: string) =>
|
|
236
|
+
new URL(String(provisioned[id]!.provides().url)).origin;
|
|
237
|
+
|
|
238
|
+
// The site depends on the API, so its origin is on the API's list.
|
|
239
|
+
expect(await settle(provisioned.Api!.provides().trustedOrigins)).toContain(
|
|
240
|
+
origin('Web'),
|
|
241
|
+
);
|
|
242
|
+
// The API calls the auth server, so it is on the auth server's list.
|
|
243
|
+
expect(await settle(provisioned.Auth!.provides().trustedOrigins)).toContain(
|
|
244
|
+
origin('Api'),
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it('builds nothing for a mobile app, and has the surfaces it calls trust its scheme', async () => {
|
|
249
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
250
|
+
|
|
251
|
+
const provisioned = fromManifest(
|
|
252
|
+
stack,
|
|
253
|
+
{
|
|
254
|
+
...manifest(),
|
|
255
|
+
App: {
|
|
256
|
+
kind: 'mobile-app',
|
|
257
|
+
id: 'App',
|
|
258
|
+
variant: 'expo',
|
|
259
|
+
app: { path: 'apps/app' },
|
|
260
|
+
dependencies: [{ target: 'Auth', kind: 'rest-api' }],
|
|
261
|
+
provides: ['APP_SCHEME'],
|
|
262
|
+
},
|
|
263
|
+
} as ConstructManifest,
|
|
264
|
+
overrides,
|
|
265
|
+
{ cache: 'db' },
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
// EAS and the stores ship it — this stack has nothing to create for it.
|
|
269
|
+
expect(provisioned.App).toBeUndefined();
|
|
270
|
+
const origins = String(
|
|
271
|
+
await settle(provisioned.Auth!.provides().trustedOrigins),
|
|
272
|
+
).split(',');
|
|
273
|
+
// The bare scheme deployed: the one the store build registers.
|
|
274
|
+
const scheme = `${schemeBase($app.name)}://`;
|
|
275
|
+
expect(origins).toContain(scheme);
|
|
276
|
+
expect(origins).toContain(`${scheme}*`);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('prints every route and exactly what it can reach', () => {
|
|
280
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
281
|
+
|
|
282
|
+
fromManifest(stack, manifest(), overrides, { cache: 'db' });
|
|
283
|
+
|
|
284
|
+
expect(log.mock.calls.map(([line]) => line)).toEqual(
|
|
285
|
+
describeRoutes(manifest()),
|
|
286
|
+
);
|
|
287
|
+
expect(describeRoutes(manifest())).toEqual([
|
|
288
|
+
'Api:',
|
|
289
|
+
' GET /orders → Orders (database)',
|
|
290
|
+
' GET /health → nothing',
|
|
291
|
+
' calls Auth (origin only; grants nothing)',
|
|
292
|
+
'Auth:',
|
|
293
|
+
' (no routes)',
|
|
294
|
+
]);
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
describe('schema tenants', () => {
|
|
298
|
+
const tenants = (roles?: false) =>
|
|
299
|
+
({
|
|
300
|
+
Orders: {
|
|
301
|
+
kind: 'database',
|
|
302
|
+
id: 'Orders',
|
|
303
|
+
engine: 'postgres',
|
|
304
|
+
schema: 'app',
|
|
305
|
+
...(roles === false ? { roles: false } : {}),
|
|
306
|
+
provides: keys('Orders', 'database', ['url']),
|
|
307
|
+
},
|
|
308
|
+
OrdersReader: {
|
|
309
|
+
kind: 'database-reader',
|
|
310
|
+
id: 'OrdersReader',
|
|
311
|
+
of: 'Orders',
|
|
312
|
+
provides: keys('OrdersReader', 'database-reader', ['url']),
|
|
313
|
+
},
|
|
314
|
+
AuthDb: {
|
|
315
|
+
kind: 'database-schema',
|
|
316
|
+
id: 'AuthDb',
|
|
317
|
+
of: 'Orders',
|
|
318
|
+
schema: 'authdb',
|
|
319
|
+
provides: keys('AuthDb', 'database-schema', ['url']),
|
|
320
|
+
},
|
|
321
|
+
// A tenant of a tenant still lives in the one cluster.
|
|
322
|
+
AuditDb: {
|
|
323
|
+
kind: 'database-schema',
|
|
324
|
+
id: 'AuditDb',
|
|
325
|
+
of: 'AuthDb',
|
|
326
|
+
schema: 'audit',
|
|
327
|
+
provides: keys('AuditDb', 'database-schema', ['url']),
|
|
328
|
+
},
|
|
329
|
+
}) as unknown as ConstructManifest;
|
|
330
|
+
|
|
331
|
+
it('gives each tenant its own role, and the reader the database’s read-only one', async () => {
|
|
332
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
333
|
+
|
|
334
|
+
const provisioned = fromManifest(stack, tenants(), { Orders: { vpc } });
|
|
335
|
+
const url = async (id: string) =>
|
|
336
|
+
String(await settle(provisioned[id]!.provides().url));
|
|
337
|
+
|
|
338
|
+
// Each connects as its own role, never the master.
|
|
339
|
+
expect(await url('AuthDb')).toContain('authdb:');
|
|
340
|
+
expect(await url('AuditDb')).toContain('auditdb:');
|
|
341
|
+
expect(await url('OrdersReader')).toContain('orders_reader:');
|
|
342
|
+
expect(await url('Orders')).not.toContain('postgres:stub-password');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('falls back to the master everywhere under roles: false', async () => {
|
|
346
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
347
|
+
|
|
348
|
+
const provisioned = fromManifest(stack, tenants(false), {
|
|
349
|
+
Orders: { vpc },
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
expect(
|
|
353
|
+
String(await settle(provisioned.AuthDb!.provides().url)),
|
|
354
|
+
).toContain('postgres:stub-password');
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
it('takes an existing cache and mailer rather than creating them', async () => {
|
|
359
|
+
vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
360
|
+
const provisioned = fromManifest(
|
|
361
|
+
stack,
|
|
362
|
+
manifest(),
|
|
363
|
+
{
|
|
364
|
+
...overrides,
|
|
365
|
+
Sessions: { url: 'https://:token@cache.upstash.test' },
|
|
366
|
+
Mail: { ...overrides.Mail, region: 'af-south-1' },
|
|
367
|
+
},
|
|
368
|
+
{ cache: 'upstash', email: 'ses' },
|
|
369
|
+
);
|
|
370
|
+
|
|
371
|
+
expect(await settle(provisioned.Sessions!.provides().url)).toBe(
|
|
372
|
+
'https://:token@cache.upstash.test',
|
|
373
|
+
);
|
|
374
|
+
expect(provisioned.Mail!.provides().url).toBe('smtp://relay.test:587');
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
describe('refuses', () => {
|
|
378
|
+
it('a database with no VPC to live in', () => {
|
|
379
|
+
expect(() =>
|
|
380
|
+
fromManifest(
|
|
381
|
+
stack,
|
|
382
|
+
manifest(),
|
|
383
|
+
{ Mail: overrides.Mail },
|
|
384
|
+
{ cache: 'db' },
|
|
385
|
+
),
|
|
386
|
+
).toThrow(DatabaseNeedsVpc);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
it('a mailer with no sending identity', () => {
|
|
390
|
+
expect(() =>
|
|
391
|
+
fromManifest(stack, manifest(), { Orders: { vpc } }, { cache: 'db' }),
|
|
392
|
+
).toThrow(EmailNeedsSender);
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('a db-backed cache when two databases could hold it', () => {
|
|
396
|
+
const two = {
|
|
397
|
+
...manifest(),
|
|
398
|
+
Billing: {
|
|
399
|
+
kind: 'database',
|
|
400
|
+
id: 'Billing',
|
|
401
|
+
engine: 'postgres',
|
|
402
|
+
provides: keys('Billing', 'database', ['url']),
|
|
403
|
+
},
|
|
404
|
+
} as unknown as ConstructManifest;
|
|
405
|
+
|
|
406
|
+
expect(() =>
|
|
407
|
+
fromManifest(
|
|
408
|
+
stack,
|
|
409
|
+
two,
|
|
410
|
+
{ ...overrides, Billing: { vpc } },
|
|
411
|
+
{ cache: 'db' },
|
|
412
|
+
),
|
|
413
|
+
).toThrow(CacheIsAmbiguous);
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
it('a component supplying keys the declaration did not name', () => {
|
|
417
|
+
const drifted = {
|
|
418
|
+
...manifest(),
|
|
419
|
+
Users: { ...manifest().Users, provides: [] },
|
|
420
|
+
} as unknown as ConstructManifest;
|
|
421
|
+
|
|
422
|
+
expect(() =>
|
|
423
|
+
fromManifest(stack, drifted, overrides, { cache: 'db' }),
|
|
424
|
+
).toThrow(ProvidesMismatch);
|
|
425
|
+
});
|
|
426
|
+
});
|
|
427
|
+
});
|
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { resolveEnvKeys } from '@geekmidas/envkit/sst';
|
|
2
|
-
import { providedKeyFor, provideKey } from '@geekmidas/manifest';
|
|
2
|
+
import { NoUrlForStage, providedKeyFor, provideKey } from '@geekmidas/manifest';
|
|
3
3
|
import { describe, expect, it } from 'vitest';
|
|
4
4
|
import { ObjectStorage } from '../aws/ObjectStorage';
|
|
5
5
|
import { type ProvidesMismatch, UnknownDeclarationKind } from '../errors';
|
|
6
6
|
import {
|
|
7
7
|
assertProvides,
|
|
8
|
+
PROVISIONED_ELSEWHERE,
|
|
8
9
|
type ProvisionedManifest,
|
|
9
10
|
provisionerFor,
|
|
10
11
|
resolveEdges,
|
|
12
|
+
subscribeConsumers,
|
|
11
13
|
} from '../fromManifest';
|
|
12
14
|
|
|
13
15
|
/**
|
|
@@ -249,18 +251,152 @@ describe('the key a provided role becomes', () => {
|
|
|
249
251
|
describe('Credential', () => {
|
|
250
252
|
it('provides under the role the declaration named', async () => {
|
|
251
253
|
// The role *is* the contract: providing `value` against a declared
|
|
252
|
-
// `
|
|
254
|
+
// `STRIPE_CREDENTIALS` supplies `STRIPE_VALUE`, and assertProvides
|
|
253
255
|
// rejects the stack at synth. Which is the check working.
|
|
254
256
|
const { Credential } = await import('../aws/Credential');
|
|
255
257
|
|
|
256
258
|
expect(
|
|
257
259
|
Object.keys(new Credential({} as never, 'Stripe').provides()),
|
|
258
|
-
).toEqual(['
|
|
260
|
+
).toEqual(['credentials']);
|
|
259
261
|
});
|
|
260
262
|
|
|
261
263
|
it('agrees with what the construct declares', () => {
|
|
262
|
-
expect(providedKeyFor('Stripe', 'credential', '
|
|
263
|
-
'
|
|
264
|
+
expect(providedKeyFor('Stripe', 'credential', 'credentials')).toBe(
|
|
265
|
+
'STRIPE_CREDENTIALS',
|
|
264
266
|
);
|
|
265
267
|
});
|
|
268
|
+
|
|
269
|
+
it('links under the key the construct reads, not the secret’s bare name', async () => {
|
|
270
|
+
const { Credential } = await import('../aws/Credential');
|
|
271
|
+
const stripe = new Credential({} as never, 'Stripe');
|
|
272
|
+
|
|
273
|
+
expect(resolveEnvKeys({ Stripe: { type: stripe._type } })).toEqual([
|
|
274
|
+
'STRIPE_CREDENTIALS',
|
|
275
|
+
]);
|
|
276
|
+
expect(stripe.getSSTLink().properties).toEqual(stripe.provides());
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('is what a function depending on it is linked to', async () => {
|
|
280
|
+
const { Credential } = await import('../aws/Credential');
|
|
281
|
+
const stripe = new Credential({} as never, 'Stripe');
|
|
282
|
+
|
|
283
|
+
const { link, envKeys } = resolveEdges(
|
|
284
|
+
[{ target: 'Stripe', kind: 'credential' }],
|
|
285
|
+
{ Stripe: stripe },
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(link).toEqual([stripe]);
|
|
289
|
+
expect(envKeys).toEqual(['STRIPE_CREDENTIALS']);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
describe('ExternalApi', () => {
|
|
294
|
+
const stack = (stage: string) => ({ stage }) as never;
|
|
295
|
+
|
|
296
|
+
it('provides the URL for its stage and its credentials, under the declared keys', async () => {
|
|
297
|
+
const { ExternalApi } = await import('../aws/ExternalApi');
|
|
298
|
+
const payfast = new ExternalApi(stack('production'), 'PayFast', {
|
|
299
|
+
url: {
|
|
300
|
+
production: 'https://www.payfast.co.za',
|
|
301
|
+
default: 'https://sandbox.payfast.co.za',
|
|
302
|
+
},
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
expect(payfast.provides().url).toBe('https://www.payfast.co.za');
|
|
306
|
+
expect(
|
|
307
|
+
Object.keys(payfast.provides()).map((role) =>
|
|
308
|
+
providedKeyFor('PayFast', 'external-api', role),
|
|
309
|
+
),
|
|
310
|
+
).toEqual(['PAY_FAST_URL', 'PAY_FAST_CREDENTIALS']);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
it('falls back to the default URL for a stage it does not name', async () => {
|
|
314
|
+
const { ExternalApi } = await import('../aws/ExternalApi');
|
|
315
|
+
const payfast = new ExternalApi(stack('staging'), 'PayFast', {
|
|
316
|
+
url: {
|
|
317
|
+
production: 'https://www.payfast.co.za',
|
|
318
|
+
default: 'https://sandbox.payfast.co.za',
|
|
319
|
+
},
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
expect(payfast.provides().url).toBe('https://sandbox.payfast.co.za');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
it('fails synth for a stage it has no URL for', async () => {
|
|
326
|
+
const { ExternalApi } = await import('../aws/ExternalApi');
|
|
327
|
+
|
|
328
|
+
expect(
|
|
329
|
+
() =>
|
|
330
|
+
new ExternalApi(stack('staging'), 'PayFast', {
|
|
331
|
+
url: { production: 'https://www.payfast.co.za' },
|
|
332
|
+
}),
|
|
333
|
+
).toThrow(NoUrlForStage);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it('flattens into the same keys at runtime', () => {
|
|
337
|
+
expect(
|
|
338
|
+
resolveEnvKeys({ PayFast: { type: 'gkm:aws:ExternalApi' } }).sort(),
|
|
339
|
+
).toEqual(['PAY_FAST_CREDENTIALS', 'PAY_FAST_URL']);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
describe('a queue’s one consumer', () => {
|
|
344
|
+
it('is subscribed from the handler the build wrote, linked to what it depends on', () => {
|
|
345
|
+
const consumed: { handler: string; link?: unknown[] }[] = [];
|
|
346
|
+
const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
|
|
347
|
+
const provisioned = {
|
|
348
|
+
Uploads: uploads,
|
|
349
|
+
Emails: {
|
|
350
|
+
_id: 'Emails',
|
|
351
|
+
_type: 'sst.aws.Queue',
|
|
352
|
+
consume: (consumer: { handler: string; link?: unknown[] }) =>
|
|
353
|
+
consumed.push(consumer),
|
|
354
|
+
},
|
|
355
|
+
} as unknown as ProvisionedManifest;
|
|
356
|
+
|
|
357
|
+
subscribeConsumers(
|
|
358
|
+
{
|
|
359
|
+
Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
|
|
360
|
+
Emails: {
|
|
361
|
+
kind: 'queue',
|
|
362
|
+
id: 'Emails',
|
|
363
|
+
provides: ['EMAILS_PUBLISHER_CONNECTION_STRING'],
|
|
364
|
+
worker: {
|
|
365
|
+
id: 'EmailsWorker',
|
|
366
|
+
handler: '.gkm/aws-lambda/queues/emails.handler',
|
|
367
|
+
dependencies: [{ target: 'Uploads', kind: 'objects' }],
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
} as never,
|
|
371
|
+
provisioned,
|
|
372
|
+
);
|
|
373
|
+
|
|
374
|
+
expect(consumed).toEqual([
|
|
375
|
+
{ handler: '.gkm/aws-lambda/queues/emails.handler', link: [uploads] },
|
|
376
|
+
]);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
it('subscribes through SST, with the queue itself linked', async () => {
|
|
380
|
+
const { Queue } = await import('../aws/Queue');
|
|
381
|
+
const queue = new Queue({} as never, 'Emails');
|
|
382
|
+
|
|
383
|
+
queue.consume({ handler: 'emails.handler', link: [], batchSize: 5 });
|
|
384
|
+
|
|
385
|
+
const [{ subscriber, args }] = (
|
|
386
|
+
queue as unknown as { subscribed: { subscriber: any; args: unknown }[] }
|
|
387
|
+
).subscribed;
|
|
388
|
+
expect(subscriber.handler).toBe('emails.handler');
|
|
389
|
+
expect(subscriber.link).toEqual([queue]);
|
|
390
|
+
expect(args).toEqual({ batch: { size: 5 } });
|
|
391
|
+
});
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
describe('kinds provisioned elsewhere', () => {
|
|
395
|
+
it('skips a worker, which is a process rather than a resource', () => {
|
|
396
|
+
// A worker in the manifest used to throw UnknownDeclarationKind at synth,
|
|
397
|
+
// so any app with background work could not be deployed to AWS at all.
|
|
398
|
+
expect(PROVISIONED_ELSEWHERE.has('worker')).toBe(true);
|
|
399
|
+
expect(PROVISIONED_ELSEWHERE.has('mobile-app')).toBe(true);
|
|
400
|
+
expect(PROVISIONED_ELSEWHERE.has('queue')).toBe(false);
|
|
401
|
+
});
|
|
266
402
|
});
|