@cosmicdrift/kumiko-bundled-features 0.154.1 → 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.
@@ -0,0 +1,306 @@
1
+ // Full-stack IMAP integration: setupTestStack + secrets + inbound-provider-imap
2
+ // + watch-supervisor against greenmail. Proves the production path
3
+ // (connect → credential secret → poll fetch → ingest projection), not the
4
+ // plugin-only live suite in imap-live.integration.test.ts.
5
+ //
6
+ // Opt-in via greenmail (same as imap-live):
7
+ // docker start cdgs-greenmail
8
+ // # or: docker run -d --name cdgs-greenmail -p 3025:3025 -p 3143:3143 \
9
+ // # -e GREENMAIL_OPTS="-Dgreenmail.setup.test.all -Dgreenmail.users=testuser:testpass@example.com" \
10
+ // # greenmail/standalone:2.1.0
11
+ //
12
+ // When greenmail is down the suite skips (visible), never fakes IMAP.
13
+
14
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
15
+ import { randomBytes } from "node:crypto";
16
+ import { connect } from "node:net";
17
+ import { ROLES } from "@cosmicdrift/kumiko-framework/auth";
18
+ import { selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
19
+ import {
20
+ configurePiiSubjectKms,
21
+ InMemoryKmsAdapter,
22
+ resetPiiSubjectKmsForTests,
23
+ } from "@cosmicdrift/kumiko-framework/crypto";
24
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
+ import { createSystemUser, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
26
+ import { createEventsTable } from "@cosmicdrift/kumiko-framework/event-store";
27
+ import { createEnvMasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
28
+ import {
29
+ createTestUser,
30
+ setupTestStack,
31
+ type TestStack,
32
+ testTenantId,
33
+ unsafeCreateEntityTable,
34
+ unsafePushTables,
35
+ } from "@cosmicdrift/kumiko-framework/stack";
36
+ import {
37
+ createMutableMasterKeyProvider,
38
+ type MutableMasterKeyProvider,
39
+ waitFor,
40
+ } from "@cosmicdrift/kumiko-framework/testing";
41
+ import { createTransport } from "nodemailer";
42
+ import {
43
+ createComplianceProfilesFeature,
44
+ tenantComplianceProfileEntity,
45
+ } from "../../compliance-profiles";
46
+ import { createConfigFeature } from "../../config";
47
+ import {
48
+ createInboundMailSupervisor,
49
+ InboundMailAccountStatuses,
50
+ InboundMailFoundationHandlers,
51
+ InboundMailFoundationQueries,
52
+ inboundCredentialSecretKey,
53
+ inboundMailFoundationFeature,
54
+ mailAccountsProjectionTable,
55
+ seenMessageEntity,
56
+ syncCursorEntity,
57
+ } from "../../inbound-mail-foundation";
58
+ import { createSecretsContext, createSecretsFeature, tenantSecretsTable } from "../../secrets";
59
+ import { createTenantFeature } from "../../tenant/feature";
60
+ import { createTenantLifecycleFeature } from "../../tenant-lifecycle";
61
+ import { inboundProviderImapFeature } from "../feature";
62
+
63
+ const HOST = process.env["IMAP_LIVE_HOST"] ?? "127.0.0.1";
64
+ const IMAP_PORT = Number(process.env["IMAP_LIVE_PORT"] ?? 3143);
65
+ const SMTP_PORT = Number(process.env["IMAP_LIVE_SMTP_PORT"] ?? 3025);
66
+ const USER = "testuser";
67
+ const PASSWORD = "testpass";
68
+ const ADDRESS = "testuser@example.com";
69
+
70
+ function probe(host: string, port: number): Promise<boolean> {
71
+ return new Promise((resolve) => {
72
+ const socket = connect({ host, port, timeout: 1500 });
73
+ socket.once("connect", () => {
74
+ socket.destroy();
75
+ resolve(true);
76
+ });
77
+ socket.once("error", () => resolve(false));
78
+ socket.once("timeout", () => {
79
+ socket.destroy();
80
+ resolve(false);
81
+ });
82
+ });
83
+ }
84
+
85
+ const available = await probe(HOST, IMAP_PORT);
86
+ const liveTest = available ? test : test.skip;
87
+ if (!available) {
88
+ console.warn(
89
+ `imap-foundation: kein IMAP-Server auf ${HOST}:${IMAP_PORT} — Suite wird geskippt (greenmail starten)`,
90
+ );
91
+ }
92
+
93
+ let stack: TestStack;
94
+ let db: DbConnection;
95
+ let secrets: ReturnType<typeof createSecretsContext>;
96
+ let providerRef: MutableMasterKeyProvider;
97
+
98
+ beforeAll(async () => {
99
+ const initialKp = createEnvMasterKeyProvider({
100
+ env: {
101
+ KUMIKO_SECRETS_MASTER_KEY_V1: randomBytes(32).toString("base64"),
102
+ KUMIKO_SECRETS_MASTER_KEY_CURRENT_VERSION: "1",
103
+ },
104
+ });
105
+ providerRef = createMutableMasterKeyProvider(initialKp);
106
+
107
+ stack = await setupTestStack({
108
+ features: [
109
+ createConfigFeature(),
110
+ createTenantFeature(),
111
+ createSecretsFeature(),
112
+ createComplianceProfilesFeature(),
113
+ createTenantLifecycleFeature(),
114
+ inboundMailFoundationFeature,
115
+ inboundProviderImapFeature,
116
+ ],
117
+ masterKeyProvider: providerRef,
118
+ extraContext: ({ db: stackDb }) => ({
119
+ secrets: createSecretsContext({ db: stackDb, masterKeyProvider: providerRef }),
120
+ }),
121
+ });
122
+ db = stack.db;
123
+ secrets = createSecretsContext({ db, masterKeyProvider: providerRef });
124
+
125
+ await createEventsTable(db);
126
+ await unsafeCreateEntityTable(db, tenantComplianceProfileEntity);
127
+ await unsafeCreateEntityTable(db, syncCursorEntity);
128
+ await unsafeCreateEntityTable(db, seenMessageEntity);
129
+ await unsafePushTables(db, { tenant_secrets: tenantSecretsTable });
130
+ configurePiiSubjectKms(new InMemoryKmsAdapter());
131
+ });
132
+
133
+ afterAll(async () => {
134
+ await stack.cleanup();
135
+ resetPiiSubjectKmsForTests();
136
+ });
137
+
138
+ function adminFor(tenantNumber: number) {
139
+ return createTestUser({
140
+ id: tenantNumber,
141
+ tenantId: testTenantId(tenantNumber),
142
+ roles: ["TenantAdmin", "SystemAdmin"],
143
+ });
144
+ }
145
+
146
+ function credentialJson(): string {
147
+ return JSON.stringify({
148
+ host: HOST,
149
+ port: IMAP_PORT,
150
+ secure: false,
151
+ user: USER,
152
+ password: PASSWORD,
153
+ });
154
+ }
155
+
156
+ async function sendTestMail(subject: string, text: string): Promise<void> {
157
+ const transport = createTransport({ host: HOST, port: SMTP_PORT, secure: false });
158
+ await transport.sendMail({
159
+ from: "Sender <sender@example.com>",
160
+ to: ADDRESS,
161
+ subject,
162
+ text,
163
+ });
164
+ transport.close();
165
+ }
166
+
167
+ function createSupervisor() {
168
+ return createInboundMailSupervisor({
169
+ providerCtx: {
170
+ registry: stack.registry,
171
+ secrets,
172
+ // Worker-identity for requireSecretsContext audit on IMAP credential reads.
173
+ _userId: "imap-foundation-supervisor",
174
+ },
175
+ db,
176
+ dispatchWrite: ({ handlerQn, payload, tenantId }) =>
177
+ stack.dispatcher.write(
178
+ handlerQn,
179
+ payload,
180
+ createSystemUser(tenantId as TenantId, [ROLES.SystemAdmin]),
181
+ ),
182
+ pollIntervalMs: 60_000,
183
+ });
184
+ }
185
+
186
+ describe("imap-foundation — greenmail + supervisor", () => {
187
+ liveTest(
188
+ "connect + credential secret + pollOnce ingests a real IMAP message",
189
+ async () => {
190
+ const admin = adminFor(4301);
191
+ const connected = (await stack.http.writeOk(
192
+ InboundMailFoundationHandlers.connectAccount,
193
+ {
194
+ provider: "imap",
195
+ authMethod: "password",
196
+ displayName: "Greenmail Inbox",
197
+ address: ADDRESS,
198
+ scope: "shared",
199
+ },
200
+ admin,
201
+ )) as { accountId: string };
202
+
203
+ await secrets.set(
204
+ admin.tenantId,
205
+ inboundCredentialSecretKey(connected.accountId),
206
+ credentialJson(),
207
+ {
208
+ redact: (plaintext) => `${plaintext.slice(0, 4)}…`,
209
+ hint: "IMAP live test credentials",
210
+ },
211
+ );
212
+
213
+ const supervisor = createSupervisor();
214
+ // Drain existing mailbox first so a dirty greenmail INBOX (>maxMessages
215
+ // backfill budget) cannot hide the message we are about to send.
216
+ await supervisor.pollOnce();
217
+
218
+ const subject = `foundation-poll-${crypto.randomUUID()}`;
219
+ await sendTestMail(subject, "Hallo aus dem Foundation-Live-Test");
220
+
221
+ await waitFor(async () => {
222
+ await supervisor.pollOnce();
223
+ const list = (await stack.http.queryOk(
224
+ InboundMailFoundationQueries.listMessages,
225
+ { accountId: connected.accountId },
226
+ admin,
227
+ )) as { rows: Array<Record<string, unknown>> };
228
+ expect(list.rows.some((r) => r["subject"] === subject)).toBe(true);
229
+ });
230
+
231
+ const accounts = await selectMany(db, mailAccountsProjectionTable, {
232
+ id: connected.accountId,
233
+ });
234
+ expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.active);
235
+ expect(accounts[0]?.["provider"]).toBe("imap");
236
+ },
237
+ 45_000,
238
+ );
239
+
240
+ liveTest(
241
+ "wrong IMAP password → pollOnce marks account auth_error",
242
+ async () => {
243
+ const admin = adminFor(4302);
244
+ const connected = (await stack.http.writeOk(
245
+ InboundMailFoundationHandlers.connectAccount,
246
+ {
247
+ provider: "imap",
248
+ authMethod: "password",
249
+ displayName: "Bad Creds",
250
+ address: ADDRESS,
251
+ scope: "shared",
252
+ },
253
+ admin,
254
+ )) as { accountId: string };
255
+
256
+ await secrets.set(
257
+ admin.tenantId,
258
+ inboundCredentialSecretKey(connected.accountId),
259
+ JSON.stringify({
260
+ host: HOST,
261
+ port: IMAP_PORT,
262
+ secure: false,
263
+ user: USER,
264
+ password: "definitely-wrong",
265
+ }),
266
+ );
267
+
268
+ const supervisor = createSupervisor();
269
+ await supervisor.pollOnce();
270
+
271
+ const accounts = await selectMany(db, mailAccountsProjectionTable, {
272
+ id: connected.accountId,
273
+ });
274
+ expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.authError);
275
+ },
276
+ 30_000,
277
+ );
278
+
279
+ liveTest(
280
+ "missing credential secret → pollOnce marks account auth_error",
281
+ async () => {
282
+ const admin = adminFor(4303);
283
+ const connected = (await stack.http.writeOk(
284
+ InboundMailFoundationHandlers.connectAccount,
285
+ {
286
+ provider: "imap",
287
+ authMethod: "password",
288
+ displayName: "No Secret",
289
+ address: ADDRESS,
290
+ scope: "shared",
291
+ },
292
+ admin,
293
+ )) as { accountId: string };
294
+
295
+ // No secrets.set — production path when connect succeeded but slot empty.
296
+ const supervisor = createSupervisor();
297
+ await supervisor.pollOnce();
298
+
299
+ const accounts = await selectMany(db, mailAccountsProjectionTable, {
300
+ id: connected.accountId,
301
+ });
302
+ expect(accounts[0]?.["status"]).toBe(InboundMailAccountStatuses.authError);
303
+ },
304
+ 30_000,
305
+ );
306
+ });
@@ -272,6 +272,57 @@ describe("legal-pages :: SYSTEM_TENANT-routing (production-bug-regression)", ()
272
272
  });
