@cosmicdrift/kumiko-bundled-features 0.102.1 → 0.104.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-bundled-features",
3
- "version": "0.102.1",
3
+ "version": "0.104.0",
4
4
  "description": "Built-in features — tenant, user, auth, delivery. The stuff you'd rewrite anyway, already typed.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -92,11 +92,11 @@
92
92
  "./step-dispatcher": "./src/step-dispatcher/index.ts"
93
93
  },
94
94
  "dependencies": {
95
- "@cosmicdrift/kumiko-dispatcher-live": "0.102.1",
96
- "@cosmicdrift/kumiko-framework": "0.102.1",
97
- "@cosmicdrift/kumiko-headless": "0.102.1",
98
- "@cosmicdrift/kumiko-renderer": "0.102.1",
99
- "@cosmicdrift/kumiko-renderer-web": "0.102.1",
95
+ "@cosmicdrift/kumiko-dispatcher-live": "0.104.0",
96
+ "@cosmicdrift/kumiko-framework": "0.104.0",
97
+ "@cosmicdrift/kumiko-headless": "0.104.0",
98
+ "@cosmicdrift/kumiko-renderer": "0.104.0",
99
+ "@cosmicdrift/kumiko-renderer-web": "0.104.0",
100
100
  "@mollie/api-client": "^4.5.0",
101
101
  "@node-rs/argon2": "^2.0.2",
102
102
  "@types/nodemailer": "^8.0.0",
@@ -1,106 +1,103 @@
1
- // Unit-Tests für die default-HTML-Renderer der Reset/Verify-Mails.
2
- // Pure-Functions, kein DOM, kein Mail-Versand nur das Rendering-
3
- // Contract: subject enthält App-Namen, body enthält Reset-URL und
4
- // expiresAt, HTML escaped XSS-Versuche, beide Locales unterscheiden
5
- // sich erwartungsgemäß.
1
+ // Unit-Tests für die structured token-mail-Renderer (reset + verify).
2
+ // Pure-Functions: sie liefern AuthMailContent (subject + header + sections +
3
+ // footer), das delivery's renderer-simple zu HTML rendert — Escaping lebt
4
+ // dort. Diese Tests prüfen daher die strukturierte Content, nicht HTML.
6
5
 
7
6
  import { describe, expect, test } from "bun:test";
7
+ import type { AuthMailContent } from "../email-templates";
8
8
  import { renderResetPasswordEmail, renderVerifyEmail } from "../email-templates";
9
9
 
10
+ function buttonUrl(content: AuthMailContent): string | undefined {
11
+ for (const section of content.sections) {
12
+ if ("button" in section) return section.button.url;
13
+ }
14
+ return undefined;
15
+ }
16
+
17
+ function textOf(content: AuthMailContent): string {
18
+ return content.sections.map((section) => ("text" in section ? section.text : "")).join(" ");
19
+ }
20
+
10
21
  describe("renderResetPasswordEmail", () => {
11
22
  const baseArgs = {
12
- resetUrl: "https://acme.example/reset?token=t-abc123",
23
+ url: "https://acme.example/reset?token=t-abc123",
13
24
  expiresAt: "2026-05-04T13:45:00.000Z",
14
25
  };
15
26
 
16
27
  test("default-locale 'en' + default-appName 'Account'", () => {
17
28
  const out = renderResetPasswordEmail(baseArgs);
18
29
  expect(out.subject).toBe("Account — Reset your password");
19
- expect(out.html).toContain("Reset password");
20
- expect(out.html).toContain(baseArgs.resetUrl);
30
+ expect(out.header).toBe("Reset password");
31
+ expect(buttonUrl(out)).toBe(baseArgs.url);
21
32
  // expiresAt wird zu human-readable-Format formatiert (UTC-pinned).
22
- expect(out.html).toContain("2026-05-04 13:45 UTC");
33
+ expect(textOf(out)).toContain("2026-05-04 13:45 UTC");
23
34
  });
24
35
 
25
36
  test("locale 'de' liefert deutsche Subjects + Body", () => {
26
37
  const out = renderResetPasswordEmail({ ...baseArgs, locale: "de" });
27
38
  expect(out.subject).toContain("Passwort zurücksetzen");
28
- expect(out.html).toContain("Passwort zurücksetzen");
29
- expect(out.html).toContain("Hallo");
39
+ expect(out.header).toBe("Passwort zurücksetzen");
40
+ expect(textOf(out)).toContain("Hallo");
30
41
  });
31
42
 
32
43
  test("appName-Override taucht in subject + body auf", () => {
33
44
  const out = renderResetPasswordEmail({ ...baseArgs, appName: "PublicStatus", locale: "en" });
34
45
  expect(out.subject).toBe("PublicStatus — Reset your password");
35
- expect(out.html).toContain("PublicStatus");
46
+ expect(textOf(out)).toContain("PublicStatus");
36
47
  });
37
48
 
38
- test("escaped potentielle XSS in resetUrl", () => {
39
- // Ein Angreifer der den resetUrl-input kontrollieren würde, würde
40
- // sonst HTML-injection im Mail erreichen. Token-Generation ist
41
- // server-side, aber defense-in-depth schadet nicht.
42
- const out = renderResetPasswordEmail({
43
- ...baseArgs,
44
- resetUrl: 'https://x.example/?token=t"><script>alert(1)</script>',
45
- });
46
- expect(out.html).not.toContain("<script>alert");
47
- expect(out.html).toContain("&quot;");
49
+ test("url kommt unescaped 1:1 durch (renderer-simple escaped beim HTML-Bau)", () => {
50
+ const url = 'https://x.example/?token=t"><script>alert(1)</script>';
51
+ const out = renderResetPasswordEmail({ ...baseArgs, url });
52
+ expect(buttonUrl(out)).toBe(url);
48
53
  });
49
54
 
50
- test("subject + html sind beide non-empty + html startet mit <!DOCTYPE", () => {
55
+ test("footer trägt die ignore-Reassurance", () => {
51
56
  const out = renderResetPasswordEmail(baseArgs);
52
- expect(out.subject.length).toBeGreaterThan(0);
53
- expect(out.html).toMatch(/^<!DOCTYPE html>/);
57
+ expect(out.footer.length).toBeGreaterThan(0);
58
+ expect(out.footer.toLowerCase()).toContain("ignore");
54
59
  });
55
60
  });
56
61
 
57
62
  describe("renderVerifyEmail", () => {
58
63
  const baseArgs = {
59
- verificationUrl: "https://acme.example/verify?token=v-abc123",
64
+ url: "https://acme.example/verify?token=v-abc123",
60
65
  expiresAt: "2026-05-04T13:45:00.000Z",
61
66
  };
62
67
 
63
68
  test("default-locale 'en' + default-appName 'Account'", () => {
64
69
  const out = renderVerifyEmail(baseArgs);
65
70
  expect(out.subject).toBe("Account — Verify your email");
66
- expect(out.html).toContain("Verify email");
67
- expect(out.html).toContain(baseArgs.verificationUrl);
68
- expect(out.html).toContain("2026-05-04 13:45 UTC");
71
+ expect(out.header).toBe("Verify email");
72
+ expect(buttonUrl(out)).toBe(baseArgs.url);
73
+ expect(textOf(out)).toContain("2026-05-04 13:45 UTC");
69
74
  });
70
75
 
71
76
  test("locale 'de' liefert deutsche Subjects + Body", () => {
72
77
  const out = renderVerifyEmail({ ...baseArgs, locale: "de" });
73
78
  expect(out.subject).toContain("E-Mail bestätigen");
74
- expect(out.html).toContain("E-Mail bestätigen");
75
- expect(out.html).toContain("Willkommen");
79
+ expect(out.header).toBe("E-Mail bestätigen");
80
+ expect(textOf(out)).toContain("Willkommen");
76
81
  });
77
82
 
78
83
  test("appName-Override im subject", () => {
79
84
  const out = renderVerifyEmail({ ...baseArgs, appName: "PublicStatus", locale: "en" });
80
85
  expect(out.subject).toBe("PublicStatus — Verify your email");
81
86
  });
82
-
83
- test("escaped XSS in verificationUrl", () => {
84
- const out = renderVerifyEmail({
85
- ...baseArgs,
86
- verificationUrl: 'https://x.example/?token=v"><script>alert(1)</script>',
87
- });
88
- expect(out.html).not.toContain("<script>alert");
89
- });
90
87
  });
91
88
 
92
- describe("Reset vs Verify haben separate subjects + body-Texte", () => {
89
+ describe("Reset vs Verify haben separate subjects + headers", () => {
93
90
  // Sicherheit gegen Copy-Paste-Bugs zwischen den beiden Renderern —
94
- // die sind strukturell ähnlich, aber subjects + body-Intros müssen
95
- // klar unterschiedlich sein damit User die Mails nicht verwechselt.
91
+ // die sind strukturell ähnlich, aber subjects + CTA müssen klar
92
+ // unterschiedlich sein damit User die Mails nicht verwechselt.
96
93
  const args = { expiresAt: "2026-05-04T13:45:00.000Z" };
97
- const reset = renderResetPasswordEmail({ ...args, resetUrl: "https://x/r" });
98
- const verify = renderVerifyEmail({ ...args, verificationUrl: "https://x/v" });
94
+ const reset = renderResetPasswordEmail({ ...args, url: "https://x/r" });
95
+ const verify = renderVerifyEmail({ ...args, url: "https://x/v" });
99
96
  test("subjects unterscheiden sich", () => {
100
97
  expect(reset.subject).not.toBe(verify.subject);
101
98
  });
102
- test("button-labels unterscheiden sich", () => {
103
- expect(reset.html).toContain("Reset password");
104
- expect(verify.html).toContain("Verify email");
99
+ test("header-CTA unterscheiden sich", () => {
100
+ expect(reset.header).toBe("Reset password");
101
+ expect(verify.header).toBe("Verify email");
105
102
  });
106
103
  });
@@ -16,9 +16,15 @@ import {
16
16
  unsafePushTables,
17
17
  } from "@cosmicdrift/kumiko-framework/stack";
18
18
  import { Temporal } from "temporal-polyfill";
19
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
19
20
  import { createConfigFeature } from "../../config";
20
21
  import { createConfigResolver } from "../../config/resolver";
21
22
  import { configValuesTable } from "../../config/table";
23
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
24
+ import { notificationPreferencesTable } from "../../delivery/tables";
25
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
26
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
27
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
22
28
  import { createTenantFeature } from "../../tenant";
23
29
  import { tenantMembershipsTable } from "../../tenant/membership-table";
24
30
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -32,7 +38,9 @@ import { hashPassword } from "../password-hashing";
32
38
  import { signResetToken } from "../reset-token";
33
39
  import { signVerificationToken } from "../verification-token";
34
40
 
35
- const capturedEmails: Array<{ email: string; verificationUrl: string; expiresAt: string }> = [];
41
+ // Verify mails now go through delivery (ctx.notify channel-email); the
42
+ // in-memory transport captures what would be sent.
43
+ const emailTransport = createInMemoryTransport();
36
44
 
37
45
  let stack: TestStack;
38
46
  const systemAdmin = TestUsers.systemAdmin;
@@ -54,16 +62,30 @@ beforeAll(async () => {
54
62
  createConfigFeature(),
55
63
  createUserFeature(),
56
64
  createTenantFeature(),
65
+ createTemplateResolverFeature(),
66
+ createRendererFoundationFeature(),
67
+ createDeliveryFeature(),
68
+ createRendererSimpleFeature(),
69
+ createChannelEmailFeature({
70
+ transport: emailTransport,
71
+ renderer: simpleRenderer,
72
+ resolveEmail: async () => "unused@test.local",
73
+ }),
57
74
  createAuthEmailPasswordFeature({
58
75
  emailVerification: {
59
76
  hmacSecret: verifySecret,
60
77
  tokenTtlMinutes: 60,
61
78
  mode: "strict",
79
+ appUrl: appVerifyUrl,
62
80
  },
63
- passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
81
+ passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15, appUrl: appResetUrl },
64
82
  }),
65
83
  ],
66
- extraContext: { configResolver: resolver, configEncryption: encryption },
84
+ extraContext: (deps) => ({
85
+ ...createDeliveryTestContext(deps),
86
+ configResolver: resolver,
87
+ configEncryption: encryption,
88
+ }),
67
89
  authConfig: {
68
90
  membershipQuery: "tenant:query:memberships",
69
91
  loginHandler: AuthHandlers.login,
@@ -75,23 +97,21 @@ beforeAll(async () => {
75
97
  emailVerification: {
76
98
  requestHandler: AuthHandlers.requestEmailVerification,
77
99
  confirmHandler: AuthHandlers.verifyEmail,
78
- appVerifyUrl,
79
- sendVerificationEmail: async (args) => {
80
- capturedEmails.push(args);
81
- },
82
100
  },
83
101
  passwordReset: {
84
102
  requestHandler: AuthHandlers.requestPasswordReset,
85
103
  confirmHandler: AuthHandlers.resetPassword,
86
- appResetUrl,
87
- sendResetEmail: async () => {},
88
104
  },
89
105
  },
90
106
  });
91
107
 
92
108
  await unsafeCreateEntityTable(stack.db, userEntity);
93
109
  await unsafeCreateEntityTable(stack.db, tenantEntity);
94
- await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
110
+ await unsafePushTables(stack.db, {
111
+ configValuesTable,
112
+ tenantMembershipsTable,
113
+ notificationPreferencesTable,
114
+ });
95
115
  });
96
116
 
97
117
  afterAll(async () => {
@@ -101,7 +121,7 @@ afterAll(async () => {
101
121
  beforeEach(async () => {
102
122
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
103
123
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
104
- capturedEmails.length = 0;
124
+ emailTransport.sent.length = 0;
105
125
  });
106
126
 
107
127
  async function seedUser(opts: {
@@ -144,7 +164,7 @@ async function post(path: string, body: unknown): Promise<Response> {
144
164
  // --- request-email-verification -------------------------------------------
145
165
 
146
166
  describe("POST /auth/request-email-verification", () => {
147
- test("unverified user → 200, email callback invoked with verification URL", async () => {
167
+ test("unverified user → 200, delivery sends mail with verification URL", async () => {
148
168
  await seedUser({ email: "fresh@example.com", password: "pw-initial-1234" });
149
169
 
150
170
  const res = await post("/api/auth/request-email-verification", {
@@ -152,14 +172,14 @@ describe("POST /auth/request-email-verification", () => {
152
172
  });
153
173
 
154
174
  expect(res.status).toBe(200);
155
- expect(capturedEmails).toHaveLength(1);
156
- const [captured] = capturedEmails;
157
- if (!captured) throw new Error("no email captured");
158
- expect(captured.email).toBe("fresh@example.com");
159
- expect(captured.verificationUrl.startsWith(`${appVerifyUrl}?token=`)).toBe(true);
175
+ expect(emailTransport.sent).toHaveLength(1);
176
+ const sent = emailTransport.sent[0];
177
+ if (!sent) throw new Error("no email sent");
178
+ expect(sent.to).toBe("fresh@example.com");
179
+ expect(sent.html).toContain(`${appVerifyUrl}?token=`);
160
180
  });
161
181
 
162
- test("already-verified user → 200, NO callback (enumeration-safe)", async () => {
182
+ test("already-verified user → 200, NO mail (enumeration-safe)", async () => {
163
183
  await seedUser({
164
184
  email: "done@example.com",
165
185
  password: "pw-already-1234",
@@ -171,15 +191,15 @@ describe("POST /auth/request-email-verification", () => {
171
191
  });
172
192
 
173
193
  expect(res.status).toBe(200);
174
- expect(capturedEmails).toHaveLength(0);
194
+ expect(emailTransport.sent).toHaveLength(0);
175
195
  });
176
196
 
177
- test("unknown email → 200, NO callback (enumeration-safe)", async () => {
197
+ test("unknown email → 200, NO mail (enumeration-safe)", async () => {
178
198
  const res = await post("/api/auth/request-email-verification", {
179
199
  email: "ghost@example.com",
180
200
  });
181
201
  expect(res.status).toBe(200);
182
- expect(capturedEmails).toHaveLength(0);
202
+ expect(emailTransport.sent).toHaveLength(0);
183
203
  });
184
204
  });
185
205
 
@@ -9,8 +9,8 @@
9
9
  //
10
10
  // Flow pro Test:
11
11
  // 1. Admin invitet email → invite-create (Admin-Auth)
12
- // 2. Mail captured durch sendInviteEmail-callback
13
- // 3. Token aus Activation-URL extrahieren
12
+ // 2. Invite-Mail via delivery (in-memory transport) an den Invitee
13
+ // 3. Token aus dem Mail-HTML extrahieren (NICHT aus dem Admin-Result)
14
14
  // 4. Branch-spezifischer Accept-Endpoint
15
15
  // 5. DB-State + Membership + Cookies/JWT verifizieren
16
16
 
@@ -28,9 +28,15 @@ import {
28
28
  unsafeCreateEntityTable,
29
29
  unsafePushTables,
30
30
  } from "@cosmicdrift/kumiko-framework/stack";
31
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
31
32
  import { createConfigFeature } from "../../config";
32
33
  import { createConfigResolver } from "../../config/resolver";
33
34
  import { configValuesTable } from "../../config/table";
35
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
36
+ import { notificationPreferencesTable } from "../../delivery/tables";
37
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
38
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
39
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
34
40
  import { createTenantFeature } from "../../tenant";
35
41
  import { tenantInvitationEntity, tenantInvitationsTable } from "../../tenant/invitation-table";
36
42
  import { tenantMembershipsTable } from "../../tenant/membership-table";
@@ -50,12 +56,10 @@ const CAROL_EMAIL = "carol@example.com";
50
56
  const BOB_PASSWORD = "bob-existing-pw-1234";
51
57
  const CAROL_PASSWORD = "carol-new-pw-1234";
52
58
 
53
- const capturedInviteEmails: Array<{
54
- email: string;
55
- inviteUrl: string;
56
- expiresAt: string;
57
- role: string;
58
- }> = [];
59
+ // Invite mails now go through delivery (ctx.notify → channel-email). The
60
+ // in-memory transport captures what would be sent; route:{email} delivers
61
+ // directly (no jobRunner in the test stack → inline send).
62
+ const emailTransport = createInMemoryTransport();
59
63
 
60
64
  let stack: TestStack;
61
65
  let aliceId: string;
@@ -77,17 +81,37 @@ const GUEST: SessionUser = {
77
81
  roles: ["all"],
78
82
  };
79
83
 
84
+ function extractTokenFromMail(html: string): string {
85
+ const match = html.match(/[?&]token=([^&"'<\s]+)/);
86
+ if (!match?.[1]) throw new Error(`No token in invite mail html: ${html.slice(0, 200)}`);
87
+ return decodeURIComponent(match[1]);
88
+ }
89
+
80
90
  beforeAll(async () => {
81
91
  stack = await setupTestStack({
82
92
  features: [
83
93
  createConfigFeature(),
84
94
  createUserFeature(),
85
95
  createTenantFeature(),
96
+ createTemplateResolverFeature(),
97
+ createRendererFoundationFeature(),
98
+ createDeliveryFeature(),
99
+ createRendererSimpleFeature(),
100
+ createChannelEmailFeature({
101
+ transport: emailTransport,
102
+ renderer: simpleRenderer,
103
+ // route:{email} delivers directly — resolveEmail (userId→address) is
104
+ // never hit by the invite flow, but the channel requires it.
105
+ resolveEmail: async () => "unused@test.local",
106
+ }),
86
107
  createAuthEmailPasswordFeature({
87
- invite: { tokenTtlMinutes: 60 },
108
+ invite: { tokenTtlMinutes: 60, appUrl: APP_ACCEPT_URL },
88
109
  }),
89
110
  ],
90
- extraContext: { configResolver: createConfigResolver() },
111
+ extraContext: (deps) => ({
112
+ ...createDeliveryTestContext(deps),
113
+ configResolver: createConfigResolver(),
114
+ }),
91
115
  authConfig: {
92
116
  membershipQuery: "tenant:query:memberships",
93
117
  loginHandler: AuthHandlers.login,
@@ -95,10 +119,6 @@ beforeAll(async () => {
95
119
  acceptHandler: AuthHandlers.inviteAccept,
96
120
  acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
97
121
  signupCompleteHandler: AuthHandlers.inviteSignupComplete,
98
- appAcceptUrl: APP_ACCEPT_URL,
99
- sendInviteEmail: async (args) => {
100
- capturedInviteEmails.push(args);
101
- },
102
122
  },
103
123
  },
104
124
  });
@@ -106,7 +126,11 @@ beforeAll(async () => {
106
126
  await unsafeCreateEntityTable(stack.db, userEntity);
107
127
  await unsafeCreateEntityTable(stack.db, tenantEntity);
108
128
  await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
109
- await unsafePushTables(stack.db, { configValuesTable, tenantMembershipsTable });
129
+ await unsafePushTables(stack.db, {
130
+ configValuesTable,
131
+ tenantMembershipsTable,
132
+ notificationPreferencesTable,
133
+ });
110
134
  });
111
135
 
112
136
  afterAll(async () => {
@@ -118,7 +142,7 @@ beforeEach(async () => {
118
142
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
119
143
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
120
144
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
121
- capturedInviteEmails.length = 0;
145
+ emailTransport.sent.length = 0;
122
146
  const allKeys = await stack.redis.redis.keys("invite:*");
123
147
  if (allKeys.length > 0) await stack.redis.redis.del(...allKeys);
124
148
 
@@ -183,31 +207,35 @@ async function authedRaw(
183
207
  }
184
208
 
185
209
  async function inviteEmail(email: string, role: string): Promise<string> {
186
- // invite-create geht via /api/write (Admin-Auth via JWT). Token kommt
187
- // direkt aus dem handler-result; sendInviteEmail-callback ist die
188
- // optionale Mail-Side, die in production-Setups separat von der
189
- // Sample-App aufgerufen wird (NICHT framework-route — invite-create
190
- // ist Admin-only und somit kein Magic-Link-Pattern wie signup-request).
191
- const result = (await stack.http.writeOk(
192
- AuthHandlers.inviteCreate,
193
- { email, role },
194
- aliceSession(),
195
- )) as { token: string };
196
- return result.token;
210
+ // invite-create geht via /api/write (Admin-Auth via JWT). Der Handler
211
+ // dispatcht die Invite-Mail via delivery; der Token erreicht den Invitee
212
+ // NUR über die Mail (das Admin-Result enthält ihn nicht mehr).
213
+ await stack.http.writeOk(AuthHandlers.inviteCreate, { email, role }, aliceSession());
214
+ const sent = emailTransport.sent.at(-1);
215
+ if (!sent) throw new Error("invite-create didn't send a mail");
216
+ return extractTokenFromMail(sent.html);
197
217
  }
198
218
 
199
219
  describe("invite-create", () => {
200
- test("Admin invitet → invitation row + token in result", async () => {
220
+ test("Admin invitet → invitation row + delivery sends mail with token URL", async () => {
201
221
  const result = (await stack.http.writeOk(
202
222
  AuthHandlers.inviteCreate,
203
223
  { email: BOB_EMAIL, role: "Admin" },
204
224
  aliceSession(),
205
- )) as { invitationId: string; email: string; role: string; token: string };
225
+ )) as { invitationId: string; email: string; role: string };
206
226
 
207
227
  expect(result.email).toBe(BOB_EMAIL);
208
228
  expect(result.role).toBe("Admin");
209
- expect(result.token).toBeTruthy();
210
- expect(result.token.length).toBeGreaterThanOrEqual(16);
229
+ // Der Token geht NICHT an den Admin zurück (er soll die Annahme nicht
230
+ // impersonieren können) — nur an den Invitee per Mail.
231
+ expect((result as { token?: string }).token).toBeUndefined();
232
+
233
+ expect(emailTransport.sent).toHaveLength(1);
234
+ const sent = emailTransport.sent[0];
235
+ if (!sent) throw new Error("no mail sent");
236
+ expect(sent.to).toBe(BOB_EMAIL);
237
+ expect(sent.html).toContain(`${APP_ACCEPT_URL}?token=`);
238
+ expect(sent.html).toContain("Admin");
211
239
 
212
240
  const rows = await selectMany(stack.db, tenantInvitationsTable, { email: BOB_EMAIL });
213
241
  expect(rows).toHaveLength(1);
@@ -442,7 +470,7 @@ describe("privilege escalation via invite role", () => {
442
470
  // passed every SystemAdmin gate cross-tenant.
443
471
  const FORBIDDEN_ROLES = ["SystemAdmin", "system", "all", "anonymous"];
444
472
 
445
- test("invite-create rejects reserved/global roles — no invitation persisted", async () => {
473
+ test("invite-create rejects reserved/global roles — no invitation persisted, no mail", async () => {
446
474
  for (const role of FORBIDDEN_ROLES) {
447
475
  const err = await stack.http.writeErr(
448
476
  AuthHandlers.inviteCreate,
@@ -453,6 +481,8 @@ describe("privilege escalation via invite role", () => {
453
481
  const rows = await selectMany(stack.db, tenantInvitationsTable, { email: CAROL_EMAIL });
454
482
  expect(rows).toHaveLength(0);
455
483
  }
484
+ // The forbidden-role check fires before the mail dispatch.
485
+ expect(emailTransport.sent).toHaveLength(0);
456
486
  });
457
487
 
458
488
  test("legitimate tenant role still issues an invitation", async () => {
@@ -11,10 +11,16 @@ import {
11
11
  unsafePushTables,
12
12
  } from "@cosmicdrift/kumiko-framework/stack";
13
13
  import { Temporal } from "temporal-polyfill";
14
+ import { createChannelEmailFeature, createInMemoryTransport } from "../../channel-email";
14
15
  import { createConfigFeature } from "../../config";
15
16
  import { createConfigResolver } from "../../config/resolver";
16
17
  import { configValuesTable } from "../../config/table";
18
+ import { createDeliveryFeature, createDeliveryTestContext } from "../../delivery";
19
+ import { notificationPreferencesTable } from "../../delivery/tables";
20
+ import { createRendererFoundationFeature } from "../../renderer-foundation/feature";
21
+ import { createRendererSimpleFeature, simpleRenderer } from "../../renderer-simple";
17
22
  import { createSessionsFeature, userSessionTable } from "../../sessions";
23
+ import { createTemplateResolverFeature } from "../../template-resolver/feature";
18
24
  import { createTenantFeature } from "../../tenant";
19
25
  import { tenantMembershipsTable } from "../../tenant/membership-table";
20
26
  import { tenantEntity } from "../../tenant/schema/tenant";
@@ -27,9 +33,10 @@ import { createAuthEmailPasswordFeature } from "../feature";
27
33
  import { hashPassword, verifyPassword } from "../password-hashing";
28
34
  import { signResetToken } from "../reset-token";
29
35
 
30
- // Signed tokens are forwarded out-of-band (email). In the test we grab them
31
- // from the sendResetEmail callback instead.
32
- const capturedEmails: Array<{ email: string; resetUrl: string; expiresAt: string }> = [];
36
+ // Reset mails now go through delivery (ctx.notify → channel-email). The
37
+ // in-memory transport captures what would be sent; route:{email} delivers
38
+ // directly (no jobRunner in the test stack inline send).
39
+ const emailTransport = createInMemoryTransport();
33
40
 
34
41
  // Records the userId every time the sessions feature's auto-revoke hook
35
42
  // fires after a password change. The session-revoke tests assert on this
@@ -51,8 +58,19 @@ beforeAll(async () => {
51
58
  createConfigFeature(),
52
59
  createUserFeature(),
53
60
  createTenantFeature(),
61
+ createTemplateResolverFeature(),
62
+ createRendererFoundationFeature(),
63
+ createDeliveryFeature(),
64
+ createRendererSimpleFeature(),
65
+ createChannelEmailFeature({
66
+ transport: emailTransport,
67
+ renderer: simpleRenderer,
68
+ // route:{email} delivers directly — resolveEmail (userId→address) is
69
+ // never hit by the reset flow, but the channel requires it.
70
+ resolveEmail: async () => "unused@test.local",
71
+ }),
54
72
  createAuthEmailPasswordFeature({
55
- passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15 },
73
+ passwordReset: { hmacSecret: resetSecret, tokenTtlMinutes: 15, appUrl: appResetUrl },
56
74
  }),
57
75
  // Sessions feature wires the cross-feature entityHook on
58
76
  // "user.postSave" that triggers autoRevokeOnPasswordChange whenever
@@ -65,17 +83,17 @@ beforeAll(async () => {
65
83
  },
66
84
  }),
67
85
  ],
68
- extraContext: { configResolver: resolver, configEncryption: encryption },
86
+ extraContext: (deps) => ({
87
+ ...createDeliveryTestContext(deps),
88
+ configResolver: resolver,
89
+ configEncryption: encryption,
90
+ }),
69
91
  authConfig: {
70
92
  membershipQuery: "tenant:query:memberships",
71
93
  loginHandler: AuthHandlers.login,
72
94
  passwordReset: {
73
95
  requestHandler: AuthHandlers.requestPasswordReset,
74
96
  confirmHandler: AuthHandlers.resetPassword,
75
- appResetUrl,
76
- sendResetEmail: async (args) => {
77
- capturedEmails.push(args);
78
- },
79
97
  },
80
98
  },
81
99
  });
@@ -86,6 +104,7 @@ beforeAll(async () => {
86
104
  configValuesTable,
87
105
  tenantMembershipsTable,
88
106
  userSessionTable,
107
+ notificationPreferencesTable,
89
108
  });
90
109
  });
91
110
 
@@ -97,7 +116,7 @@ beforeEach(async () => {
97
116
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
98
117
  await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
99
118
  await asRawClient(stack.db).unsafe(`DELETE FROM "${userSessionTable.tableName}"`);
100
- capturedEmails.length = 0;
119
+ emailTransport.sent.length = 0;
101
120
  autoRevokeCalls.length = 0;
102
121
  });
103
122
 
@@ -132,34 +151,34 @@ async function post(path: string, body: unknown): Promise<Response> {
132
151
  // --- request-password-reset -----------------------------------------------
133
152
 
134
153
  describe("POST /auth/request-password-reset", () => {
135
- test("known email → 200, email callback invoked with reset URL", async () => {
154
+ test("known email → 200, delivery sends mail with reset URL", async () => {
136
155
  await seedUser({ email: "alice@example.com", password: "initial-pw!" });
137
156
 
138
157
  const res = await post("/api/auth/request-password-reset", { email: "alice@example.com" });
139
158
 
140
159
  expect(res.status).toBe(200);
141
160
  expect(await res.json()).toEqual({ isSuccess: true });
142
- expect(capturedEmails).toHaveLength(1);
143
- const [captured] = capturedEmails;
144
- if (!captured) throw new Error("no email captured");
145
- expect(captured.email).toBe("alice@example.com");
146
- expect(captured.resetUrl.startsWith(`${appResetUrl}?token=`)).toBe(true);
147
- expect(typeof captured.expiresAt).toBe("string");
161
+ expect(emailTransport.sent).toHaveLength(1);
162
+ const sent = emailTransport.sent[0];
163
+ if (!sent) throw new Error("no email sent");
164
+ expect(sent.to).toBe("alice@example.com");
165
+ expect(sent.subject).toContain("Reset");
166
+ expect(sent.html).toContain(`${appResetUrl}?token=`);
148
167
  });
149
168
 
150
- test("unknown email → 200 with NO sendResetEmail side-effect (enumeration-safe)", async () => {
169
+ test("unknown email → 200 with NO mail sent (enumeration-safe)", async () => {
151
170
  const res = await post("/api/auth/request-password-reset", { email: "ghost@example.com" });
152
171
 
153
172
  expect(res.status).toBe(200);
154
173
  expect(await res.json()).toEqual({ isSuccess: true });
155
- expect(capturedEmails).toHaveLength(0);
174
+ expect(emailTransport.sent).toHaveLength(0);
156
175
  });
157
176
 
158
177
  test("malformed body → 200 (silent success, no enumeration via error shape)", async () => {
159
178
  const res = await post("/api/auth/request-password-reset", { wrong: "shape" });
160
179
  expect(res.status).toBe(200);
161
180
  expect(await res.json()).toEqual({ isSuccess: true });
162
- expect(capturedEmails).toHaveLength(0);
181
+ expect(emailTransport.sent).toHaveLength(0);
163
182
  });
164
183
  });
165
184