@cosmicdrift/kumiko-bundled-features 0.154.0 → 0.154.2
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 +6 -6
- package/src/auth-email-password/web/__tests__/auth-form-logic.test.ts +44 -0
- package/src/auth-email-password/web/__tests__/signup-complete-screen.test.tsx +130 -0
- package/src/auth-email-password/web/__tests__/signup-screen.test.tsx +89 -0
- package/src/auth-email-password/web/auth-form-logic.ts +30 -0
- package/src/auth-email-password/web/index.ts +2 -0
- package/src/auth-email-password/web/reset-password-screen.tsx +4 -2
- package/src/auth-email-password/web/signup-complete-screen.tsx +5 -7
- package/src/auth-email-password/web/signup-screen.tsx +2 -4
- package/src/auth-mfa/web/mfa-enable-screen.tsx +29 -1
- package/src/custom-fields/wire-for-entity.ts +1 -1
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +1 -1
- package/src/files-provider-s3/__tests__/s3-provider.integration.test.ts +18 -2
- package/src/inbound-mail-foundation/__tests__/connect-routes.integration.test.ts +307 -0
- package/src/inbound-mail-foundation/__tests__/watch-supervisor.integration.test.ts +271 -0
- package/src/inbound-provider-imap/__tests__/imap-foundation.integration.test.ts +306 -0
- package/src/legal-pages/__tests__/legal-pages.integration.test.ts +51 -0
- package/src/legal-pages/feature.ts +3 -0
- package/src/sessions/feature.ts +1 -1
- package/src/tier-engine/feature.ts +4 -4
|
@@ -0,0 +1,307 @@
|
|
|
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.
|
|
5
|
+
|
|
6
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
7
|
+
import { randomBytes } from "node:crypto";
|
|
8
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
9
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
10
|
+
import {
|
|
11
|
+
configurePiiSubjectKms,
|
|
12
|
+
InMemoryKmsAdapter,
|
|
13
|
+
resetPiiSubjectKmsForTests,
|
|
14
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
15
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
16
|
+
import {
|
|
17
|
+
createSystemUser,
|
|
18
|
+
defineFeature,
|
|
19
|
+
type TenantId,
|
|
20
|
+
} from "@cosmicdrift/kumiko-framework/engine";
|
|
21
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
22
|
+
import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
|
|
23
|
+
import {
|
|
24
|
+
createTestUser,
|
|
25
|
+
setupTestStack,
|
|
26
|
+
type TestStack,
|
|
27
|
+
testTenantId,
|
|
28
|
+
unsafeCreateEntityTable,
|
|
29
|
+
unsafePushTables,
|
|
30
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
31
|
+
import {
|
|
32
|
+
createMutableMasterKeyProvider,
|
|
33
|
+
type MutableMasterKeyProvider,
|
|
34
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
35
|
+
import { Hono } from "hono";
|
|
36
|
+
import {
|
|
37
|
+
createComplianceProfilesFeature,
|
|
38
|
+
tenantComplianceProfileEntity,
|
|
39
|
+
} from "../../compliance-profiles";
|
|
40
|
+
import { createConfigFeature } from "../../config";
|
|
41
|
+
import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
|
|
42
|
+
import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
|
|
43
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
44
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
45
|
+
import {
|
|
46
|
+
createInboundMailConnectRoutes,
|
|
47
|
+
INBOUND_MAIL_PROVIDER_EXTENSION,
|
|
48
|
+
type InboundMailProviderPlugin,
|
|
49
|
+
inboundCredentialSecretKey,
|
|
50
|
+
inboundMailFoundationFeature,
|
|
51
|
+
mailAccountsProjectionTable,
|
|
52
|
+
type OAuthTokenSet,
|
|
53
|
+
seenMessageEntity,
|
|
54
|
+
syncCursorEntity,
|
|
55
|
+
} from "../index";
|
|
56
|
+
|
|
57
|
+
const OAUTH_PROVIDER_KEY = "oauth-test";
|
|
58
|
+
const STATE_SECRET = "inbound-mail-connect-test-state-secret-32b";
|
|
59
|
+
const CALLBACK_URL = "http://localhost/inbound-mail/oauth/callback";
|
|
60
|
+
|
|
61
|
+
const oauthTestPlugin: InboundMailProviderPlugin = {
|
|
62
|
+
verify: async () => {},
|
|
63
|
+
fetch: async () => ({ messages: [], nextCursor: { offset: 0 }, hasMore: false }),
|
|
64
|
+
oauth: {
|
|
65
|
+
scopes: { receive: ["mail.read"] },
|
|
66
|
+
buildAuthorizeUrl: async (_ctx, p) =>
|
|
67
|
+
`https://oauth.test/authorize?state=${encodeURIComponent(p.state)}&redirect_uri=${encodeURIComponent(p.redirectUri)}`,
|
|
68
|
+
exchangeCode: async (_ctx, p): Promise<OAuthTokenSet> => {
|
|
69
|
+
if (p.code === "fail-exchange") {
|
|
70
|
+
throw new Error("token endpoint unavailable");
|
|
71
|
+
}
|
|
72
|
+
if (p.code === "no-refresh") {
|
|
73
|
+
return {
|
|
74
|
+
accessToken: "access-only",
|
|
75
|
+
expiresAt: "2099-01-01T00:00:00Z",
|
|
76
|
+
scopesGranted: ["mail.read"],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
accessToken: "access-token",
|
|
81
|
+
refreshToken: "refresh-token-abc",
|
|
82
|
+
expiresAt: "2099-01-01T00:00:00Z",
|
|
83
|
+
scopesGranted: ["mail.read"],
|
|
84
|
+
};
|
|
85
|
+
},
|
|
86
|
+
refreshAccessToken: async () => {
|
|
87
|
+
throw new Error("not used in connect-routes tests");
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const oauthTestProviderFeature = defineFeature("inbound-provider-oauth-test", (r) => {
|
|
93
|
+
r.requires("inbound-mail-foundation");
|
|
94
|
+
r.useExtension(INBOUND_MAIL_PROVIDER_EXTENSION, OAUTH_PROVIDER_KEY, oauthTestPlugin);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
let stack: TestStack;
|
|
98
|
+
let db: DbConnection;
|
|
99
|
+
let secrets: ReturnType<typeof createSecretsContext>;
|
|
100
|
+
let providerRef: MutableMasterKeyProvider;
|
|
101
|
+
let routes: ReturnType<typeof createInboundMailConnectRoutes>;
|
|
102
|
+
|
|
103
|
+
beforeAll(async () => {
|
|
104
|
+
const initialKp = createEnvMasterKeyProvider({
|
|
105
|
+
env: {
|
|
106
|
+
KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
|
|
107
|
+
KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
providerRef = createMutableMasterKeyProvider(initialKp);
|
|
111
|
+
|
|
112
|
+
stack = await setupTestStack({
|
|
113
|
+
features: [
|
|
114
|
+
createConfigFeature(),
|
|
115
|
+
createTenantFeature(),
|
|
116
|
+
createSecretsFeature(),
|
|
117
|
+
createComplianceProfilesFeature(),
|
|
118
|
+
createTenantLifecycleFeature(),
|
|
119
|
+
inboundMailFoundationFeature,
|
|
120
|
+
inboundProviderInMemoryFeature,
|
|
121
|
+
oauthTestProviderFeature,
|
|
122
|
+
],
|
|
123
|
+
masterKeyProvider: providerRef,
|
|
124
|
+
extraContext: ({ db: stackDb }) => ({
|
|
125
|
+
secrets: createSecretsContext({ db: stackDb, masterKeyProvider: providerRef }),
|
|
126
|
+
}),
|
|
127
|
+
});
|
|
128
|
+
db = stack.db;
|
|
129
|
+
secrets = createSecretsContext({ db, masterKeyProvider: providerRef });
|
|
130
|
+
|
|
131
|
+
await createEventsTable(db);
|
|
132
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
133
|
+
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
134
|
+
await unsafeCreateEntityTable(db, seenMessageEntity);
|
|
135
|
+
await unsafePushTables(db, { tenant_secrets: tenantSecretsTable });
|
|
136
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
137
|
+
|
|
138
|
+
routes = createInboundMailConnectRoutes({
|
|
139
|
+
providerCtx: { registry: stack.registry, secrets },
|
|
140
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
141
|
+
stack.dispatcher.write(
|
|
142
|
+
handlerQn,
|
|
143
|
+
payload,
|
|
144
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
145
|
+
),
|
|
146
|
+
secrets,
|
|
147
|
+
stateSecret: STATE_SECRET,
|
|
148
|
+
callbackUrl: CALLBACK_URL,
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
afterAll(async () => {
|
|
153
|
+
await stack.cleanup();
|
|
154
|
+
resetPiiSubjectKmsForTests();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
function adminFor(tenantNumber: number) {
|
|
158
|
+
return createTestUser({
|
|
159
|
+
id: tenantNumber,
|
|
160
|
+
tenantId: testTenantId(tenantNumber),
|
|
161
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildApp(opts: { user?: { id: string; tenantId: string } | null } = {}) {
|
|
166
|
+
const app = new Hono<{ Variables: { user?: { id: string; tenantId: string } } }>();
|
|
167
|
+
if (opts.user !== null) {
|
|
168
|
+
const user = opts.user ?? adminFor(4201);
|
|
169
|
+
app.use("*", async (c, next) => {
|
|
170
|
+
c.set("user", { id: user.id, tenantId: user.tenantId });
|
|
171
|
+
await next();
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
app.get("/api/inbound-mail/connect", routes.connect);
|
|
175
|
+
app.get("/inbound-mail/oauth/callback", routes.callback);
|
|
176
|
+
return app;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
describe("connect-routes — connect", () => {
|
|
180
|
+
test("401 without session user", async () => {
|
|
181
|
+
const app = buildApp({ user: null });
|
|
182
|
+
const res = await app.request(
|
|
183
|
+
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=inbox@acme.test`,
|
|
184
|
+
);
|
|
185
|
+
expect(res.status).toBe(401);
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test("400 when query params are incomplete", async () => {
|
|
189
|
+
const app = buildApp();
|
|
190
|
+
const res = await app.request(`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}`);
|
|
191
|
+
expect(res.status).toBe(400);
|
|
192
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
193
|
+
expect(body.error.code).toBe("invalid_connect_request");
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("404 for unknown provider key", async () => {
|
|
197
|
+
const app = buildApp();
|
|
198
|
+
const res = await app.request(
|
|
199
|
+
"/api/inbound-mail/connect?provider=does-not-exist&scope=shared&mailbox=inbox@acme.test",
|
|
200
|
+
);
|
|
201
|
+
expect(res.status).toBe(404);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("400 when provider has no oauth flow (inmemory)", async () => {
|
|
205
|
+
const app = buildApp();
|
|
206
|
+
const res = await app.request(
|
|
207
|
+
"/api/inbound-mail/connect?provider=inmemory&scope=shared&mailbox=inbox@acme.test",
|
|
208
|
+
);
|
|
209
|
+
expect(res.status).toBe(400);
|
|
210
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
211
|
+
expect(body.error.code).toBe("provider_has_no_oauth_flow");
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test("302 redirect to provider authorize URL with signed state", async () => {
|
|
215
|
+
const app = buildApp({ user: adminFor(4202) });
|
|
216
|
+
const res = await app.request(
|
|
217
|
+
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=team@acme.test`,
|
|
218
|
+
{ redirect: "manual" },
|
|
219
|
+
);
|
|
220
|
+
expect(res.status).toBe(302);
|
|
221
|
+
const location = res.headers.get("location");
|
|
222
|
+
expect(location).toBeTruthy();
|
|
223
|
+
const url = new URL(location!);
|
|
224
|
+
expect(url.origin + url.pathname).toBe("https://oauth.test/authorize");
|
|
225
|
+
expect(url.searchParams.get("redirect_uri")).toBe(CALLBACK_URL);
|
|
226
|
+
expect(url.searchParams.get("state")).toBeTruthy();
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe("connect-routes — oauth callback", () => {
|
|
231
|
+
test("400 when code/state missing", async () => {
|
|
232
|
+
const app = buildApp({ user: null });
|
|
233
|
+
const res = await app.request("/inbound-mail/oauth/callback");
|
|
234
|
+
expect(res.status).toBe(400);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("400 on tampered state", async () => {
|
|
238
|
+
const app = buildApp({ user: null });
|
|
239
|
+
const res = await app.request("/inbound-mail/oauth/callback?code=ok&state=not.a.valid.state");
|
|
240
|
+
expect(res.status).toBe(400);
|
|
241
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
242
|
+
expect(body.error.code).toBe("invalid_state");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("happy path: creates mail account + stores refresh token secret", async () => {
|
|
246
|
+
const user = adminFor(4203);
|
|
247
|
+
const connectApp = buildApp({ user });
|
|
248
|
+
const connectRes = await connectApp.request(
|
|
249
|
+
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=user&mailbox=owner@acme.test`,
|
|
250
|
+
{ redirect: "manual" },
|
|
251
|
+
);
|
|
252
|
+
const state = new URL(connectRes.headers.get("location")!).searchParams.get("state");
|
|
253
|
+
expect(state).toBeTruthy();
|
|
254
|
+
|
|
255
|
+
const callbackApp = buildApp({ user: null });
|
|
256
|
+
const callbackRes = await callbackApp.request(
|
|
257
|
+
`/inbound-mail/oauth/callback?code=ok&state=${encodeURIComponent(state!)}`,
|
|
258
|
+
);
|
|
259
|
+
expect(callbackRes.status).toBe(200);
|
|
260
|
+
const body = (await callbackRes.json()) as { connected: boolean; accountId: string };
|
|
261
|
+
expect(body.connected).toBe(true);
|
|
262
|
+
expect(body.accountId).toBeTruthy();
|
|
263
|
+
|
|
264
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: body.accountId });
|
|
265
|
+
expect(accounts).toHaveLength(1);
|
|
266
|
+
expect(accounts[0]?.["provider"]).toBe(OAUTH_PROVIDER_KEY);
|
|
267
|
+
expect(accounts[0]?.["ownerUserId"]).toBe(user.id);
|
|
268
|
+
|
|
269
|
+
const stored = await secrets.get(user.tenantId, inboundCredentialSecretKey(body.accountId));
|
|
270
|
+
expect(stored).toBeDefined();
|
|
271
|
+
expect(stored!.reveal()).toBe("refresh-token-abc");
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test("502 when token exchange fails", async () => {
|
|
275
|
+
const user = adminFor(4204);
|
|
276
|
+
const connectApp = buildApp({ user });
|
|
277
|
+
const connectRes = await connectApp.request(
|
|
278
|
+
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=fail@acme.test`,
|
|
279
|
+
{ redirect: "manual" },
|
|
280
|
+
);
|
|
281
|
+
const state = new URL(connectRes.headers.get("location")!).searchParams.get("state");
|
|
282
|
+
|
|
283
|
+
const res = await buildApp({ user: null }).request(
|
|
284
|
+
`/inbound-mail/oauth/callback?code=fail-exchange&state=${encodeURIComponent(state!)}`,
|
|
285
|
+
);
|
|
286
|
+
expect(res.status).toBe(502);
|
|
287
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
288
|
+
expect(body.error.code).toBe("token_exchange_failed");
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
test("502 when provider omits refresh token", async () => {
|
|
292
|
+
const user = adminFor(4205);
|
|
293
|
+
const connectApp = buildApp({ user });
|
|
294
|
+
const connectRes = await connectApp.request(
|
|
295
|
+
`/api/inbound-mail/connect?provider=${OAUTH_PROVIDER_KEY}&scope=shared&mailbox=norefresh@acme.test`,
|
|
296
|
+
{ redirect: "manual" },
|
|
297
|
+
);
|
|
298
|
+
const state = new URL(connectRes.headers.get("location")!).searchParams.get("state");
|
|
299
|
+
|
|
300
|
+
const res = await buildApp({ user: null }).request(
|
|
301
|
+
`/inbound-mail/oauth/callback?code=no-refresh&state=${encodeURIComponent(state!)}`,
|
|
302
|
+
);
|
|
303
|
+
expect(res.status).toBe(502);
|
|
304
|
+
const body = (await res.json()) as { error: { code: string } };
|
|
305
|
+
expect(body.error.code).toBe("no_refresh_token");
|
|
306
|
+
});
|
|
307
|
+
});
|
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
// Watch-supervisor integration — drives createInboundMailSupervisor against
|
|
2
|
+
// a real setupTestStack + inbound-provider-inmemory (fetch/watch/error
|
|
3
|
+
// injection). Proves poll ingest, IDLE watch push, auth_error quarantine,
|
|
4
|
+
// and cursor-invalid resync — not existence checks.
|
|
5
|
+
|
|
6
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
|
|
8
|
+
import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
9
|
+
import {
|
|
10
|
+
configurePiiSubjectKms,
|
|
11
|
+
InMemoryKmsAdapter,
|
|
12
|
+
resetPiiSubjectKmsForTests,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/crypto";
|
|
14
|
+
import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
|
|
15
|
+
import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
16
|
+
import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
|
|
17
|
+
import {
|
|
18
|
+
createTestUser,
|
|
19
|
+
setupTestStack,
|
|
20
|
+
type TestStack,
|
|
21
|
+
testTenantId,
|
|
22
|
+
unsafeCreateEntityTable,
|
|
23
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
24
|
+
import { waitFor } from "@cosmicdrift/kumiko-framework/testing";
|
|
25
|
+
import {
|
|
26
|
+
createComplianceProfilesFeature,
|
|
27
|
+
tenantComplianceProfileEntity,
|
|
28
|
+
} from "../../compliance-profiles";
|
|
29
|
+
import { createConfigFeature } from "../../config";
|
|
30
|
+
import { inboundProviderInMemoryFeature } from "../../inbound-provider-inmemory";
|
|
31
|
+
import {
|
|
32
|
+
emitWatchError,
|
|
33
|
+
failNextFetchWith,
|
|
34
|
+
isWatching,
|
|
35
|
+
resetInboundInMemory,
|
|
36
|
+
seedInboundMessage,
|
|
37
|
+
} from "../../inbound-provider-inmemory/feature";
|
|
38
|
+
import { createTenantFeature } from "../../tenant/feature";
|
|
39
|
+
import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
|
|
40
|
+
import {
|
|
41
|
+
createInboundMailSupervisor,
|
|
42
|
+
InboundAuthError,
|
|
43
|
+
InboundCursorInvalidError,
|
|
44
|
+
InboundMailAccountStatuses,
|
|
45
|
+
InboundMailFoundationHandlers,
|
|
46
|
+
inboundMailFoundationFeature,
|
|
47
|
+
inboundMessagesProjectionTable,
|
|
48
|
+
mailAccountsProjectionTable,
|
|
49
|
+
type RawInboundMessage,
|
|
50
|
+
seenMessageEntity,
|
|
51
|
+
syncCursorEntity,
|
|
52
|
+
} from "../index";
|
|
53
|
+
|
|
54
|
+
let stack: TestStack;
|
|
55
|
+
let db: DbConnection;
|
|
56
|
+
|
|
57
|
+
beforeAll(async () => {
|
|
58
|
+
stack = await setupTestStack({
|
|
59
|
+
features: [
|
|
60
|
+
createConfigFeature(),
|
|
61
|
+
createTenantFeature(),
|
|
62
|
+
createComplianceProfilesFeature(),
|
|
63
|
+
createTenantLifecycleFeature(),
|
|
64
|
+
inboundMailFoundationFeature,
|
|
65
|
+
inboundProviderInMemoryFeature,
|
|
66
|
+
],
|
|
67
|
+
});
|
|
68
|
+
db = stack.db;
|
|
69
|
+
await createEventsTable(db);
|
|
70
|
+
await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
|
|
71
|
+
await unsafeCreateEntityTable(db, syncCursorEntity);
|
|
72
|
+
await unsafeCreateEntityTable(db, seenMessageEntity);
|
|
73
|
+
configurePiiSubjectKms(new InMemoryKmsAdapter());
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
afterAll(async () => {
|
|
77
|
+
await stack.cleanup();
|
|
78
|
+
resetPiiSubjectKmsForTests();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
resetInboundInMemory();
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
afterEach(() => {
|
|
86
|
+
resetInboundInMemory();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
function adminFor(tenantNumber: number) {
|
|
90
|
+
return createTestUser({
|
|
91
|
+
id: tenantNumber,
|
|
92
|
+
tenantId: testTenantId(tenantNumber),
|
|
93
|
+
roles: ["TenantAdmin", "SystemAdmin"],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function rawMsg(
|
|
98
|
+
overrides: Partial<RawInboundMessage> & { providerMessageId: string },
|
|
99
|
+
): RawInboundMessage {
|
|
100
|
+
return {
|
|
101
|
+
messageIdHeader: `${overrides.providerMessageId}@example.com`,
|
|
102
|
+
providerThreadId: null,
|
|
103
|
+
references: [],
|
|
104
|
+
from: "sender@example.com",
|
|
105
|
+
to: ["inbox@tenant.example"],
|
|
106
|
+
cc: [],
|
|
107
|
+
subject: `Subject ${overrides.providerMessageId}`,
|
|
108
|
+
snippet: "snippet",
|
|
109
|
+
receivedAtIso: "2026-07-15T10:00:00Z",
|
|
110
|
+
rawMime: null,
|
|
111
|
+
scope: "inbox",
|
|
112
|
+
...overrides,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async function connectSharedAccount(admin: ReturnType<typeof adminFor>): Promise<string> {
|
|
117
|
+
const result = (await stack.http.writeOk(
|
|
118
|
+
InboundMailFoundationHandlers.connectAccount,
|
|
119
|
+
{
|
|
120
|
+
provider: "inmemory",
|
|
121
|
+
authMethod: "password",
|
|
122
|
+
displayName: "Team-Inbox",
|
|
123
|
+
address: "inbox@tenant.example",
|
|
124
|
+
scope: "shared",
|
|
125
|
+
},
|
|
126
|
+
admin,
|
|
127
|
+
)) as { accountId: string };
|
|
128
|
+
return result.accountId;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createSupervisor() {
|
|
132
|
+
return createInboundMailSupervisor({
|
|
133
|
+
providerCtx: { registry: stack.registry },
|
|
134
|
+
db,
|
|
135
|
+
dispatchWrite: ({ handlerQn, payload, tenantId }) =>
|
|
136
|
+
stack.dispatcher.write(
|
|
137
|
+
handlerQn,
|
|
138
|
+
payload,
|
|
139
|
+
createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
|
|
140
|
+
),
|
|
141
|
+
// Keep the periodic tick far away so tests only exercise explicit
|
|
142
|
+
// pollOnce / watch push — not a racing background timer.
|
|
143
|
+
pollIntervalMs: 60_000,
|
|
144
|
+
watchBackoffInitialMs: 50,
|
|
145
|
+
watchBackoffMaxMs: 200,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
describe("watch-supervisor — poll reconciliation", () => {
|
|
150
|
+
test("pollOnce fetches seeded messages and persists them via ingest-message", async () => {
|
|
151
|
+
const admin = adminFor(4101);
|
|
152
|
+
const accountId = await connectSharedAccount(admin);
|
|
153
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "poll-uid-1" }));
|
|
154
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "poll-uid-2" }));
|
|
155
|
+
|
|
156
|
+
const supervisor = createSupervisor();
|
|
157
|
+
await supervisor.pollOnce();
|
|
158
|
+
|
|
159
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
160
|
+
expect(rows).toHaveLength(2);
|
|
161
|
+
const ids = rows.map((r) => r["messageIdHeader"]).sort();
|
|
162
|
+
expect(ids).toEqual(["poll-uid-1@example.com", "poll-uid-2@example.com"]);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test("second pollOnce is idempotent — Dedup keeps a single row per providerMessageId", async () => {
|
|
166
|
+
const admin = adminFor(4102);
|
|
167
|
+
const accountId = await connectSharedAccount(admin);
|
|
168
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "poll-dedup-1" }));
|
|
169
|
+
|
|
170
|
+
const supervisor = createSupervisor();
|
|
171
|
+
await supervisor.pollOnce();
|
|
172
|
+
await supervisor.pollOnce();
|
|
173
|
+
|
|
174
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
175
|
+
expect(rows).toHaveLength(1);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
describe("watch-supervisor — IDLE watch path", () => {
|
|
180
|
+
test("start() arms watch; seedInboundMessage pushes and ingest lands a row", async () => {
|
|
181
|
+
const admin = adminFor(4103);
|
|
182
|
+
const accountId = await connectSharedAccount(admin);
|
|
183
|
+
const supervisor = createSupervisor();
|
|
184
|
+
try {
|
|
185
|
+
await supervisor.start();
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(isWatching(accountId)).toBe(true);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
await seedInboundMessage(
|
|
192
|
+
accountId,
|
|
193
|
+
rawMsg({ providerMessageId: "watch-uid-1", subject: "via watch" }),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
await waitFor(async () => {
|
|
197
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
198
|
+
expect(rows).toHaveLength(1);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
202
|
+
expect(rows[0]?.["messageIdHeader"]).toBe("watch-uid-1@example.com");
|
|
203
|
+
} finally {
|
|
204
|
+
await supervisor.stop();
|
|
205
|
+
}
|
|
206
|
+
expect(isWatching(accountId)).toBe(false);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("watch-supervisor — error semantics", () => {
|
|
211
|
+
test("InboundAuthError on fetch marks the account auth_error", async () => {
|
|
212
|
+
const admin = adminFor(4104);
|
|
213
|
+
const accountId = await connectSharedAccount(admin);
|
|
214
|
+
failNextFetchWith(accountId, new InboundAuthError("credentials rejected"));
|
|
215
|
+
|
|
216
|
+
const supervisor = createSupervisor();
|
|
217
|
+
await supervisor.pollOnce();
|
|
218
|
+
|
|
219
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
220
|
+
expect(accounts).toHaveLength(1);
|
|
221
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.authError);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("InboundAuthError on live watch stops the watcher and marks auth_error", async () => {
|
|
225
|
+
const admin = adminFor(4106);
|
|
226
|
+
const accountId = await connectSharedAccount(admin);
|
|
227
|
+
const supervisor = createSupervisor();
|
|
228
|
+
try {
|
|
229
|
+
await supervisor.start();
|
|
230
|
+
await waitFor(() => {
|
|
231
|
+
expect(isWatching(accountId)).toBe(true);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
emitWatchError(accountId, new InboundAuthError("token revoked"));
|
|
235
|
+
|
|
236
|
+
await waitFor(async () => {
|
|
237
|
+
expect(isWatching(accountId)).toBe(false);
|
|
238
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
239
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.authError);
|
|
240
|
+
});
|
|
241
|
+
} finally {
|
|
242
|
+
await supervisor.stop();
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("InboundCursorInvalidError resets the cursor; a later poll still ingests", async () => {
|
|
247
|
+
const admin = adminFor(4105);
|
|
248
|
+
const accountId = await connectSharedAccount(admin);
|
|
249
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "cursor-ok-1" }));
|
|
250
|
+
|
|
251
|
+
const supervisor = createSupervisor();
|
|
252
|
+
await supervisor.pollOnce();
|
|
253
|
+
expect(await selectMany(db, inboundMessagesProjectionTable, { accountId })).toHaveLength(1);
|
|
254
|
+
|
|
255
|
+
failNextFetchWith(accountId, new InboundCursorInvalidError("uidValidity changed"));
|
|
256
|
+
await supervisor.pollOnce();
|
|
257
|
+
|
|
258
|
+
// Account stays active (cursor invalid ≠ auth failure).
|
|
259
|
+
const accounts = await selectMany(db, mailAccountsProjectionTable, { id: accountId });
|
|
260
|
+
expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
|
|
261
|
+
|
|
262
|
+
await seedInboundMessage(accountId, rawMsg({ providerMessageId: "cursor-ok-2" }));
|
|
263
|
+
await supervisor.pollOnce();
|
|
264
|
+
|
|
265
|
+
const rows = await selectMany(db, inboundMessagesProjectionTable, { accountId });
|
|
266
|
+
expect(rows.map((r) => r["messageIdHeader"]).sort()).toEqual([
|
|
267
|
+
"cursor-ok-1@example.com",
|
|
268
|
+
"cursor-ok-2@example.com",
|
|
269
|
+
]);
|
|
270
|
+
});
|
|
271
|
+
});
|