@geekmidas/cloud 10.0.0-alpha.10 → 10.0.0-alpha.101

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 (48) hide show
  1. package/README.md +14 -6
  2. package/dist/index.cjs +3 -3
  3. package/dist/index.d.cts +1 -1
  4. package/dist/index.d.mts +1 -1
  5. package/dist/index.mjs +2 -3
  6. package/dist/utils/index.cjs +106 -5
  7. package/dist/utils/index.cjs.map +1 -0
  8. package/dist/utils/index.d.cts +62 -2
  9. package/dist/utils/index.d.cts.map +1 -0
  10. package/dist/utils/index.d.mts +62 -2
  11. package/dist/utils/index.d.mts.map +1 -0
  12. package/dist/utils/index.mjs +78 -2
  13. package/dist/utils/index.mjs.map +1 -0
  14. package/package.json +9 -8
  15. package/src/dokploy/Application.ts +52 -5
  16. package/src/dokploy/__tests__/Application.provider.spec.ts +206 -0
  17. package/src/sst/__tests__/api.spec.ts +185 -0
  18. package/src/sst/__tests__/encryption.spec.ts +90 -0
  19. package/src/sst/__tests__/fromManifest.integration.spec.ts +427 -0
  20. package/src/sst/__tests__/fromManifest.spec.ts +306 -5
  21. package/src/sst/__tests__/function.spec.ts +121 -0
  22. package/src/sst/__tests__/links.spec.ts +209 -0
  23. package/src/sst/__tests__/surfaces.spec.ts +17 -0
  24. package/src/sst/__type-tests__/overrides.type-test.ts +152 -0
  25. package/src/sst/aws/Api.ts +16 -44
  26. package/src/sst/aws/Cache.ts +4 -4
  27. package/src/sst/aws/Credential.ts +42 -9
  28. package/src/sst/aws/Cron.ts +5 -45
  29. package/src/sst/aws/Database.ts +15 -1
  30. package/src/sst/aws/DatabaseBootstrap.ts +3 -0
  31. package/src/sst/aws/Encryption.ts +91 -0
  32. package/src/sst/aws/ExternalApi.ts +58 -0
  33. package/src/sst/aws/Function.ts +9 -38
  34. package/src/sst/aws/Queue.ts +34 -1
  35. package/src/sst/aws/RestApiSurface.ts +36 -14
  36. package/src/sst/aws/bootstrap/handler.ts +10 -1
  37. package/src/sst/fromManifest.ts +261 -44
  38. package/src/sst/index.ts +6 -1
  39. package/src/sst/overrides.ts +133 -0
  40. package/dist/index-B5CZ1xVf.d.cts +0 -64
  41. package/dist/index-B5CZ1xVf.d.cts.map +0 -1
  42. package/dist/index-DhHRjduZ.d.mts +0 -64
  43. package/dist/index-DhHRjduZ.d.mts.map +0 -1
  44. package/dist/utils-B1a2UuEO.cjs +0 -129
  45. package/dist/utils-B1a2UuEO.cjs.map +0 -1
  46. package/dist/utils-DMOXJ27j.mjs +0 -83
  47. package/dist/utils-DMOXJ27j.mjs.map +0 -1
  48. package/src/sst/__type-tests__/manifest.type-test.ts +0 -77
@@ -1,13 +1,24 @@
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
+ import { App } from '../App';
5
+ import { Cron } from '../aws/Cron';
6
+ import { Database } from '../aws/Database';
7
+ import { DatabaseSchema } from '../aws/DerivedDatabase';
8
+ import { Function } from '../aws/Function';
4
9
  import { ObjectStorage } from '../aws/ObjectStorage';
10
+ import { RestApiSurface } from '../aws/RestApiSurface';
5
11
  import { type ProvidesMismatch, UnknownDeclarationKind } from '../errors';
6
12
  import {
7
13
  assertProvides,
14
+ mountRoutes,
15
+ PROVISIONED_ELSEWHERE,
8
16
  type ProvisionedManifest,
17
+ provisionCompute,
9
18
  provisionerFor,
10
19
  resolveEdges,
20
+ subscribeConsumers,
21
+ vpcFor,
11
22
  } from '../fromManifest';
