@geekmidas/cloud 10.0.0-alpha.2 → 10.0.0-alpha.21

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.
@@ -0,0 +1,206 @@
1
+ import { runInNewContext } from 'node:vm';
2
+ import { runtime } from '@pulumi/pulumi';
3
+ import { HttpResponse, http } from 'msw';
4
+ import { setupServer } from 'msw/node';
5
+ import { afterAll, afterEach, beforeAll, describe, expect, it } from 'vitest';
6
+ import {
7
+ DokployCallFailed,
8
+ applicationProvider as provider,
9
+ } from '../Application';
10
+
11
+ /**
12
+ * The provider against Dokploy's tRPC API, served by MSW: what each lifecycle
13
+ * call sends, and how it reads what comes back — including "already gone".
14
+ */
15
+
16
+ const endpoint = 'https://dokploy.test';
17
+ const inputs = {
18
+ endpoint,
19
+ apiToken: 'token-1',
20
+ name: 'My API',
21
+ projectId: 'proj-1',
22
+ environmentId: 'env-1',
23
+ };
24
+ /** What state holds once the application exists: every later call gets it. */
25
+ const outs = { ...inputs, applicationId: 'app-1', appName: 'my-api' };
26
+
27
+ const server = setupServer();
28
+ beforeAll(() => server.listen({ onUnhandledRequest: 'error' }));
29
+ afterEach(() => server.resetHandlers());
30
+ afterAll(() => server.close());
31
+
32
+ /** Records the one request a call makes, and answers it. */
33
+ function answer(
34
+ method: 'get' | 'post',
35
+ path: string,
36
+ respond: () => Response,
37
+ ): { request: () => Promise<{ body: unknown; headers: Headers }> } {
38
+ let seen: Request | undefined;
39
+ server.use(
40
+ http[method](`${endpoint}/api/trpc/${path}`, ({ request }) => {
41
+ seen = request.clone();
42
+ return respond();
43
+ }),
44
+ );
45
+ return {
46
+ request: async () => ({
47
+ body: seen && method === 'post' ? await seen.json() : undefined,
48
+ headers: seen!.headers,
49
+ }),
50
+ };
51
+ }
52
+
53
+ describe('Dokploy application provider', () => {
54
+ it('creates the application under the name Dokploy derives', async () => {
55
+ const call = answer('post', 'application.create', () =>
56
+ HttpResponse.json({ applicationId: 'app-1', appName: 'my-api' }),
57
+ );
58
+
59
+ const result = await provider.create!(inputs);
60
+
61
+ const { body, headers } = await call.request();
62
+ expect(headers.get('x-api-key')).toBe('token-1');
63
+ expect(body).toEqual({
64
+ name: 'My API',
65
+ projectId: 'proj-1',
66
+ environmentId: 'env-1',
67
+ appName: 'my-api',
68
+ });
69
+ // Pulumi's id is Dokploy's, so state and the Dokploy UI name the same thing.
70
+ expect(result).toEqual({
71
+ id: 'app-1',
72
+ outs: { ...inputs, applicationId: 'app-1', appName: 'my-api' },
73
+ });
74
+ });
75
+
76
+ it('refreshes from the server, and reports one deleted by hand as gone', async () => {
77
+ answer('get', 'application.one', () =>
78
+ HttpResponse.json({ applicationId: 'app-1', appName: 'my-api-2' }),
79
+ );
80
+ expect(await provider.read!('app-1', outs)).toEqual({
81
+ id: 'app-1',
82
+ props: { ...inputs, applicationId: 'app-1', appName: 'my-api-2' },
83
+ });
84
+
85
+ server.resetHandlers();
86
+ answer('get', 'application.one', () => HttpResponse.json(null));
87
+ expect(await provider.read!('app-1', outs)).toEqual({ id: undefined });
88
+ });
89
+
90
+ it('diffs through the same rule preview uses', async () => {
91
+ expect(
92
+ await provider.diff!('app-1', outs, { ...inputs, name: 'Gateway' }),
93
+ ).toMatchObject({ changes: true, replaces: ['name'] });
94
+ });
95
+
96
+ it('renames in place, and reports the app name that follows', async () => {
97
+ const call = answer('post', 'application.update', () =>
98
+ HttpResponse.json({}),
99
+ );
100
+
101
+ const result = await provider.update!('app-1', outs, {
102
+ ...inputs,
103
+ name: 'My Api',
104
+ });
105
+
106
+ expect((await call.request()).body).toEqual({
107
+ applicationId: 'app-1',
108
+ name: 'My Api',
109
+ });
110
+ expect(result).toEqual({
111
+ outs: {
112
+ ...inputs,
113
+ name: 'My Api',
114
+ applicationId: 'app-1',
115
+ appName: 'my-api',
116
+ },
117
+ });
118
+ });
119
+
120
+ it('treats an application that is already gone as deleted', async () => {
121
+ const call = answer('post', 'application.remove', () =>
122
+ HttpResponse.text('no such application', {
123
+ status: 404,
124
+ statusText: 'Not Found',
125
+ }),
126
+ );
127
+
128
+ await expect(provider.delete!('app-1', outs)).resolves.toBeUndefined();
129
+ expect((await call.request()).body).toEqual({ applicationId: 'app-1' });
130
+ });
131
+
132
+ it('fails a delete, or any call, that Dokploy refused for another reason', async () => {
133
+ answer('post', 'application.remove', () =>
134
+ HttpResponse.text('database is locked', {
135
+ status: 500,
136
+ statusText: 'Internal Server Error',
137
+ }),
138
+ );
139
+ await expect(provider.delete!('app-1', outs)).rejects.toThrow(
140
+ DokployCallFailed,
141
+ );
142
+ await expect(provider.delete!('app-1', outs)).rejects.toMatchObject({
143
+ path: 'application.remove',
144
+ status: 500,
145
+ detail: 'database is locked',
146
+ });
147
+
148
+ server.resetHandlers();
149
+ answer(
150
+ 'post',
151
+ 'application.create',
152
+ () => new HttpResponse(null, { status: 401, statusText: 'Unauthorized' }),
153
+ );
154
+ await expect(provider.create!(inputs)).rejects.toMatchObject({
155
+ name: 'DokployCallFailed',
156
+ status: 401,
157
+ });
158
+ });
159
+
160
+ /**
161
+ * What actually runs: Pulumi serialises the provider into state and the
162
+ * deploy runs that text, not this module. Serialisation rewrites
163
+ * `DokployCallFailed` into a plain function that drops `Error.prototype` and
164
+ * never initialises `message` — which the provider host reports — so this
165
+ * runs the serialised provider and checks the error still says something.
166
+ */
167
+ it('still recognises a 404, and reports a failure, once serialised', async () => {
168
+ const serialised = await runtime.serializeFunction(() => provider, {
169
+ isFactoryFunction: true,
170
+ });
171
+ const module = { exports: {} as Record<string, typeof provider> };
172
+ runInNewContext(serialised.text, {
173
+ module,
174
+ exports: module.exports,
175
+ require,
176
+ global: globalThis,
177
+ fetch: (...args: Parameters<typeof fetch>) => fetch(...args),
178
+ Error,
179
+ });
180
+ const deployed = module.exports[serialised.exportName]!;
181
+
182
+ answer('post', 'application.remove', () =>
183
+ HttpResponse.json({}, { status: 404, statusText: 'Not Found' }),
184
+ );
185
+ await expect(deployed.delete!('app-1', outs)).resolves.toBeUndefined();
186
+
187
+ server.resetHandlers();
188
+ answer('post', 'application.remove', () =>
189
+ HttpResponse.text('database is locked', {
190
+ status: 500,
191
+ statusText: 'Internal Server Error',
192
+ }),
193
+ );
194
+ const failure = await deployed.delete!('app-1', outs).catch(
195
+ (error: unknown) => error as Error & { status: number },
196
+ );
197
+ expect(failure).toMatchObject({ name: 'DokployCallFailed', status: 500 });
198
+ expect(failure?.message).toContain(
199
+ 'Dokploy application.remove failed: 500 Internal Server Error — database is locked',
200
+ );
201
+ expect(failure?.stack).toBeTruthy();
202
+ expect(`${failure}`).toMatch(
203
+ /^DokployCallFailed: Dokploy application\.remove failed/,
204
+ );
205
+ });
206
+ });
@@ -0,0 +1,231 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { App } from '../App';
3
+ import { Api, RoutesMissingEnvironment } from '../aws/Api';
4
+ import { type GkmLinkable, ResourceType } from '../Linkable';
5
+
6
+ /**
7
+ * The HTTP API as data: what it asks SST to route, with which authorizer, and
8
+ * which links each route gets. SST's `ApiGatewayV2` is a recording stub
9
+ * (`test/sst-globals.ts`), so no resource is created.
10
+ */
11
+
12
+ const stack = new App({
13
+ name: 'shop',
14
+ stage: 'prod',
15
+ domain: 'shop.test',
16
+ hostedZoneId: 'Z1',
17
+ region: 'eu-west-1',
18
+ }).stack('api');
19
+
20
+ const db: GkmLinkable = { _id: 'db', _type: ResourceType.Postgres };
21
+
22
+ type Recorded = {
23
+ args: Record<string, unknown>;
24
+ routes: { key: string; handler: Record<string, unknown>; args: unknown }[];
25
+ authorizers: Record<string, unknown>[];
26
+ };
27
+ const recorded = (api: Api) => api as unknown as Recorded;
28
+
29
+ describe('Api', () => {
30
+ it('routes each entry to its handler, with only the links it needs', () => {
31
+ const api = new Api(stack, 'Http', {
32
+ root: 'apps/api',
33
+ links: [db],
34
+ environment: { FEATURE: 'on' },
35
+ cors: { allowOrigins: ['https://shop.test'] },
36
+ routes: [
37
+ {
38
+ method: 'GET',
39
+ path: '/orders',
40
+ handler: 'handlers/orders.handler',
41
+ environment: ['DB_URL', 'FEATURE'],
42
+ },
43
+ {
44
+ method: 'GET',
45
+ path: '/health',
46
+ handler: 'handlers/health.handler',
47
+ runtime: 'nodejs22.x',
48
+ timeout: '5 seconds',
49
+ memory: '256 MB',
50
+ },
51
+ ],
52
+ });
53
+ const { args, routes } = recorded(api);
54
+
55
+ expect(api._type).toBe(ResourceType.ApiGatewayV2);
56
+ // Native args pass straight through.
57
+ expect(args).toEqual({ cors: { allowOrigins: ['https://shop.test'] } });
58
+ expect(routes.map((r) => r.key)).toEqual(['GET /orders', 'GET /health']);
59
+
60
+ const [orders, health] = routes;
61
+ expect(orders!.handler).toMatchObject({
62
+ handler: 'apps/api/handlers/orders.handler',
63
+ runtime: 'nodejs24.x',
64
+ link: [db],
65
+ environment: expect.objectContaining({ STAGE: 'prod', FEATURE: 'on' }),
66
+ });
67
+ expect(orders!.args).toBeUndefined();
68
+ // Least privilege: a route that names no link var gets no link.
69
+ expect(health!.handler).toMatchObject({
70
+ link: [],
71
+ runtime: 'nodejs22.x',
72
+ timeout: '5 seconds',
73
+ memory: '256 MB',
74
+ });
75
+ });
76
+
77
+ it('wires each route to the authorizer it names', () => {
78
+ const lambda = { arn: 'arn:aws:lambda:eu-west-1:1:function:authz' };
79
+ const api = new Api(stack, 'Http', {
80
+ authorizers: {
81
+ jwt: {
82
+ issuer: 'https://id.shop.test',
83
+ audiences: ['shop'],
84
+ scopes: ['read'],
85
+ },
86
+ tenant: { handler: lambda as never, payload: '2.0' },
87
+ key: { handler: 'handlers/key.handler' },
88
+ },
89
+ routes: [
90
+ { method: 'GET', path: '/a', handler: 'a.h', authorizer: 'jwt' },
91
+ {
92
+ method: 'GET',
93
+ path: '/b',
94
+ handler: 'b.h',
95
+ authorizer: 'jwt',
96
+ scopes: ['write'],
97
+ },
98
+ { method: 'GET', path: '/c', handler: 'c.h', authorizer: 'tenant' },
99
+ { method: 'GET', path: '/d', handler: 'd.h', authorizer: 'iam' },
100
+ { method: 'GET', path: '/e', handler: 'e.h', authorizer: 'none' },
101
+ { method: 'GET', path: '/f', handler: 'f.h' },
102
+ ],
103
+ } as never);
104
+ const { routes, authorizers } = recorded(api);
105
+
106
+ expect(authorizers).toEqual([
107
+ {
108
+ name: 'jwt',
109
+ jwt: {
110
+ issuer: 'https://id.shop.test',
111
+ audiences: ['shop'],
112
+ identitySource: undefined,
113
+ },
114
+ },
115
+ {
116
+ name: 'tenant',
117
+ // A `Function` construct is passed as its ARN.
118
+ lambda: {
119
+ function: lambda.arn,
120
+ identitySources: undefined,
121
+ payload: '2.0',
122
+ },
123
+ },
124
+ {
125
+ name: 'key',
126
+ lambda: {
127
+ function: 'handlers/key.handler',
128
+ identitySources: undefined,
129
+ payload: undefined,
130
+ },
131
+ },
132
+ ]);
133
+ expect(routes.map((r) => r.args)).toEqual([
134
+ { auth: { jwt: { authorizer: 'jwt-authorizer-id', scopes: ['read'] } } },
135
+ // A route's own scopes override the authorizer's defaults.
136
+ { auth: { jwt: { authorizer: 'jwt-authorizer-id', scopes: ['write'] } } },
137
+ { auth: { lambda: 'tenant-authorizer-id' } },
138
+ { auth: { iam: true } },
139
+ undefined,
140
+ undefined,
141
+ ]);
142
+ });
143
+
144
+ it('fails the synth, naming every route whose variables no link provides', () => {
145
+ const build = () =>
146
+ new Api(stack, 'Http', {
147
+ links: [db],
148
+ routes: [
149
+ {
150
+ method: 'GET',
151
+ path: '/ok',
152
+ handler: 'ok.h',
153
+ environment: ['DB_URL'],
154
+ },
155
+ {
156
+ method: 'POST',
157
+ path: '/pay',
158
+ handler: 'pay.h',
159
+ environment: ['STRIPE_KEY'],
160
+ },
161
+ {
162
+ method: 'GET',
163
+ path: '/mail',
164
+ handler: 'mail.h',
165
+ environment: ['SMTP_URL'],
166
+ },
167
+ ],
168
+ });
169
+
170
+ expect(build).toThrow(RoutesMissingEnvironment);
171
+ expect(build).toThrow(/Http POST \/pay[\s\S]*STRIPE_KEY/);
172
+ expect(build).toThrow(/Http GET \/mail[\s\S]*SMTP_URL/);
173
+ });
174
+
175
+ it('leaves a route public rather than half-wired when its authorizer was never declared', () => {
176
+ const api = new Api(stack, 'Http', {
177
+ routes: [
178
+ { method: 'GET', path: '/ghost', handler: 'g.h', authorizer: 'ghost' },
179
+ ],
180
+ } as never);
181
+
182
+ expect(recorded(api).routes[0]!.args).toBeUndefined();
183
+ expect(recorded(api).authorizers).toEqual([]);
184
+ });
185
+ });
186
+
187
+ describe('Api.fromManifest', () => {
188
+ it('turns built routes, flat or partitioned, into the route table', () => {
189
+ const routes = {
190
+ public: [
191
+ {
192
+ method: 'GET',
193
+ path: '/orders',
194
+ handler: '.gkm/aws-lambda/routes/orders.handler',
195
+ environment: ['DB_URL'],
196
+ authorizer: 'iam',
197
+ timeout: 30,
198
+ memorySize: 1024,
199
+ },
200
+ ],
201
+ internal: [
202
+ {
203
+ method: 'POST',
204
+ path: '/jobs',
205
+ handler: '.gkm/aws-lambda/routes/jobs.handler',
206
+ environment: [],
207
+ },
208
+ ],
209
+ };
210
+
211
+ const api = Api.fromManifest(stack, 'Http', routes as never, {
212
+ links: [db],
213
+ });
214
+ const { routes: recordedRoutes } = recorded(api);
215
+
216
+ expect(recordedRoutes.map((r) => r.key)).toEqual([
217
+ 'GET /orders',
218
+ 'POST /jobs',
219
+ ]);
220
+ expect(recordedRoutes[0]!.handler).toMatchObject({
221
+ timeout: '30 seconds',
222
+ memory: '1024 MB',
223
+ link: [db],
224
+ });
225
+ expect(recordedRoutes[0]!.args).toEqual({ auth: { iam: true } });
226
+ expect(recordedRoutes[1]!.handler).toMatchObject({
227
+ timeout: undefined,
228
+ memory: undefined,
229
+ });
230
+ });
231
+ });