273
273
  });
274
274
 
275
+ describe("legal-pages :: wrapLayout erhält route.slug (alt-lang-switch-regression)", () => {
276
+ test("custom wrapLayout bekommt den passenden slug pro Route, nicht immer imprint", async () => {
277
+ const seenSlugs: (string | undefined)[] = [];
278
+ const customStack = await setupTestStack({
279
+ features: [
280
+ createTextContentFeature(),
281
+ createLegalPagesFeature({
282
+ wrapLayout: (opts) => {
283
+ seenSlugs.push(opts.slug);
284
+ return `<html data-slug="${opts.slug ?? ""}">${opts.bodyHtml}</html>`;
285
+ },
286
+ }),
287
+ ],
288
+ anonymousAccess: { defaultTenantId: SYSTEM_TENANT_ID },
289
+ extraContext: ({ db }) => ({
290
+ textContent: createTextContentApi(db),
291
+ }),
292
+ });
293
+ try {
294
+ await unsafeCreateEntityTable(customStack.db, textBlockEntity);
295
+ await createEventsTable(customStack.db);
296
+ await seedTextBlock(customStack.db, {
297
+ tenantId: SYSTEM_TENANT_ID,
298
+ slug: "imprint",
299
+ lang: "de",
300
+ title: "Impressum",
301
+ body: "Body",
302
+ });
303
+ await seedTextBlock(customStack.db, {
304
+ tenantId: SYSTEM_TENANT_ID,
305
+ slug: "privacy",
306
+ lang: "de",
307
+ title: "Datenschutzerklärung",
308
+ body: "Body",
309
+ });
310
+
311
+ const imprintRes = await customStack.app.request("/legal/impressum");
312
+ expect(imprintRes.status).toBe(200);
313
+ expect(await imprintRes.text()).toContain('data-slug="imprint"');
314
+
315
+ const privacyRes = await customStack.app.request("/legal/datenschutz");
316
+ expect(privacyRes.status).toBe(200);
317
+ expect(await privacyRes.text()).toContain('data-slug="privacy"');
318
+
319
+ expect(seenSlugs).toEqual(["imprint", "privacy"]);
320
+ } finally {
321
+ await customStack.cleanup();
322
+ }
323
+ });
324
+ });
325
+
275
326
  describe("legal-pages :: runLegalPagesBootCheck (direct unit-tests)", () => {
276
327
  // Direkter Test der Boot-Check-Logik mit constructed ctx-Objects —
277
328
  // keine JobRunner-Coupling, keine Test-Stacks. Das ist die echte
@@ -50,6 +50,7 @@ export type LegalPagesWrapLayout = (opts: {
50
50
  readonly title: string;
51
51
  readonly bodyHtml: string;
52
52
  readonly lang: string;
53
+ readonly slug?: "imprint" | "privacy" | "terms";
53
54
  }) => string;
54
55
 
55
56
  export type LegalPagesOptions = {
@@ -98,6 +99,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
98
99
  SYSTEM_TENANT_ID,
99
100
  )) as TextBlockQueryResult;
100
101
  } catch (err) {
102
+ // biome-ignore lint/suspicious/noConsole: ops-visible fallback, no logger wired into HttpRouteHandlerDeps
101
103
  console.error(`legal-pages: text-content query failed for slug="${route.slug}"`, err);
102
104
  return c.text("legal page unavailable", 503);
103
105
  }