12
23
 
13
24
  /**
@@ -249,18 +260,308 @@ describe('the key a provided role becomes', () => {
249
260
  describe('Credential', () => {
250
261
  it('provides under the role the declaration named', async () => {
251
262
  // The role *is* the contract: providing `value` against a declared
252
- // `STRIPE_CREDENTIAL` supplies `STRIPE_VALUE`, and assertProvides
263
+ // `STRIPE_CREDENTIALS` supplies `STRIPE_VALUE`, and assertProvides
253
264
  // rejects the stack at synth. Which is the check working.
254
265
  const { Credential } = await import('../aws/Credential');
255
266
 
256
267
  expect(
257
268
  Object.keys(new Credential({} as never, 'Stripe').provides()),
258
- ).toEqual(['credential']);
269
+ ).toEqual(['credentials']);
259
270
  });
260
271
 
261
272
  it('agrees with what the construct declares', () => {
262
- expect(providedKeyFor('Stripe', 'credential', 'credential')).toBe(
263
- 'STRIPE_CREDENTIAL',
273
+ expect(providedKeyFor('Stripe', 'credential', 'credentials')).toBe(
274
+ 'STRIPE_CREDENTIALS',
264
275
  );
265
276
  });
277
+
278
+ it('links under the key the construct reads, not the secret’s bare name', async () => {
279
+ const { Credential } = await import('../aws/Credential');
280
+ const stripe = new Credential({} as never, 'Stripe');
281
+
282
+ expect(resolveEnvKeys({ Stripe: { type: stripe._type } })).toEqual([
283
+ 'STRIPE_CREDENTIALS',
284
+ ]);
285
+ expect(stripe.getSSTLink().properties).toEqual(stripe.provides());
286
+ });
287
+
288
+ it('is what a function depending on it is linked to', async () => {
289
+ const { Credential } = await import('../aws/Credential');
290
+ const stripe = new Credential({} as never, 'Stripe');
291
+
292
+ const { link, envKeys } = resolveEdges(
293
+ [{ target: 'Stripe', kind: 'credential' }],
294
+ { Stripe: stripe },
295
+ );
296
+
297
+ expect(link).toEqual([stripe]);
298
+ expect(envKeys).toEqual(['STRIPE_CREDENTIALS']);
299
+ });
300
+ });
301
+
302
+ describe('ExternalApi', () => {
303
+ const stack = (stage: string) => ({ stage }) as never;
304
+
305
+ it('provides the URL for its stage and its credentials, under the declared keys', async () => {
306
+ const { ExternalApi } = await import('../aws/ExternalApi');
307
+ const payfast = new ExternalApi(stack('production'), 'PayFast', {
308
+ url: {
309
+ production: 'https://www.payfast.co.za',
310
+ default: 'https://sandbox.payfast.co.za',
311
+ },
312
+ });
313
+
314
+ expect(payfast.provides().url).toBe('https://www.payfast.co.za');
315
+ expect(
316
+ Object.keys(payfast.provides()).map((role) =>
317
+ providedKeyFor('PayFast', 'external-api', role),
318
+ ),
319
+ ).toEqual(['PAY_FAST_URL', 'PAY_FAST_CREDENTIALS']);
320
+ });
321
+
322
+ it('falls back to the default URL for a stage it does not name', async () => {
323
+ const { ExternalApi } = await import('../aws/ExternalApi');
324
+ const payfast = new ExternalApi(stack('staging'), 'PayFast', {
325
+ url: {
326
+ production: 'https://www.payfast.co.za',
327
+ default: 'https://sandbox.payfast.co.za',
328
+ },
329
+ });
330
+
331
+ expect(payfast.provides().url).toBe('https://sandbox.payfast.co.za');
332
+ });
333
+
334
+ it('fails synth for a stage it has no URL for', async () => {
335
+ const { ExternalApi } = await import('../aws/ExternalApi');
336
+
337
+ expect(
338
+ () =>
339
+ new ExternalApi(stack('staging'), 'PayFast', {
340
+ url: { production: 'https://www.payfast.co.za' },
341
+ }),
342
+ ).toThrow(NoUrlForStage);
343
+ });
344
+
345
+ it('flattens into the same keys at runtime', () => {
346
+ expect(
347
+ resolveEnvKeys({ PayFast: { type: 'gkm:aws:ExternalApi' } }).sort(),
348
+ ).toEqual(['PAY_FAST_CREDENTIALS', 'PAY_FAST_URL']);
349
+ });
350
+ });
351
+
352
+ describe('a queue’s one consumer', () => {
353
+ it('is subscribed from the handler the build wrote, linked to what it depends on', () => {
354
+ const consumed: { handler: string; link?: unknown[] }[] = [];
355
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
356
+ const provisioned = {
357
+ Uploads: uploads,
358
+ Emails: {
359
+ _id: 'Emails',
360
+ _type: 'sst.aws.Queue',
361
+ consume: (consumer: { handler: string; link?: unknown[] }) =>
362
+ consumed.push(consumer),
363
+ },
364
+ } as unknown as ProvisionedManifest;
365
+
366
+ subscribeConsumers(
367
+ {
368
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
369
+ Emails: {
370
+ kind: 'queue',
371
+ id: 'Emails',
372
+ provides: ['EMAILS_PUBLISHER_CONNECTION_STRING'],
373
+ worker: {
374
+ id: 'EmailsWorker',
375
+ handler: 'apps/api/.gkm/aws/queues/emails.handler',
376
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
377
+ },
378
+ },
379
+ } as never,
380
+ provisioned,
381
+ );
382
+
383
+ expect(consumed).toEqual([
384
+ { handler: 'apps/api/.gkm/aws/queues/emails.handler', link: [uploads] },
385
+ ]);
386
+ });
387
+
388
+ it('subscribes through SST, with the queue itself linked', async () => {
389
+ const { Queue } = await import('../aws/Queue');
390
+ const queue = new Queue({} as never, 'Emails');
391
+
392
+ queue.consume({ handler: 'emails.handler', link: [], batchSize: 5 });
393
+
394
+ const [{ subscriber, args }] = (
395
+ queue as unknown as { subscribed: { subscriber: any; args: unknown }[] }
396
+ ).subscribed;
397
+ expect(subscriber.handler).toBe('emails.handler');
398
+ expect(subscriber.link).toEqual([queue]);
399
+ expect(args).toEqual({ batch: { size: 5 } });
400
+ });
401
+ });
402
+
403
+ describe('a surface’s endpoints', () => {
404
+ const routesOf = (surface: RestApiSurface) =>
405
+ (
406
+ surface as unknown as {
407
+ routes: { key: string; handler: any; args: unknown }[];
408
+ }
409
+ ).routes;
410
+
411
+ it('are mounted from the handlers the build wrote, each linked to what it depends on', () => {
412
+ // An API Gateway with no routes 404s everything — which is what a deploy
413
+ // produced while the routes sat in the manifest with nothing reading them.
414
+ const api = new RestApiSurface({} as never, 'Api');
415
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
416
+
417
+ mountRoutes(
418
+ {
419
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
420
+ Api: {
421
+ kind: 'rest-api',
422
+ id: 'Api',
423
+ provides: ['API_URL'],
424
+ endpoints: [
425
+ {
426
+ id: 'ApiGET/users/{id}',
427
+ method: 'GET',
428
+ path: '/users/{id}',
429
+ handler: 'apps/api/.gkm/aws/routes/getUser.handler',
430
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
431
+ authorizer: 'none',
432
+ },
433
+ {
434
+ id: 'ApiGET/health',
435
+ method: 'GET',
436
+ path: '/health',
437
+ handler: 'apps/api/.gkm/aws/routes/health.handler',
438
+ dependencies: [],
439
+ },
440
+ ],
441
+ },
442
+ } as never,
443
+ { Uploads: uploads, Api: api } as unknown as ProvisionedManifest,
444
+ );
445
+
446
+ const [getUser, health] = routesOf(api);
447
+ expect(getUser?.key).toBe('GET /users/{id}');
448
+ expect(getUser?.handler.handler).toBe(
449
+ 'apps/api/.gkm/aws/routes/getUser.handler',
450
+ );
451
+ expect(getUser?.handler.link).toEqual([uploads]);
452
+ expect(getUser?.args).toEqual({});
453
+ // Least privilege: an endpoint is linked to its own edges, not the app's.
454
+ expect(health?.key).toBe('GET /health');
455
+ expect(health?.handler.link).toEqual([]);
456
+ });
457
+
458
+ it('leaves IAM to the gateway, and every other authorizer to the handler', () => {
459
+ const api = new RestApiSurface({} as never, 'Api');
460
+
461
+ api.mount({
462
+ method: 'POST',
463
+ path: '/x',
464
+ handler: 'x.handler',
465
+ authorizer: 'iam',
466
+ });
467
+ api.mount({
468
+ method: 'POST',
469
+ path: '/y',
470
+ handler: 'y.handler',
471
+ authorizer: 'session',
472
+ });
473
+
474
+ const [x, y] = routesOf(api);
475
+ expect(x?.args).toEqual({ auth: { iam: true } });
476
+ expect(y?.args).toEqual({});
477
+ });
478
+
479
+ it('runs a handler that reaches a database inside the database’s network', () => {
480
+ const vpc = { id: 'stub-vpc' } as never;
481
+ const cluster = new Database({} as never, 'Orders', { vpc });
482
+ const tenant = new DatabaseSchema('Billing', cluster, 'billing');
483
+
484
+ expect(vpcFor([cluster as never])).toBe(vpc);
485
+ // A schema tenant lives in its parent's cluster, so in its network too.
486
+ expect(vpcFor([{ _id: 'Uploads' } as never, tenant as never])).toBe(vpc);
487
+ expect(vpcFor([{ _id: 'Uploads' } as never])).toBeUndefined();
488
+ });
489
+ });
490
+
491
+ describe('functions and crons', () => {
492
+ const stack = new App({
493
+ name: 'shop',
494
+ stage: 'prod',
495
+ domain: 'example.com',
496
+ hostedZoneId: 'Z',
497
+ region: 'eu-west-1',
498
+ }).stack('jobs');
499
+ const argsOf = (component: unknown) =>
500
+ (component as { args: Record<string, any> }).args;
501
+
502
+ it('are deployed from the declarations, each linked to its own edges', () => {
503
+ // The `manifest.crons` / `manifest.functions` tables they were built from
504
+ // are gone: the declarations carry the handler, schedule and edges.
505
+ const vpc = { id: 'stub-vpc' } as never;
506
+ const orders = new Database({} as never, 'Orders', { vpc });
507
+ const uploads = { _id: 'Uploads', _type: 'sst.aws.Bucket' };
508
+ const provisioned = {
509
+ Orders: orders,
510
+ Uploads: uploads,
511
+ } as unknown as ProvisionedManifest;
512
+
513
+ const [digest] = provisionCompute(
514
+ stack,
515
+ {
516
+ Orders: { kind: 'database', id: 'Orders' },
517
+ Uploads: { kind: 'objects', id: 'Uploads', provides: ['UPLOADS_URL'] },
518
+ Invoice: {
519
+ kind: 'function',
520
+ id: 'Invoice',
521
+ handler: 'apps/api/.gkm/aws/functions/invoice.handler',
522
+ dependencies: [{ target: 'Uploads', kind: 'objects' }],
523
+ provides: ['INVOICE_URL'],
524
+ },
525
+ Digest: {
526
+ kind: 'cron',
527
+ id: 'Digest',
528
+ handler: 'apps/api/.gkm/aws/crons/digest.handler',
529
+ schedule: 'rate(1 day)',
530
+ dependencies: [{ target: 'Orders', kind: 'database' }],
531
+ },
532
+ } as never,
533
+ provisioned,
534
+ );
535
+
536
+ const invoice = provisioned.Invoice as unknown as Function;
537
+ expect(invoice).toBeInstanceOf(Function);
538
+ expect(argsOf(invoice)).toMatchObject({
539
+ name: stack.logicalPrefixedName('Invoice'),
540
+ handler: 'apps/api/.gkm/aws/functions/invoice.handler',
541
+ link: [uploads],
542
+ url: { authorization: 'iam' },
543
+ });
544
+ expect(argsOf(invoice).vpc).toBeUndefined();
545
+
546
+ // A cron is a schedule, not something to link to; its Lambda reaches
547
+ // the database, so it runs in the database's network.
548
+ expect(provisioned.Digest).toBeUndefined();
549
+ expect(digest).toBeInstanceOf(Cron);
550
+ expect(argsOf(digest).schedule).toBe('rate(1 day)');
551
+ expect(argsOf(digest!.processor)).toMatchObject({
552
+ handler: 'apps/api/.gkm/aws/crons/digest.handler',
553
+ link: [orders],
554
+ vpc,
555
+ });
556
+ });
557
+ });
558
+
559
+ describe('kinds provisioned elsewhere', () => {
560
+ it('skips a worker, which is a process rather than a resource', () => {
561
+ // A worker in the manifest used to throw UnknownDeclarationKind at synth,
562
+ // so any app with background work could not be deployed to AWS at all.
563
+ expect(PROVISIONED_ELSEWHERE.has('worker')).toBe(true);
564
+ expect(PROVISIONED_ELSEWHERE.has('mobile-app')).toBe(true);
565
+ expect(PROVISIONED_ELSEWHERE.has('queue')).toBe(false);
566
+ });
266
567
  });
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { App } from '../App';
3
+ import { Cron } from '../aws/Cron';
4
+ import { Function } from '../aws/Function';
5
+ import { type GkmLinkable, ResourceType } from '../Linkable';
6
+
7
+ /**
8
+ * A Lambda and a schedule as data: the args each hands SST (a recording stub
9
+ * in `test/sst-globals.ts`), and the env validation that fails before deploy.
10
+ */
11
+
12
+ const app = new App({
13
+ name: 'shop',
14
+ stage: 'prod',
15
+ domain: 'shop.test',
16
+ hostedZoneId: 'Z1',
17
+ region: 'eu-west-1',
18
+ });
19
+ const stack = app.stack('jobs');
20
+ const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
21
+
22
+ const argsOf = (component: unknown) =>
23
+ (component as { args: Record<string, unknown> }).args;
24
+
25
+ describe('Function', () => {
26
+ it('defaults the runtime and logging, and links only what its variables need', () => {
27
+ const fn = new Function(stack, 'Reports', {
28
+ handler: 'reports.handler',
29
+ links: [db],
30
+ envVars: ['DB_URL'],
31
+ environment: { FEATURE: 'on' },
32
+ });
33
+
34
+ expect(fn._type).toBe(ResourceType.Function);
35
+ expect(argsOf(fn)).toMatchObject({
36
+ handler: 'reports.handler',
37
+ runtime: 'nodejs24.x',
38
+ logging: { format: 'json' },
39
+ link: [db],
40
+ environment: expect.objectContaining({
41
+ SERVICE_NAME: 'Reports',
42
+ STAGE: 'prod',
43
+ FEATURE: 'on',
44
+ }),
45
+ });
46
+ expect(fn.validate().valid).toBe(true);
47
+ });
48
+
49
+ it('keeps a runtime and logging it was given', () => {
50
+ const fn = new Function(stack, 'Legacy', {
51
+ handler: 'legacy.handler',
52
+ runtime: 'nodejs20.x',
53
+ logging: { format: 'text' },
54
+ });
55
+
56
+ expect(argsOf(fn)).toMatchObject({
57
+ runtime: 'nodejs20.x',
58
+ logging: { format: 'text' },
59
+ link: [],
60
+ });
61
+ });
62
+
63
+ it('fails the synth on a variable no link provides, unless told not to check', () => {
64
+ expect(
65
+ () =>
66
+ new Function(stack, 'Mailer', {
67
+ handler: 'mailer.handler',
68
+ envVars: ['SMTP_URL'],
69
+ }),
70
+ ).toThrow(/SMTP_URL/);
71
+
72
+ const unchecked = new Function(stack, 'Mailer', {
73
+ handler: 'mailer.handler',
74
+ envVars: ['SMTP_URL'],
75
+ autoValidate: false,
76
+ });
77
+ expect(unchecked.validate()).toMatchObject({
78
+ valid: false,
79
+ invalidVars: ['SMTP_URL'],
80
+ });
81
+ });
82
+ });
83
+
84
+ describe('Cron', () => {
85
+ it('schedules the function it is given, by ARN', () => {
86
+ const processor = { arn: 'arn:aws:lambda:eu-west-1:1:function:nightly' };
87
+ const cron = new Cron(stack, 'Nightly', {
88
+ processor: processor as never,
89
+ schedule: 'rate(1 day)',
90
+ enabled: false,
91
+ });
92
+
93
+ expect(argsOf(cron)).toEqual({
94
+ enabled: false,
95
+ schedule: 'rate(1 day)',
96
+ function: processor.arn,
97
+ });
98
+ });
99
+ });
100
+
101
+ describe('App and Stack', () => {
102
+ it('pick a value for the stage, falling back to the default', () => {
103
+ expect(app.select({ prod: 'live', default: 'test' })).toBe('live');
104
+ expect(stack.select({ staging: 'test', default: 'dev' } as never)).toBe(
105
+ 'dev',
106
+ );
107
+ });
108
+
109
+ it('name subdomains and URLs under the root domain', () => {
110
+ expect(stack.domain).toBe('shop.test');
111
+ expect(stack.getSubdomain('api')).toBe('api.shop.test');
112
+ expect(stack.getURL('api')).toBe('https://api.shop.test');
113
+ expect(stack.getURL()).toBe('https://shop.test');
114
+ expect(app.getURL()).toBe('https://shop.test');
115
+ });
116
+
117
+ it('prefix physical names with the stage, app and stack', () => {
118
+ expect(app.logicalPrefixedName('Uploads')).toBe('prod-shop-uploads');
119
+ expect(stack.logicalPrefixedName('Uploads')).toBe('prod-shop-jobs-uploads');
120
+ });
121
+ });
@@ -0,0 +1,209 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { Cache, CacheNeedsProvider, CacheNeedsVpc } from '../aws/Cache';
3
+ import { Database } from '../aws/Database';
4
+ import { DatabaseReader, DatabaseSchema } from '../aws/DerivedDatabase';
5
+ import { Email } from '../aws/Email';
6
+ import { FileServer } from '../aws/FileServer';
7
+ import { Queue } from '../aws/Queue';
8
+ import { RestApiSurface } from '../aws/RestApiSurface';
9
+ import { Secret } from '../aws/Secret';
10
+ import { StaticSite } from '../aws/StaticSite';
11
+ import { Storage } from '../aws/Storage';
12
+ import { Topic } from '../aws/Topic';
13
+ import { ResourceType } from '../Linkable';
14
+
15
+ /**
16
+ * What a linked function receives from each component, and the type that
17
+ * decides which variables a link provides. `getSSTLink()` is the payload SST
18
+ * injects into every handler linked to the resource; `_type` is what
19
+ * `LinkedEnvironment` keys the variable names off.
20
+ */
21
+
22
+ const stack = {} as never;
23
+ const vpc = { subnets: ['subnet-1'], securityGroups: ['sg-1'] } as never;
24
+
25
+ /** Resolves `$util.output(promise)`, which the stub leaves as the promise. */
26
+ const settle = async (value: unknown) =>
27
+ (value as { apply: (fn: (v: unknown) => unknown) => unknown }).apply(
28
+ (v) => v,
29
+ );
30
+
31
+ describe('link payloads', () => {
32
+ it('a queue adds its ARN and connection string to SST’s own link', () => {
33
+ const queue = new Queue(stack, 'Emails');
34
+
35
+ expect(queue._type).toBe(ResourceType.Queue);
36
+ // The connection string itself is parsed back in provides.spec; here, the
37
+ // link carries exactly what `provides()` resolved.
38
+ expect(queue.getSSTLink().properties).toEqual({
39
+ name: 'Emails',
40
+ arn: queue.arn,
41
+ ...queue.provides(),
42
+ });
43
+ });
44
+
45
+ it('a topic carries its publisher connection string', () => {
46
+ const topic = new Topic(stack, 'Users');
47
+
48
+ expect(topic._type).toBe(ResourceType.SnsTopic);
49
+ expect(topic.getSSTLink().properties).toEqual({
50
+ name: 'Users',
51
+ ...topic.provides(),
52
+ });
53
+ });
54
+
55
+ it('a secret carries its value', () => {
56
+ const secret = new Secret(stack, 'AuthSecret');
57
+
58
+ expect(secret._type).toBe(ResourceType.SSTSecret);
59
+ expect(secret.getSSTLink().properties).toMatchObject(secret.provides());
60
+ });
61
+
62
+ it('a mailer carries its URL and sending identity, and nothing else', () => {
63
+ const email = new Email(stack, 'Mail', {
64
+ backend: 'smtp',
65
+ url: 'smtp://relay.test:587',
66
+ from: 'hello@shop.test',
67
+ } as never);
68
+
69
+ expect(email._type).toBe(ResourceType.Email);
70
+ expect(email.getSSTLink()).toEqual({
71
+ properties: { url: 'smtp://relay.test:587', from: 'hello@shop.test' },
72
+ });
73
+ });
74
+
75
+ it('a file server routes everything to its bucket, and links its URL', () => {
76
+ const origin = new Storage(stack, 'Uploads');
77
+ const server = new FileServer(stack, 'Files', { origin } as never);
78
+
79
+ expect(origin._type).toBe(ResourceType.ObjectStorage);
80
+ expect(origin._id).toBe('Uploads');
81
+ expect(server._type).toBe(ResourceType.FileServer);
82
+ expect((server as unknown as { routed: unknown[] }).routed).toEqual([
83
+ { pattern: '/*', bucket: origin },
84
+ ]);
85
+ expect(server.getSSTLink().properties).toMatchObject({
86
+ url: server.url,
87
+ });
88
+ });
89
+
90
+ it('a site is built the way its variant builds, and links its URL', () => {
91
+ const vite = new StaticSite(stack, 'Web');
92
+ const next = new StaticSite(stack, 'Admin', {
93
+ variant: 'next',
94
+ build: { command: 'pnpm build:custom', output: 'out' },
95
+ } as never);
96
+ const args = (site: unknown) => (site as { args: { build: unknown } }).args;
97
+
98
+ expect(vite._type).toBe(ResourceType.StaticSite);
99
+ expect(args(vite).build).toEqual(expect.objectContaining({}));
100
+ // A build the caller supplied wins over the variant's.
101
+ expect(args(next).build).toEqual({
102
+ command: 'pnpm build:custom',
103
+ output: 'out',
104
+ });
105
+ expect(vite.getSSTLink().properties).toMatchObject({ url: vite.url });
106
+ });
107
+
108
+ it('a surface links its address and, once resolved, who may call it', async () => {
109
+ const bare = new RestApiSurface(stack, 'Api');
110
+ const called = new RestApiSurface(stack, 'Api', {
111
+ callers: Promise.resolve({
112
+ trustedOrigins: 'https://web.shop.test',
113
+ cookieDomain: 'shop.test',
114
+ }),
115
+ });
116
+
117
+ expect(bare._type).toBe(ResourceType.ApiGatewayV2);
118
+ expect(await settle(bare.provides().trustedOrigins)).toBe('');
119
+ expect(await settle(bare.provides().cookieDomain)).toBe('');
120
+
121
+ const link = called.getSSTLink().properties as Record<string, unknown>;
122
+ expect(link.url).toBe(called.url);
123
+ expect(await settle(link.trustedOrigins)).toBe('https://web.shop.test');
124
+ expect(await settle(link.cookieDomain)).toBe('shop.test');
125
+ });
126
+
127
+ it('a database and the nodes derived from it each link only their own URL', () => {
128
+ const cluster = new Database(stack, 'Orders', { vpc, schema: 'app' });
129
+ const reader = new DatabaseReader('OrdersReader', cluster);
130
+ const tenant = new DatabaseSchema('AuthDb', cluster, 'authdb');
131
+
132
+ expect(cluster._type).toBe(ResourceType.SSTPostgres);
133
+ expect(reader._type).toBe(ResourceType.SSTPostgres);
134
+ expect(tenant._type).toBe(ResourceType.SSTPostgres);
135
+ expect(cluster.getSSTLink().properties).toMatchObject({
136
+ url: cluster.provides().url,
137
+ });
138
+ // Least privilege: the reader's link is its URL and not the writer's.
139
+ expect(reader.getSSTLink()).toEqual({
140
+ properties: { url: reader.provides().url },
141
+ });
142
+ expect(tenant.getSSTLink()).toEqual({
143
+ properties: { url: tenant.provides().url },
144
+ });
145
+ });
146
+
147
+ it('a cache given its URL links it as-is', () => {
148
+ const cache = new Cache(stack, 'Sessions', {
149
+ backend: 'db',
150
+ url: 'postgres://db.test/app',
151
+ });
152
+
153
+ expect(cache._type).toBe(ResourceType.Cache);
154
+ expect(cache.getSSTLink()).toEqual({
155
+ properties: { url: 'postgres://db.test/app' },
156
+ });
157
+ });
158
+ });
159
+
160
+ describe('Cache on Upstash', () => {
161
+ /** `sst add upstash` installs this global; here, a recording stand-in. */
162
+ const created: { name: string; args: Record<string, unknown> }[] = [];
163
+ class RedisDatabase {
164
+ readonly endpoint = 'eu1-cache.upstash.io';
165
+ readonly restToken = 'tok/en+1';
166
+ constructor(name: string, args: Record<string, unknown>) {
167
+ created.push({ name, args });
168
+ }
169
+ }
170
+
171
+ it('creates a TLS database and puts its token in the URL', () => {
172
+ (globalThis as Record<string, unknown>).upstash = { RedisDatabase };
173
+ try {
174
+ const defaulted = new Cache(stack, 'Sessions', { backend: 'upstash' });
175
+ new Cache(stack, 'Rates', { backend: 'upstash', region: 'us-east-1' });
176
+
177
+ expect(created).toEqual([
178
+ {
179
+ name: 'SessionsCache',
180
+ args: { databaseName: 'Sessions', region: 'eu-west-1', tls: true },
181
+ },
182
+ {
183
+ name: 'RatesCache',
184
+ args: { databaseName: 'Rates', region: 'us-east-1', tls: true },
185
+ },
186
+ ]);
187
+ // The address and the credential that opens it are one fact.
188
+ expect(defaulted.provides().url).toBe(
189
+ `https://:${encodeURIComponent('tok/en+1')}@eu1-cache.upstash.io`,
190
+ );
191
+ } finally {
192
+ delete (globalThis as Record<string, unknown>).upstash;
193
+ }
194
+ });
195
+ });
196
+
197
+ describe('Cache provisioning refusals', () => {
198
+ it('asks for the Upstash provider when it is not installed', () => {
199
+ expect(() => new Cache(stack, 'Sessions', { backend: 'upstash' })).toThrow(
200
+ CacheNeedsProvider,
201
+ );
202
+ });
203
+
204
+ it('asks for a VPC before creating an ElastiCache', () => {
205
+ expect(
206
+ () => new Cache(stack, 'Sessions', { backend: 'elasticache' }),
207
+ ).toThrow(CacheNeedsVpc);
208
+ });
209
+ });