@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.
@@ -1,19 +1,14 @@
1
- // Connect-routes integration — mounts createInboundMailConnectRoutes on a
2
- // Hono app wired to a real setupTestStack dispatcher + secrets context.
3
- // Exercises OAuth connect redirect, HMAC state, callback account create,
4
- // and error paths (401/400/404/502) — not mock-only existence checks.
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 buildApp(opts: { user?: { id: string; tenantId: string } | null } = {}) {
161
- const app = new Hono<{ Variables: { user?: { id: string; tenantId: string } } }>();
162
- if (opts.user !== null) {
163
- const user = opts.user ?? adminFor(4201);
164
- app.use("*", async (c, next) => {
165
- c.set("user", { id: user.id, tenantId: user.tenantId });
166
- await next();
167
- });
168
- }
169
- app.get("/api/inbound-mail/connect", routes.connect);
170
- app.get("/inbound-mail/oauth/callback", routes.callback);
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 app = buildApp({ user: null });
177
- const res = await app.request(
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 app = buildApp();
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 app = buildApp();
193
- const res = await app.request(
194
- "/api/inbound-mail/connect?provider=does-not-exist&scope=shared&mailbox=inbox@acme.test",
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 app = buildApp();
201
- const res = await app.request(
202
- "/api/inbound-mail/connect?provider=inmemory&scope=shared&mailbox=inbox@acme.test",
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 app = buildApp({ user: adminFor(4202) });
211
- const res = await app.request(
212
- `/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=team@acme.test`,
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 app = buildApp({ user: null });
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 app = buildApp({ user: null });
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(4203);
242
- const connectApp = buildApp({ user });
243
- const connectRes = await connectApp.request(
244
- `/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=user&mailbox=owner@acme.test`,
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 callbackApp = buildApp({ user: null });
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 user = adminFor(4204);
271
- const connectApp = buildApp({ user });
272
- const connectRes = await connectApp.request(
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 buildApp({ user: null }).request(
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 user = adminFor(4205);
288
- const connectApp = buildApp({ user });
289
- const connectRes = await connectApp.request(
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 buildApp({ user: null }).request(
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",