@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 +6 -6
- package/src/auth-email-password/__tests__/email-templates.test.ts +44 -47
- package/src/auth-email-password/__tests__/email-verification.integration.test.ts +41 -21
- package/src/auth-email-password/__tests__/invite-flow.integration.test.ts +62 -32
- package/src/auth-email-password/__tests__/password-reset.integration.test.ts +39 -20
- package/src/auth-email-password/__tests__/public-routes-rate-limit.integration.test.ts +39 -8
- package/src/auth-email-password/__tests__/signup-flow.integration.test.ts +53 -36
- package/src/auth-email-password/auth-paths.ts +24 -0
- package/src/auth-email-password/email-templates.ts +59 -118
- package/src/auth-email-password/feature.ts +19 -1
- package/src/auth-email-password/handlers/invite-create.write.ts +33 -5
- package/src/auth-email-password/handlers/request-email-verification.write.ts +5 -0
- package/src/auth-email-password/handlers/request-password-reset.write.ts +5 -0
- package/src/auth-email-password/handlers/signup-request.write.ts +38 -8
- package/src/auth-email-password/handlers/token-request-handler.ts +31 -0
- package/src/auth-email-password/index.ts +9 -20
- package/src/auth-email-password/magic-link-mail.ts +62 -0
- package/src/auth-email-password/__tests__/auth-mailer.test.ts +0 -138
- package/src/auth-email-password/auth-mailer.ts +0 -153
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-bundled-features",
|
|
3
|
-
"version": "0.
|
|
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.
|
|
96
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
97
|
-
"@cosmicdrift/kumiko-headless": "0.
|
|
98
|
-
"@cosmicdrift/kumiko-renderer": "0.
|
|
99
|
-
"@cosmicdrift/kumiko-renderer-web": "0.
|
|
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
|
|
2
|
-
// Pure-Functions
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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
|
-
|
|
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.
|
|
20
|
-
expect(out
|
|
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
|
|
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.
|
|
29
|
-
expect(out
|
|
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
|
|
46
|
+
expect(textOf(out)).toContain("PublicStatus");
|
|
36
47
|
});
|
|
37
48
|
|
|
38
|
-
test("
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(""");
|
|
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("
|
|
55
|
+
test("footer trägt die ignore-Reassurance", () => {
|
|
51
56
|
const out = renderResetPasswordEmail(baseArgs);
|
|
52
|
-
expect(out.
|
|
53
|
-
expect(out.
|
|
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
|
-
|
|
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.
|
|
67
|
-
expect(out
|
|
68
|
-
expect(out
|
|
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.
|
|
75
|
-
expect(out
|
|
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 +
|
|
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 +
|
|
95
|
-
//
|
|
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,
|
|
98
|
-
const verify = renderVerifyEmail({ ...args,
|
|
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("
|
|
103
|
-
expect(reset.
|
|
104
|
-
expect(verify.
|
|
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
|
-
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
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,
|
|
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(
|
|
156
|
-
const
|
|
157
|
-
if (!
|
|
158
|
-
expect(
|
|
159
|
-
expect(
|
|
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
|
|
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(
|
|
194
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
175
195
|
});
|
|
176
196
|
|
|
177
|
-
test("unknown email → 200, NO
|
|
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(
|
|
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
|
|
13
|
-
// 3. Token aus
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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:
|
|
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, {
|
|
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
|
-
|
|
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).
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
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:
|
|
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
|
-
|
|
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,
|
|
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(
|
|
143
|
-
const
|
|
144
|
-
if (!
|
|
145
|
-
expect(
|
|
146
|
-
expect(
|
|
147
|
-
expect(
|
|
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
|
|
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(
|
|
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(
|
|
181
|
+
expect(emailTransport.sent).toHaveLength(0);
|
|
163
182
|
});
|
|
164
183
|
});
|
|
165
184
|
|