@cosmicdrift/kumiko-bundled-features 0.296.0 → 0.299.0
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/package.json +9 -9
- package/src/billing-foundation/__tests__/billing-foundation.integration.test.ts +11 -37
- package/src/billing-foundation/__tests__/subscription-tier-sync.integration.test.ts +159 -119
- package/src/billing-foundation/__tests__/webhook-handler.integration.test.ts +174 -0
- package/src/billing-foundation/changes.json +6 -0
- package/src/billing-foundation/feature.ts +3 -3
- package/src/billing-foundation/handlers/process-event.write.ts +1 -1
- package/src/billing-foundation/index.ts +2 -4
- package/src/billing-foundation/subscription-tier-sync.ts +110 -83
- package/src/billing-foundation/webhook-handler.ts +117 -187
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +52 -81
- package/src/inbound-mail-foundation/changes.json +6 -0
- package/src/inbound-mail-foundation/connect-routes.ts +218 -197
- package/src/inbound-mail-foundation/index.ts +1 -2
- package/src/subscription-mollie/__tests__/mollie-foundation.integration.test.ts +7 -40
- package/src/subscription-stripe/__tests__/stripe-foundation.integration.test.ts +18 -60
- package/src/billing-foundation/__tests__/webhook-handler.test.ts +0 -200
|
@@ -1,19 +1,14 @@
|
|
|
1
|
-
// Connect-routes integration — mounts createInboundMailConnectRoutes
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// Connect-routes integration — mounts createInboundMailConnectRoutes as
|
|
2
|
+
// real extraRoutes on a setupTestStack (kumiko-framework#3050), driven with
|
|
3
|
+
// real HTTP through the framework's own auth/signature wiring, never a
|
|
4
|
+
// hand-rolled Hono app or createTestDispatcher.
|
|
5
5
|
|
|
6
6
|
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
7
|
import { randomBytes } from "node:crypto";
|
|
8
|
-
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
9
8
|
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
9
|
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
|
|
11
10
|
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
12
|
-
import {
|
|
13
|
-
createSystemUser,
|
|
14
|
-
defineFeature,
|
|
15
|
-
type TenantId,
|
|
16
|
-
} from "@cosmicdrift/kumiko-framework/engine";
|
|
11
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
17
12
|
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
18
13
|
import {
|
|
19
14
|
createTestUser,
|
|
@@ -28,7 +23,6 @@ import {
|
|
|
28
23
|
type MutableMasterKeyProvider,
|
|
29
24
|
resetPiiSubjectKmsForTests,
|
|
30
25
|
} from "@cosmicdrift/kumiko-framework/testing";
|
|
31
|
-
import { Hono } from "hono";
|
|
32
26
|
import {
|
|
33
27
|
createComplianceProfilesFeature,
|
|
34
28
|
tenantComplianceProfileEntity,
|
|
@@ -37,6 +31,7 @@ import { createConfigFeature } from "../../config";
|
|
|
37
31
|
import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
|
|
38
32
|
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
39
33
|
import { createTenantFeature } from "../../tenant/feature";
|
|
34
|
+
import { tenantEntity } from "../../tenant/schema/tenant";
|
|
40
35
|
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
41
36
|
import {
|
|
42
37
|
createInboundMailConnectRoutes,
|
|
@@ -53,6 +48,8 @@ import {
|
|
|
53
48
|
const OAUTH_PROVIDER_KEY = "oauth-test";
|
|
54
49
|
const STATE_SECRET = "inbound-mail-connect-test-state-secret-32b";
|
|
55
50
|
const CALLBACK_URL = "http://localhost/inbound-mail/oauth/callback";
|
|
51
|
+
const CONNECT_PATH = "/api/inbound-mail/connect";
|
|
52
|
+
const CALLBACK_PATH = "/inbound-mail/oauth/callback";
|
|
56
53
|
|
|
57
54
|
const oauthTestPlugin: InboundMailProviderPlugin = {
|
|
58
55
|
verify: async () => {},
|
|
@@ -94,7 +91,6 @@ let stack: TestStack;
|
|
|
94
91
|
let db: DbConnection;
|
|
95
92
|
let secrets: ReturnType<typeof createSecretsContext>;
|
|
96
93
|
let providerRef: MutableMasterKeyProvider;
|
|
97
|
-
let routes: ReturnType<typeof createInboundMailConnectRoutes>;
|
|
98
94
|
|
|
99
95
|
beforeAll(async () => {
|
|
100
96
|
const initialKp = createEnvMasterKeyProvider({
|
|
@@ -120,28 +116,20 @@ beforeAll(async () => {
|
|
|
120
116
|
extraContext: ({ db: stackDb }) => ({
|
|
121
117
|
secrets: createSecretsContext({ db: stackDb, masterKeyProvider: providerRef }),
|
|
122
118
|
}),
|
|
119
|
+
extraRoutes: createInboundMailConnectRoutes({
|
|
120
|
+
stateSecret: STATE_SECRET,
|
|
121
|
+
callbackUrl: CALLBACK_URL,
|
|
122
|
+
}),
|
|
123
123
|
});
|
|
124
124
|
db = stack.db;
|
|
125
125
|
secrets = createSecretsContext({ db, masterKeyProvider: providerRef });
|
|
126
126
|
|
|
127
|
+
await unsafeCreateEntityTable(db, tenantEntity);
|
|
127
128
|
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
128
129
|
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
129
130
|
await unsafeCreateEntityTable(db, seenMessageEntity);
|
|
130
131
|
await unsafePushTables(db, { tenant_secrets: tenantSecretsTable });
|
|
131
132
|
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
132
|
-
|
|
133
|
-
routes = createInboundMailConnectRoutes({
|
|
134
|
-
providerCtx: { registry: stack.registry, secrets },
|
|
135
|
-
dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
136
|
-
stack.dispatcher.write(
|
|
137
|
-
handlerQn,
|
|
138
|
-
payload,
|
|
139
|
-
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
140
|
-
),
|
|
141
|
-
secrets,
|
|
142
|
-
stateSecret: STATE_SECRET,
|
|
143
|
-
callbackUrl: CALLBACK_URL,
|
|
144
|
-
});
|
|
145
133
|
});
|
|
146
134
|
|
|
147
135
|
afterAll(async () => {
|
|
@@ -157,49 +145,47 @@ function adminFor(tenantNumber: number) {
|
|
|
157
145
|
});
|
|
158
146
|
}
|
|
159
147
|
|
|
160
|
-
function
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
return app;
|
|
148
|
+
async function authHeader(user: ReturnType<typeof adminFor>): Promise<Record<string, string>> {
|
|
149
|
+
const token = await stack.jwt.sign(user);
|
|
150
|
+
return { Authorization: `Bearer ${token}` };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function getConnect(query: string, user?: ReturnType<typeof adminFor>) {
|
|
154
|
+
const headers = user ? await authHeader(user) : {};
|
|
155
|
+
return stack.app.request(`${CONNECT_PATH}?${query}`, { headers, redirect: "manual" });
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function getCallback(query: string) {
|
|
159
|
+
return stack.app.request(`${CALLBACK_PATH}?${query}`);
|
|
172
160
|
}
|
|
173
161
|
|
|
174
162
|
describe("connect-routes — connect", () => {
|
|
175
163
|
test("401 without session user", async () => {
|
|
176
|
-
const
|
|
177
|
-
|
|
178
|
-
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=inbox@acme.test`,
|
|
164
|
+
const res = await getConnect(
|
|
165
|
+
`provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=inbox@acme.test`,
|
|
179
166
|
);
|
|
180
167
|
expect(res.status).toBe(401);
|
|
181
168
|
});
|
|
182
169
|
|
|
183
170
|
test("400 when query params are incomplete", async () => {
|
|
184
|
-
const
|
|
185
|
-
const res = await app.request(`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}`);
|
|
171
|
+
const res = await getConnect(`provider=${OAUTH_PROVIDER_KEY}`, adminFor(4301));
|
|
186
172
|
expect(res.status).toBe(400);
|
|
187
173
|
const body = (await res.json()) as { error: { code: string } };
|
|
188
174
|
expect(body.error.code).toBe("invalid_connect_request");
|
|
189
175
|
});
|
|
190
176
|
|
|
191
177
|
test("404 for unknown provider key", async () => {
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
178
|
+
const res = await getConnect(
|
|
179
|
+
"provider=does-not-exist&scope=shared&mailbox=inbox@acme.test",
|
|
180
|
+
adminFor(4302),
|
|
195
181
|
);
|
|
196
182
|
expect(res.status).toBe(404);
|
|
197
183
|
});
|
|
198
184
|
|
|
199
185
|
test("400 when provider has no oauth flow (inmemory)", async () => {
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
186
|
+
const res = await getConnect(
|
|
187
|
+
"provider=inmemory&scope=shared&mailbox=inbox@acme.test",
|
|
188
|
+
adminFor(4303),
|
|
203
189
|
);
|
|
204
190
|
expect(res.status).toBe(400);
|
|
205
191
|
const body = (await res.json()) as { error: { code: string } };
|
|
@@ -207,10 +193,9 @@ describe("connect-routes — connect", () => {
|
|
|
207
193
|
});
|
|
208
194
|
|
|
209
195
|
test("302 redirect to provider authorize URL with signed state", async () => {
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
{ redirect: "manual" },
|
|
196
|
+
const res = await getConnect(
|
|
197
|
+
`provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=team@acme.test`,
|
|
198
|
+
adminFor(4304),
|
|
214
199
|
);
|
|
215
200
|
expect(res.status).toBe(302);
|
|
216
201
|
const location = res.headers.get("location");
|
|
@@ -224,33 +209,27 @@ describe("connect-routes — connect", () => {
|
|
|
224
209
|
|
|
225
210
|
describe("connect-routes — oauth callback", () => {
|
|
226
211
|
test("400 when code/state missing", async () => {
|
|
227
|
-
const
|
|
228
|
-
const res = await app.request("/inbound-mail/oauth/callback");
|
|
212
|
+
const res = await getCallback("");
|
|
229
213
|
expect(res.status).toBe(400);
|
|
230
214
|
});
|
|
231
215
|
|
|
232
216
|
test("400 on tampered state", async () => {
|
|
233
|
-
const
|
|
234
|
-
const res = await app.request("/inbound-mail/oauth/callback?code=ok&state=not.a.valid.state");
|
|
217
|
+
const res = await getCallback("code=ok&state=not.a.valid.state");
|
|
235
218
|
expect(res.status).toBe(400);
|
|
236
219
|
const body = (await res.json()) as { error: { code: string } };
|
|
237
220
|
expect(body.error.code).toBe("invalid_state");
|
|
238
221
|
});
|
|
239
222
|
|
|
240
223
|
test("happy path: creates mail account + stores refresh token secret", async () => {
|
|
241
|
-
const user = adminFor(
|
|
242
|
-
const
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
{ redirect: "manual" },
|
|
224
|
+
const user = adminFor(4305);
|
|
225
|
+
const connectRes = await getConnect(
|
|
226
|
+
`provider=${OAUTH_PROVIDER_KEY}&scope=user&mailbox=owner@acme.test`,
|
|
227
|
+
user,
|
|
246
228
|
);
|
|
247
229
|
const state = new URL(connectRes.headers.get("location")!).searchParams.get("state");
|
|
248
230
|
expect(state).toBeTruthy();
|
|
249
231
|
|
|
250
|
-
const
|
|
251
|
-
const callbackRes = await callbackApp.request(
|
|
252
|
-
`/inbound-mail/oauth/callback?code=ok&state=${encodeURIComponent(state!)}`,
|
|
253
|
-
);
|
|
232
|
+
const callbackRes = await getCallback(`code=ok&state=${encodeURIComponent(state!)}`);
|
|
254
233
|
expect(callbackRes.status).toBe(200);
|
|
255
234
|
const body = (await callbackRes.json()) as { connected: boolean; accountId: string };
|
|
256
235
|
expect(body.connected).toBe(true);
|
|
@@ -267,34 +246,26 @@ describe("connect-routes — oauth callback", () => {
|
|
|
267
246
|
});
|
|
268
247
|
|
|
269
248
|
test("502 when token exchange fails", async () => {
|
|
270
|
-
const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=fail@acme.test`,
|
|
274
|
-
{ redirect: "manual" },
|
|
249
|
+
const connectRes = await getConnect(
|
|
250
|
+
`provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=fail@acme.test`,
|
|
251
|
+
adminFor(4306),
|
|
275
252
|
);
|
|
276
253
|
const state = new URL(connectRes.headers.get("location")!).searchParams.get("state");
|
|
277
254
|
|
|
278
|
-
const res = await
|
|
279
|
-
`/inbound-mail/oauth/callback?code=fail-exchange&state=${encodeURIComponent(state!)}`,
|
|
280
|
-
);
|
|
255
|
+
const res = await getCallback(`code=fail-exchange&state=${encodeURIComponent(state!)}`);
|
|
281
256
|
expect(res.status).toBe(502);
|
|
282
257
|
const body = (await res.json()) as { error: { code: string } };
|
|
283
258
|
expect(body.error.code).toBe("token_exchange_failed");
|
|
284
259
|
});
|
|
285
260
|
|
|
286
261
|
test("502 when provider omits refresh token", async () => {
|
|
287
|
-
const
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=norefresh@acme.test`,
|
|
291
|
-
{ redirect: "manual" },
|
|
262
|
+
const connectRes = await getConnect(
|
|
263
|
+
`provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=norefresh@acme.test`,
|
|
264
|
+
adminFor(4307),
|
|
292
265
|
);
|
|
293
266
|
const state = new URL(connectRes.headers.get("location")!).searchParams.get("state");
|
|
294
267
|
|
|
295
|
-
const res = await
|
|
296
|
-
`/inbound-mail/oauth/callback?code=no-refresh&state=${encodeURIComponent(state!)}`,
|
|
297
|
-
);
|
|
268
|
+
const res = await getCallback(`code=no-refresh&state=${encodeURIComponent(state!)}`);
|
|
298
269
|
expect(res.status).toBe(502);
|
|
299
270
|
const body = (await res.json()) as { error: { code: string } };
|
|
300
271
|
expect(body.error.code).toBe("no_refresh_token");
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.298.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "createInboundMailConnectRoutes() returns ExtraRouteDefinition[] with static options only",
|
|
6
|
+
"migration": "createInboundMailConnectRoutes(options) now returns readonly ExtraRouteDefinition[] (connect route as entry: \"user\", callback route as entry: \"signature\") instead of mounting itself on app. options is now static config only - any db/registry/dispatch values previously passed through options are supplied by the framework via the route's deps instead. Replace extraRoutes: (app, deps) => createInboundMailConnectRoutes(options)(app, deps) with extraRoutes: createInboundMailConnectRoutes(options) (spread into the app's route array alongside other route definitions)."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.150.0",
|
|
4
10
|
"type": "fix",
|