@@ -134,6 +136,7 @@ export function createLegalPagesFeature(opts: LegalPagesOptions = {}): FeatureDe
134
136
  title: data.title || route.titleFallback,
135
137
  bodyHtml: renderMarkdownToHtml(data.body),
136
138
  lang: route.lang,
139
+ slug: route.slug,
137
140
  });
138
141
 
139
142
  return cachedSecurePageResponse(c.req.raw, {
@@ -132,7 +132,7 @@ export function createSessionsFeature(options?: SessionsFeatureOptions): Feature
132
132
  // we want to skip on. Works for both direct user:update calls and any
133
133
  // other handler that happens to write the column.
134
134
  let autoRevoke = options?.autoRevokeOnPasswordChange;
135
- r.entityHook("postSave", "user", async (ctx) => {
135
+ r.hook("postSave", { allOf: "user" }, async (ctx) => {
136
136
  // skip: nothing bound — stateless-JWT deployments without a runtime
137
137
  // that calls bindAutoRevokeOnPasswordChange keep the old behavior.
138
138
  if (!autoRevoke) return;
@@ -290,7 +290,7 @@ export function createTierEngineFeature<
290
290
  };
291
291
 
292
292
  // Invalidation: tier-assignment events update the cache.
293
- r.entityHook("postSave", "tier-assignment", async (result) => {
293
+ r.hook("postSave", { allOf: "tier-assignment" }, async (result) => {
294
294
  // result.data has tenantId + tier (after entity-update merge)
295
295
  const data = result.data as { tenantId?: unknown; tier?: unknown }; // @cast-boundary engine-payload
296
296
  // skip: defensive type-guard auf payload-shape. Bei korrekt gerenderten
@@ -302,7 +302,7 @@ export function createTierEngineFeature<
302
302
  const tenantId = data.tenantId as TenantId;
303
303
  cache.set(tenantId, mergeAlwaysOn(alwaysOnHolder.set, featuresForTier(tierMap, data.tier)));
304
304
  });
305
- r.entityHook("postDelete", "tier-assignment", async (payload) => {
305
+ r.hook("postDelete", { allOf: "tier-assignment" }, async (payload) => {
306
306
  const data = payload.data as { tenantId?: unknown }; // @cast-boundary engine-payload
307
307
  // skip: gleiche type-guard semantik wie postSave-hook oben.
308
308
  if (typeof data.tenantId !== "string") return;
@@ -323,9 +323,9 @@ export function createTierEngineFeature<
323
323
  // neuer Tenant (Memory `feedback_event_store_tenant_consistency`).
324
324
  if (opts.defaultTier !== undefined) {
325
325
  const defaultTier = opts.defaultTier;
326
- r.entityHook(
326
+ r.hook(
327
327
  "postSave",
328
- "tenant",
328
+ { allOf: "tenant" },
329
329
  async (result, ctx) => {
330
330
  // result-shape: kumiko-framework's SaveContext mit isNew + data
331
331
  const saveResult = result as { isNew?: unknown; data?: unknown }; // @cast-boundary engine-payload