@cosmicdrift/kumiko-dev-server 1.0.0 → 2.0.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/bin/kumiko-schema-check.ts +7 -0
- package/package.json +12 -7
- package/src/__tests__/build-prod-bundle.integration.test.ts +1 -1
- package/src/__tests__/build-server-bundle.test.ts +62 -0
- package/src/__tests__/compose-stacks.test.ts +271 -0
- package/src/__tests__/create-kumiko-server-errors.test.ts +54 -0
- package/src/__tests__/create-kumiko-server-options.test.ts +33 -0
- package/src/__tests__/create-kumiko-server.integration.test.ts +128 -1
- package/src/__tests__/discover-format.test.ts +1 -1
- package/src/__tests__/env-schema.integration.test.ts +1 -1
- package/src/__tests__/few-shot-corpus.test.ts +16 -11
- package/src/__tests__/merge-extra-context.test.ts +56 -0
- package/src/__tests__/resolve-stylesheet.test.ts +16 -0
- package/src/__tests__/saas-identity-wire.integration.test.ts +430 -0
- package/src/__tests__/scaffold-app-feature.test.ts +59 -6
- package/src/__tests__/scaffold-app.test.ts +34 -2
- package/src/__tests__/schema-apply.integration.test.ts +5 -2
- package/src/__tests__/setup-test-stack-from-features.integration.test.ts +30 -0
- package/src/__tests__/walkthrough.integration.test.ts +22 -6
- package/src/build-server-bundle.ts +33 -15
- package/src/build.ts +1 -2
- package/src/codegen/__tests__/render-codegen.test.ts +2 -1
- package/src/codegen/__tests__/run-codegen.test.ts +2 -2
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +6 -1
- package/src/codegen/__tests__/watch.test.ts +1 -2
- package/src/codegen/render.ts +6 -1
- package/src/codegen/scan-events.ts +7 -5
- package/src/codegen/watch.ts +7 -4
- package/src/compose-stacks.ts +184 -0
- package/src/create-kumiko-server.ts +28 -5
- package/src/few-shot-corpus.ts +4 -3
- package/src/index.ts +30 -12
- package/src/run-dev-app.ts +195 -61
- package/src/scaffold-app-feature.ts +118 -15
- package/src/scaffold-app.ts +151 -79
- package/src/scaffold-demo-tasks.ts +233 -0
- package/src/schema-apply.ts +15 -2
- package/src/schema-check-core.ts +1 -1
- package/src/setup-test-stack-from-features.ts +61 -0
- package/src/welcome-banner.ts +1 -4
- package/src/__tests__/boot-extra-context.test.ts +0 -140
- package/src/__tests__/build-prod-bundle.test.ts +0 -311
- package/src/__tests__/cache-headers.test.ts +0 -83
- package/src/__tests__/compose-features-wiring.integration.test.ts +0 -382
- package/src/__tests__/compose-features.test.ts +0 -129
- package/src/__tests__/config-seed-boot.integration.test.ts +0 -158
- package/src/__tests__/inject-schema.test.ts +0 -62
- package/src/__tests__/renderer-web-css-relocation.integration.test.ts +0 -85
- package/src/__tests__/renderer-web-shell-sentinel.test.ts +0 -35
- package/src/__tests__/require-env.test.ts +0 -29
- package/src/__tests__/resolve-auth-mail.test.ts +0 -69
- package/src/__tests__/resolve-tailwind-cli.test.ts +0 -81
- package/src/__tests__/run-prod-app-env-source.test.ts +0 -157
- package/src/__tests__/run-prod-app-spec.test.ts +0 -57
- package/src/__tests__/run-prod-app.integration.test.ts +0 -840
- package/src/__tests__/session-wiring.test.ts +0 -51
- package/src/__tests__/try-hono-first.test.ts +0 -63
- package/src/boot/apply-boot-seeds.ts +0 -18
- package/src/build-prod-bundle.ts +0 -697
- package/src/compose-features.ts +0 -145
- package/src/extra-routes-deps.ts +0 -47
- package/src/inject-schema.ts +0 -24
- package/src/resolve-tailwind-cli.ts +0 -45
- package/src/run-prod-app.ts +0 -1492
- package/src/session-wiring.ts +0 -29
- package/src/try-hono-first.ts +0 -46
|
@@ -44,7 +44,7 @@ describe("buildFewShotCorpus — smoke", () => {
|
|
|
44
44
|
});
|
|
45
45
|
plantPackage(workdir, "samples/recipes/legacy-demo", {
|
|
46
46
|
pkgName: "@cosmicdrift/kumiko-sample-legacy-demo",
|
|
47
|
-
pkgDescription: "
|
|
47
|
+
pkgDescription: "Handler-object-reference-style legacy feature.",
|
|
48
48
|
featureSource: buildLegacyFeature("legacyDemo"),
|
|
49
49
|
});
|
|
50
50
|
|
|
@@ -169,7 +169,7 @@ describe("buildFewShotCorpus — warnings", () => {
|
|
|
169
169
|
|
|
170
170
|
expect(corpus.entries).toHaveLength(1);
|
|
171
171
|
expect(corpus.warnings).toHaveLength(1);
|
|
172
|
-
expect(corpus.warnings[0]?.
|
|
172
|
+
expect(corpus.warnings[0]?.message).toMatch(/^duplicate-id:/);
|
|
173
173
|
});
|
|
174
174
|
|
|
175
175
|
test("parser-throw surfaces as a warning instead of silent skip", () => {
|
|
@@ -293,19 +293,24 @@ defineFeature("${featureName}", (r) => {
|
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
function buildLegacyFeature(featureName: string): string {
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
296
|
+
// Handler-object-reference style — writeHandler receives a single
|
|
297
|
+
// identifier referencing a pre-built handler object rather than an
|
|
298
|
+
// inline string name + schema + handler. The parser has no extractor
|
|
299
|
+
// shape for that (it only reads a literal name as the first arg, or
|
|
300
|
+
// an inline object-literal form), so it produces a ParseError and the
|
|
301
|
+
// corpus marks the entry as authoringStyle: "legacy".
|
|
302
|
+
//
|
|
303
|
+
// Note: a captured-const *entity definition* (`r.entity("x", xEntity)`)
|
|
304
|
+
// used to be the legacy example here, but #998 taught the parser to
|
|
305
|
+
// resolve such references via a raw-ref sentinel — that idiom parses
|
|
306
|
+
// cleanly now and no longer exercises the legacy path.
|
|
299
307
|
return `
|
|
300
|
-
import { defineFeature
|
|
308
|
+
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
301
309
|
|
|
302
|
-
const
|
|
303
|
-
table: "items",
|
|
304
|
-
fields: { title: createTextField({ required: true }) },
|
|
305
|
-
});
|
|
310
|
+
const itemCreateHandler = { name: "item:create" };
|
|
306
311
|
|
|
307
312
|
defineFeature("${featureName}", (r) => {
|
|
308
|
-
r.
|
|
313
|
+
r.writeHandler(itemCreateHandler);
|
|
309
314
|
});
|
|
310
315
|
`;
|
|
311
316
|
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { mergeExtraContext } from "../setup-test-stack-from-features";
|
|
3
|
+
|
|
4
|
+
// #882/4: the integration test only asserted getFeature("config") — which
|
|
5
|
+
// includeBundled already provides — never that presets:["config"] actually
|
|
6
|
+
// merges configResolver/textContent into extraContext. Unit-test the merge
|
|
7
|
+
// directly instead.
|
|
8
|
+
|
|
9
|
+
const fakeDeps = {
|
|
10
|
+
registry: {} as never,
|
|
11
|
+
db: { marker: "fake-db" } as never,
|
|
12
|
+
sseBroker: {} as never,
|
|
13
|
+
redis: {} as never,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
describe("mergeExtraContext", () => {
|
|
17
|
+
test("no presets → returns base untouched (function passthrough)", () => {
|
|
18
|
+
const base = () => ({ foo: "bar" });
|
|
19
|
+
const merged = mergeExtraContext(base, []);
|
|
20
|
+
expect(merged).toBe(base);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("no presets → returns base untouched (object passthrough)", () => {
|
|
24
|
+
const base = { foo: "bar" };
|
|
25
|
+
const merged = mergeExtraContext(base, []);
|
|
26
|
+
expect(merged).toBe(base);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("config preset merges configResolver, keeping base-object fields", () => {
|
|
30
|
+
const merged = mergeExtraContext({ foo: "bar" }, ["config"]);
|
|
31
|
+
expect(typeof merged).toBe("function");
|
|
32
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
33
|
+
expect(result["foo"]).toBe("bar");
|
|
34
|
+
expect(result["configResolver"]).toBeDefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("config preset merges configResolver, keeping base-fn fields", () => {
|
|
38
|
+
const merged = mergeExtraContext(() => ({ fromFn: 1 }), ["config"]);
|
|
39
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
40
|
+
expect(result["fromFn"]).toBe(1);
|
|
41
|
+
expect(result["configResolver"]).toBeDefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("text-content preset merges textContent, built from deps.db", () => {
|
|
45
|
+
const merged = mergeExtraContext(undefined, ["text-content"]);
|
|
46
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
47
|
+
expect(result["textContent"]).toBeDefined();
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("both presets merge both fields", () => {
|
|
51
|
+
const merged = mergeExtraContext(undefined, ["config", "text-content"]);
|
|
52
|
+
const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
|
|
53
|
+
expect(result["configResolver"]).toBeDefined();
|
|
54
|
+
expect(result["textContent"]).toBeDefined();
|
|
55
|
+
});
|
|
56
|
+
});
|
|
@@ -64,6 +64,22 @@ describe("resolveStylesheet", () => {
|
|
|
64
64
|
}
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
+
test("Bun.resolveSync failure → undefined (catch path, no throw)", () => {
|
|
68
|
+
const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-resolve-catch-")));
|
|
69
|
+
const cwdBefore = process.cwd();
|
|
70
|
+
process.chdir(tmpDir);
|
|
71
|
+
try {
|
|
72
|
+
const out = resolveStylesheet({
|
|
73
|
+
features: [],
|
|
74
|
+
clientEntry: "./entry.tsx",
|
|
75
|
+
});
|
|
76
|
+
expect(out).toBeUndefined();
|
|
77
|
+
} finally {
|
|
78
|
+
process.chdir(cwdBefore);
|
|
79
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
67
83
|
test("undefined + clientEntry + src/styles.css existiert → returns App-Theme-Override", () => {
|
|
68
84
|
// Auto-Detection greift VOR dem renderer-web-Fallback: Wenn die App
|
|
69
85
|
// ein eigenes src/styles.css hat (App-Theme-Pattern), wird das
|
|
@@ -0,0 +1,430 @@
|
|
|
1
|
+
// Cross-feature SaaS identity wire suite — one mount that mirrors a real app
|
|
2
|
+
// stack (composeIdentityStack + delivery + renderer + channel-email) and drives
|
|
3
|
+
// the happy paths apps always need. Proves auth ↔ delivery mail ↔ sessions
|
|
4
|
+
// (login jti + mine) ↔ MFA challenge/verify. Renderer stack is mounted for
|
|
5
|
+
// delivery deps; channel-email uses an injected in-memory transport (not a
|
|
6
|
+
// template-resolver round-trip). Edge cases stay in per-feature suites.
|
|
7
|
+
|
|
8
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
9
|
+
import { randomBytes } from "node:crypto";
|
|
10
|
+
import { AuthErrors, AuthHandlers } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
11
|
+
import {
|
|
12
|
+
seedAdmin,
|
|
13
|
+
seedUserWithPassword,
|
|
14
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
|
|
15
|
+
import {
|
|
16
|
+
AuthMfaHandlers,
|
|
17
|
+
base32Decode,
|
|
18
|
+
bindMfaRevokeAllOtherSessionsFromFeature,
|
|
19
|
+
userMfaEntity,
|
|
20
|
+
userMfaTable,
|
|
21
|
+
} from "@cosmicdrift/kumiko-bundled-features/auth-mfa";
|
|
22
|
+
import { currentTotpCode } from "@cosmicdrift/kumiko-bundled-features/auth-mfa/testing";
|
|
23
|
+
import {
|
|
24
|
+
createChannelEmailFeature,
|
|
25
|
+
createInMemoryTransport,
|
|
26
|
+
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
27
|
+
import {
|
|
28
|
+
configValuesTable,
|
|
29
|
+
createConfigResolver,
|
|
30
|
+
} from "@cosmicdrift/kumiko-bundled-features/config";
|
|
31
|
+
import { createDeliveryTestContext } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
32
|
+
import { notificationPreferencesTable } from "@cosmicdrift/kumiko-bundled-features/delivery/tables";
|
|
33
|
+
import { simpleRenderer } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
|
|
34
|
+
import {
|
|
35
|
+
createSessionCallbacks,
|
|
36
|
+
type SessionCallbacks,
|
|
37
|
+
SessionQueries,
|
|
38
|
+
userSessionEntity,
|
|
39
|
+
userSessionTable,
|
|
40
|
+
} from "@cosmicdrift/kumiko-bundled-features/sessions";
|
|
41
|
+
import { sessionCallbacksFromLateBound } from "@cosmicdrift/kumiko-bundled-features/sessions/testing";
|
|
42
|
+
import {
|
|
43
|
+
tenantEntity,
|
|
44
|
+
tenantInvitationEntity,
|
|
45
|
+
tenantInvitationsTable,
|
|
46
|
+
tenantMembershipsTable,
|
|
47
|
+
tenantTable,
|
|
48
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant";
|
|
49
|
+
import {
|
|
50
|
+
seedTenant,
|
|
51
|
+
seedTenantMembership,
|
|
52
|
+
} from "@cosmicdrift/kumiko-bundled-features/tenant/seeding";
|
|
53
|
+
import { userEntity, userTable } from "@cosmicdrift/kumiko-bundled-features/user";
|
|
54
|
+
import { asRawClient, selectMany } from "@cosmicdrift/kumiko-framework/bun-db";
|
|
55
|
+
import { configureEntityFieldEncryption } from "@cosmicdrift/kumiko-framework/db";
|
|
56
|
+
import type { SessionUser, TenantId } from "@cosmicdrift/kumiko-framework/engine";
|
|
57
|
+
import {
|
|
58
|
+
setupTestStack,
|
|
59
|
+
type TestStack,
|
|
60
|
+
unsafeCreateEntityTable,
|
|
61
|
+
unsafePushTables,
|
|
62
|
+
} from "@cosmicdrift/kumiko-framework/stack";
|
|
63
|
+
import {
|
|
64
|
+
createLateBoundHolder,
|
|
65
|
+
createTestEnvelopeCipher,
|
|
66
|
+
deleteRows,
|
|
67
|
+
updateRows,
|
|
68
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
69
|
+
import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
|
|
70
|
+
import * as jose from "jose";
|
|
71
|
+
import { composeIdentityStack, composeOpsStack, composeRendererStack } from "../compose-stacks";
|
|
72
|
+
|
|
73
|
+
const SETUP_TOKEN_SECRET = "wire-mfa-setup-secret-at-least-32-bytes-long!!";
|
|
74
|
+
const CHALLENGE_TOKEN_SECRET = "wire-mfa-challenge-secret-at-least-32-bytes!!";
|
|
75
|
+
const RESET_SECRET = randomBytes(32).toString("base64");
|
|
76
|
+
const VERIFY_SECRET = randomBytes(32).toString("base64");
|
|
77
|
+
const APP_SIGNUP_URL = "https://app.example.com/signup/complete";
|
|
78
|
+
const APP_RESET_URL = "https://app.example.com/reset";
|
|
79
|
+
const APP_VERIFY_URL = "https://app.example.com/verify";
|
|
80
|
+
const APP_INVITE_URL = "https://app.example.com/invite/accept";
|
|
81
|
+
|
|
82
|
+
const emailTransport = createInMemoryTransport();
|
|
83
|
+
const callbacks = createLateBoundHolder<SessionCallbacks>("session-callbacks");
|
|
84
|
+
|
|
85
|
+
let stack: TestStack;
|
|
86
|
+
|
|
87
|
+
function extractTokenFromMail(html: string): string {
|
|
88
|
+
const match = html.match(/[?&]token=([^&"'<\s]+)/);
|
|
89
|
+
if (!match?.[1]) throw new Error(`No token in mail html: ${html.slice(0, 200)}`);
|
|
90
|
+
return decodeURIComponent(match[1]);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function markVerified(userId: string): Promise<void> {
|
|
94
|
+
await updateRows(stack.db, userTable, { emailVerified: true }, { id: userId });
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// writeOk drives real HTTP through the auth middleware, so a hand-built
|
|
98
|
+
// actor needs a live sid — sessionChecker rejects sidless JWTs.
|
|
99
|
+
async function withSession(user: SessionUser): Promise<SessionUser> {
|
|
100
|
+
const sid = await callbacks.get().sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
|
|
101
|
+
return { ...user, sid };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
beforeAll(async () => {
|
|
105
|
+
configureEntityFieldEncryption(createTestEnvelopeCipher());
|
|
106
|
+
const bound = sessionCallbacksFromLateBound(callbacks);
|
|
107
|
+
|
|
108
|
+
const identity = composeIdentityStack({
|
|
109
|
+
mfa: {
|
|
110
|
+
setupTokenSecret: SETUP_TOKEN_SECRET,
|
|
111
|
+
challengeTokenSecret: CHALLENGE_TOKEN_SECRET,
|
|
112
|
+
issuer: "Kumiko Wire",
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
const mfaFeature = identity.find((f) => f.name === "auth-mfa");
|
|
116
|
+
if (!mfaFeature) throw new Error("composeIdentityStack did not mount auth-mfa");
|
|
117
|
+
|
|
118
|
+
const features = composeFeatures(
|
|
119
|
+
[
|
|
120
|
+
...identity,
|
|
121
|
+
...composeOpsStack({ delivery: true, audit: false, jobs: false }),
|
|
122
|
+
...composeRendererStack(),
|
|
123
|
+
createChannelEmailFeature({
|
|
124
|
+
transport: emailTransport,
|
|
125
|
+
renderer: simpleRenderer,
|
|
126
|
+
resolveEmail: async () => "unused@test.local",
|
|
127
|
+
}),
|
|
128
|
+
],
|
|
129
|
+
{
|
|
130
|
+
includeBundled: true,
|
|
131
|
+
authOptions: {
|
|
132
|
+
signup: { tokenTtlMinutes: 60, appUrl: APP_SIGNUP_URL },
|
|
133
|
+
invite: { tokenTtlMinutes: 60, appUrl: APP_INVITE_URL },
|
|
134
|
+
passwordReset: {
|
|
135
|
+
hmacSecret: RESET_SECRET,
|
|
136
|
+
tokenTtlMinutes: 15,
|
|
137
|
+
appUrl: APP_RESET_URL,
|
|
138
|
+
},
|
|
139
|
+
emailVerification: {
|
|
140
|
+
hmacSecret: VERIFY_SECRET,
|
|
141
|
+
tokenTtlMinutes: 60,
|
|
142
|
+
mode: "strict",
|
|
143
|
+
appUrl: APP_VERIFY_URL,
|
|
144
|
+
},
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
stack = await setupTestStack({
|
|
150
|
+
features,
|
|
151
|
+
extraContext: (deps) => ({
|
|
152
|
+
...createDeliveryTestContext(deps),
|
|
153
|
+
configResolver: createConfigResolver(),
|
|
154
|
+
}),
|
|
155
|
+
authConfig: {
|
|
156
|
+
...bound.asAuthConfig(),
|
|
157
|
+
// sessionChecker rejects sidless JWTs — hand-built actors used with
|
|
158
|
+
// writeOk go through withSession() to get a live sid first.
|
|
159
|
+
membershipQuery: "tenant:query:memberships",
|
|
160
|
+
loginHandler: AuthHandlers.login,
|
|
161
|
+
mfaVerifyHandler: AuthMfaHandlers.verify,
|
|
162
|
+
loginErrorStatusMap: {
|
|
163
|
+
[AuthErrors.invalidCredentials]: 401,
|
|
164
|
+
[AuthErrors.noMembership]: 403,
|
|
165
|
+
[AuthErrors.emailNotVerified]: 403,
|
|
166
|
+
},
|
|
167
|
+
signup: {
|
|
168
|
+
requestHandler: AuthHandlers.signupRequest,
|
|
169
|
+
confirmHandler: AuthHandlers.signupConfirm,
|
|
170
|
+
},
|
|
171
|
+
passwordReset: {
|
|
172
|
+
requestHandler: AuthHandlers.requestPasswordReset,
|
|
173
|
+
confirmHandler: AuthHandlers.resetPassword,
|
|
174
|
+
},
|
|
175
|
+
emailVerification: {
|
|
176
|
+
requestHandler: AuthHandlers.requestEmailVerification,
|
|
177
|
+
confirmHandler: AuthHandlers.verifyEmail,
|
|
178
|
+
},
|
|
179
|
+
invite: {
|
|
180
|
+
acceptHandler: AuthHandlers.inviteAccept,
|
|
181
|
+
acceptWithLoginHandler: AuthHandlers.inviteAcceptWithLogin,
|
|
182
|
+
signupCompleteHandler: AuthHandlers.inviteSignupComplete,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
const sessionCallbacks = createSessionCallbacks({ db: stack.db });
|
|
188
|
+
callbacks.set(sessionCallbacks);
|
|
189
|
+
bindMfaRevokeAllOtherSessionsFromFeature(mfaFeature)?.(sessionCallbacks.sessionRevokeAllOthers);
|
|
190
|
+
|
|
191
|
+
await unsafeCreateEntityTable(stack.db, userEntity);
|
|
192
|
+
await unsafeCreateEntityTable(stack.db, tenantEntity);
|
|
193
|
+
await unsafeCreateEntityTable(stack.db, tenantInvitationEntity);
|
|
194
|
+
await unsafeCreateEntityTable(stack.db, userMfaEntity);
|
|
195
|
+
await unsafeCreateEntityTable(stack.db, userSessionEntity);
|
|
196
|
+
await unsafePushTables(stack.db, {
|
|
197
|
+
configValuesTable,
|
|
198
|
+
tenantMembershipsTable,
|
|
199
|
+
notificationPreferencesTable,
|
|
200
|
+
userSessionTable,
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
afterAll(async () => {
|
|
205
|
+
await stack?.cleanup();
|
|
206
|
+
configureEntityFieldEncryption(undefined);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
beforeEach(async () => {
|
|
210
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${userTable.tableName}"`);
|
|
211
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantMembershipsTable.tableName}"`);
|
|
212
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantInvitationsTable.tableName}"`);
|
|
213
|
+
await asRawClient(stack.db).unsafe(`DELETE FROM "${tenantTable.tableName}"`);
|
|
214
|
+
await deleteRows(stack.db, userSessionTable, {});
|
|
215
|
+
await deleteRows(stack.db, userMfaTable, {});
|
|
216
|
+
emailTransport.sent.length = 0;
|
|
217
|
+
for (const pattern of ["signup:*", "invite:*"] as const) {
|
|
218
|
+
const keys = await stack.redis.redis.keys(pattern);
|
|
219
|
+
if (keys.length > 0) await stack.redis.redis.del(...keys);
|
|
220
|
+
}
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
describe("saas-identity-wire", () => {
|
|
224
|
+
test("signup → delivery mail → confirm → login JWT with sid", async () => {
|
|
225
|
+
const email = "signup-wire@example.com";
|
|
226
|
+
const password = "fresh-secure-pw-1234";
|
|
227
|
+
|
|
228
|
+
const req = await stack.http.raw("POST", "/api/auth/signup-request", { email });
|
|
229
|
+
expect(req.status).toBe(200);
|
|
230
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
231
|
+
const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
|
|
232
|
+
|
|
233
|
+
const confirm = await stack.http.raw("POST", "/api/auth/signup-confirm", { token, password });
|
|
234
|
+
expect(confirm.status).toBe(200);
|
|
235
|
+
|
|
236
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
237
|
+
expect(login.status).toBe(200);
|
|
238
|
+
const body = (await login.json()) as { token?: string };
|
|
239
|
+
expect(body.token).toBeTypeOf("string");
|
|
240
|
+
expect(typeof jose.decodeJwt(body.token!).jti).toBe("string");
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("password reset → delivery mail → reset → login with new password", async () => {
|
|
244
|
+
const email = "reset-wire@example.com";
|
|
245
|
+
const oldPassword = "old-password-1234";
|
|
246
|
+
const newPassword = "new-password-5678";
|
|
247
|
+
const tenantId = "00000000-0000-4000-8000-000000000001" as TenantId;
|
|
248
|
+
const { id } = await seedAdmin(stack.db, {
|
|
249
|
+
email,
|
|
250
|
+
password: oldPassword,
|
|
251
|
+
displayName: "Reset",
|
|
252
|
+
memberships: [
|
|
253
|
+
{
|
|
254
|
+
tenantId,
|
|
255
|
+
tenantKey: "reset-wire",
|
|
256
|
+
tenantName: "Reset Wire",
|
|
257
|
+
roles: ["User"],
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
});
|
|
261
|
+
await markVerified(id);
|
|
262
|
+
|
|
263
|
+
const req = await stack.http.raw("POST", "/api/auth/request-password-reset", { email });
|
|
264
|
+
expect(req.status).toBe(200);
|
|
265
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
266
|
+
const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
|
|
267
|
+
|
|
268
|
+
const reset = await stack.http.raw("POST", "/api/auth/reset-password", {
|
|
269
|
+
token,
|
|
270
|
+
newPassword,
|
|
271
|
+
});
|
|
272
|
+
expect(reset.status).toBe(200);
|
|
273
|
+
|
|
274
|
+
const oldLogin = await stack.http.raw("POST", "/api/auth/login", {
|
|
275
|
+
email,
|
|
276
|
+
password: oldPassword,
|
|
277
|
+
});
|
|
278
|
+
expect(oldLogin.status).toBe(401);
|
|
279
|
+
|
|
280
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password: newPassword });
|
|
281
|
+
expect(login.status).toBe(200);
|
|
282
|
+
const body = (await login.json()) as { token?: string };
|
|
283
|
+
expect(body.token).toBeTypeOf("string");
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("invite Branch3 → signup-complete → login", async () => {
|
|
287
|
+
const tenantId = crypto.randomUUID() as TenantId;
|
|
288
|
+
const { id: adminId } = await seedAdmin(stack.db, {
|
|
289
|
+
email: "admin-wire@example.com",
|
|
290
|
+
password: "admin-password-1234",
|
|
291
|
+
displayName: "Admin",
|
|
292
|
+
memberships: [
|
|
293
|
+
{
|
|
294
|
+
tenantId,
|
|
295
|
+
tenantKey: `invite-${tenantId.slice(0, 8)}`,
|
|
296
|
+
tenantName: "Invite Wire",
|
|
297
|
+
roles: ["Admin"],
|
|
298
|
+
},
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
await markVerified(adminId);
|
|
302
|
+
|
|
303
|
+
const admin = await withSession({ id: adminId, tenantId, roles: ["Admin"] });
|
|
304
|
+
const invitee = "carol-wire@example.com";
|
|
305
|
+
await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
|
|
306
|
+
expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
|
|
307
|
+
const token = extractTokenFromMail(emailTransport.sent.at(-1)?.html ?? "");
|
|
308
|
+
|
|
309
|
+
const complete = await stack.http.raw("POST", "/api/auth/invite-signup-complete", {
|
|
310
|
+
token,
|
|
311
|
+
password: "carol-new-pw-1234",
|
|
312
|
+
});
|
|
313
|
+
expect(complete.status).toBe(200);
|
|
314
|
+
|
|
315
|
+
const login = await stack.http.raw("POST", "/api/auth/login", {
|
|
316
|
+
email: invitee,
|
|
317
|
+
password: "carol-new-pw-1234",
|
|
318
|
+
});
|
|
319
|
+
expect(login.status).toBe(200);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test("email verify gate: unverified blocked → verify → login ok", async () => {
|
|
323
|
+
const email = "verify-wire@example.com";
|
|
324
|
+
const password = "verify-password-1234";
|
|
325
|
+
const tenantId = "00000000-0000-4000-8000-000000000002" as TenantId;
|
|
326
|
+
await seedTenant(stack.db, {
|
|
327
|
+
id: tenantId,
|
|
328
|
+
key: "verify-wire",
|
|
329
|
+
name: "Verify Wire",
|
|
330
|
+
});
|
|
331
|
+
const { id } = await seedUserWithPassword(stack.db, {
|
|
332
|
+
email,
|
|
333
|
+
password,
|
|
334
|
+
displayName: "Verify",
|
|
335
|
+
emailVerified: false,
|
|
336
|
+
});
|
|
337
|
+
await seedTenantMembership(stack.db, {
|
|
338
|
+
userId: id,
|
|
339
|
+
tenantId,
|
|
340
|
+
roles: ["User"],
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
const blocked = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
344
|
+
expect(blocked.status).toBe(403);
|
|
345
|
+
const blockedBody = (await blocked.json()) as { error?: { details?: { reason?: string } } };
|
|
346
|
+
expect(blockedBody.error?.details?.reason).toBe(AuthErrors.emailNotVerified);
|
|
347
|
+
|
|
348
|
+
const req = await stack.http.raw("POST", "/api/auth/request-email-verification", { email });
|
|
349
|
+
expect(req.status).toBe(200);
|
|
350
|
+
expect(emailTransport.sent).toHaveLength(1);
|
|
351
|
+
const token = extractTokenFromMail(emailTransport.sent[0]?.html ?? "");
|
|
352
|
+
|
|
353
|
+
const verify = await stack.http.raw("POST", "/api/auth/verify-email", { token });
|
|
354
|
+
expect(verify.status).toBe(200);
|
|
355
|
+
|
|
356
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
357
|
+
expect(login.status).toBe(200);
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
test("MFA enable → login challenges → verify JWT; sessions:mine lists sid", async () => {
|
|
361
|
+
const email = "mfa-wire@example.com";
|
|
362
|
+
const password = "mfa-password-1234";
|
|
363
|
+
const tenantId = "00000000-0000-4000-8000-000000000003" as TenantId;
|
|
364
|
+
const { id: userId } = await seedAdmin(stack.db, {
|
|
365
|
+
email,
|
|
366
|
+
password,
|
|
367
|
+
displayName: "Mfa",
|
|
368
|
+
memberships: [
|
|
369
|
+
{
|
|
370
|
+
tenantId,
|
|
371
|
+
tenantKey: "mfa-wire",
|
|
372
|
+
tenantName: "MFA Wire",
|
|
373
|
+
roles: ["User"],
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
});
|
|
377
|
+
await markVerified(userId);
|
|
378
|
+
const user = await withSession({ id: userId, tenantId, roles: ["User"] });
|
|
379
|
+
|
|
380
|
+
const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
|
|
381
|
+
AuthMfaHandlers.enableStart,
|
|
382
|
+
{ accountLabel: email },
|
|
383
|
+
user,
|
|
384
|
+
);
|
|
385
|
+
const secretParam = new URLSearchParams(start.otpauthUri.split("?")[1]).get("secret") ?? "";
|
|
386
|
+
const secret = base32Decode(secretParam);
|
|
387
|
+
await stack.http.writeOk(
|
|
388
|
+
AuthMfaHandlers.enableConfirm,
|
|
389
|
+
{ setupToken: start.setupToken, code: currentTotpCode(secret) },
|
|
390
|
+
user,
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
const login = await stack.http.raw("POST", "/api/auth/login", { email, password });
|
|
394
|
+
expect(login.status).toBe(200);
|
|
395
|
+
const challenge = (await login.json()) as {
|
|
396
|
+
mfaRequired?: boolean;
|
|
397
|
+
challengeToken?: string;
|
|
398
|
+
token?: string;
|
|
399
|
+
};
|
|
400
|
+
expect(challenge.mfaRequired).toBe(true);
|
|
401
|
+
expect(challenge.token).toBeUndefined();
|
|
402
|
+
expect(challenge.challengeToken).toBeTypeOf("string");
|
|
403
|
+
|
|
404
|
+
const verify = await stack.http.raw("POST", "/api/auth/mfa/verify", {
|
|
405
|
+
challengeToken: challenge.challengeToken,
|
|
406
|
+
code: currentTotpCode(secret),
|
|
407
|
+
});
|
|
408
|
+
expect(verify.status).toBe(200);
|
|
409
|
+
const { token } = (await verify.json()) as { token: string };
|
|
410
|
+
expect(token).toBeTypeOf("string");
|
|
411
|
+
const sid = jose.decodeJwt(token).jti;
|
|
412
|
+
expect(typeof sid).toBe("string");
|
|
413
|
+
|
|
414
|
+
const mineRes = await stack.http.raw(
|
|
415
|
+
"POST",
|
|
416
|
+
"/api/query",
|
|
417
|
+
{ type: SessionQueries.mine, payload: {} },
|
|
418
|
+
{ Authorization: `Bearer ${token}` },
|
|
419
|
+
);
|
|
420
|
+
expect(mineRes.status).toBe(200);
|
|
421
|
+
const mineBody = (await mineRes.json()) as {
|
|
422
|
+
data?: Array<{ id: string }> | { items?: Array<{ id: string }> };
|
|
423
|
+
};
|
|
424
|
+
const items = Array.isArray(mineBody.data) ? mineBody.data : (mineBody.data?.items ?? []);
|
|
425
|
+
expect(items.some((s) => s.id === sid)).toBe(true);
|
|
426
|
+
|
|
427
|
+
const rows = await selectMany(stack.db, userSessionTable, { userId });
|
|
428
|
+
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
429
|
+
});
|
|
430
|
+
});
|
|
@@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
4
4
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { tmpdir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
|
+
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
7
8
|
import { scaffoldApp } from "../scaffold-app";
|
|
8
9
|
import { scaffoldAppFeature } from "../scaffold-app-feature";
|
|
9
10
|
|
|
@@ -19,23 +20,60 @@ describe("scaffoldAppFeature", () => {
|
|
|
19
20
|
rmSync(tmp, { recursive: true, force: true });
|
|
20
21
|
});
|
|
21
22
|
|
|
22
|
-
test("scaffolds
|
|
23
|
+
test("scaffolds die volle App-Feature-Konvention", () => {
|
|
23
24
|
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
24
25
|
expect(result.featureName).toBe("product-catalog");
|
|
25
26
|
expect(result.files).toEqual([
|
|
26
27
|
"src/features/product-catalog/feature.ts",
|
|
27
28
|
"src/features/product-catalog/index.ts",
|
|
29
|
+
"src/features/product-catalog/constants.ts",
|
|
30
|
+
"src/features/product-catalog/i18n.ts",
|
|
31
|
+
"src/features/product-catalog/schema/product-catalog-item.ts",
|
|
32
|
+
"src/features/product-catalog/schema/index.ts",
|
|
33
|
+
"src/features/product-catalog/web/index.ts",
|
|
34
|
+
"src/features/product-catalog/__tests__/feature.boot.test.ts",
|
|
28
35
|
]);
|
|
29
|
-
|
|
30
|
-
|
|
36
|
+
for (const file of result.files) {
|
|
37
|
+
expect(existsSync(join(appRoot, file))).toBe(true);
|
|
38
|
+
}
|
|
31
39
|
});
|
|
32
40
|
|
|
33
|
-
test("feature.ts
|
|
41
|
+
test("feature.ts registriert nur — Entity aus schema/, Keys aus i18n.ts", () => {
|
|
34
42
|
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
35
43
|
const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
|
|
36
|
-
expect(feature).toContain(
|
|
44
|
+
expect(feature).toContain("defineFeature(PRODUCT_CATALOG_FEATURE");
|
|
37
45
|
expect(feature).toContain("export const productCatalogFeature");
|
|
38
|
-
expect(feature).toContain('r.entity("product-catalog-item"');
|
|
46
|
+
expect(feature).toContain('r.entity("product-catalog-item", productCatalogItemEntity)');
|
|
47
|
+
expect(feature).toContain('type: "entityList"');
|
|
48
|
+
expect(feature).toContain("r.translations({ keys: productCatalogTranslationKeys })");
|
|
49
|
+
// Konvention: Entity-Felder leben in schema/, nicht inline in feature.ts.
|
|
50
|
+
expect(feature).not.toContain("fields:");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("Starter erfüllt Boot-Constraints: sortable-Feld, defaultSort, i18n-Pflichtkeys", () => {
|
|
54
|
+
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
55
|
+
const entity = readFileSync(
|
|
56
|
+
join(appRoot, "src/features/product-catalog/schema/product-catalog-item.ts"),
|
|
57
|
+
"utf-8",
|
|
58
|
+
);
|
|
59
|
+
expect(entity).toContain("sortable: true");
|
|
60
|
+
const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
|
|
61
|
+
expect(feature).toContain('defaultSort: { field: "title", dir: "asc" }');
|
|
62
|
+
const i18n = readFileSync(join(appRoot, "src/features/product-catalog/i18n.ts"), "utf-8");
|
|
63
|
+
expect(i18n).toContain('"screen:items.title"');
|
|
64
|
+
expect(i18n).toContain('"product-catalog:entity:product-catalog-item:field:title"');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test("web/index.ts ist Client-Stub, __tests__ enthält validateBoot-Test", () => {
|
|
68
|
+
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
69
|
+
const web = readFileSync(join(appRoot, "src/features/product-catalog/web/index.ts"), "utf-8");
|
|
70
|
+
expect(web).toContain("@runtime client");
|
|
71
|
+
expect(web).toContain("export const productCatalogClient: ClientFeatureDefinition");
|
|
72
|
+
const bootTest = readFileSync(
|
|
73
|
+
join(appRoot, "src/features/product-catalog/__tests__/feature.boot.test.ts"),
|
|
74
|
+
"utf-8",
|
|
75
|
+
);
|
|
76
|
+
expect(bootTest).toContain("validateBoot([productCatalogFeature])");
|
|
39
77
|
});
|
|
40
78
|
|
|
41
79
|
test("auto-mounts in src/run-config.ts (import + APP_FEATURES entry)", () => {
|
|
@@ -119,6 +157,21 @@ describe("scaffoldAppFeature", () => {
|
|
|
119
157
|
expect(healed).toMatch(/\]\s*as const;/);
|
|
120
158
|
});
|
|
121
159
|
|
|
160
|
+
test("gescaffoldetes Feature bootet wirklich (validateBoot über dynamic import)", async () => {
|
|
161
|
+
// Ins Repo scaffolden (nicht os.tmpdir): nur hier löst der dynamic
|
|
162
|
+
// import "@cosmicdrift/kumiko-framework/engine" über node_modules auf.
|
|
163
|
+
const repoTmp = mkdtempSync(join(import.meta.dir, ".tmp-scaffold-"));
|
|
164
|
+
try {
|
|
165
|
+
scaffoldAppFeature({ name: "boot-probe", appRoot: repoTmp });
|
|
166
|
+
const mod = (await import(join(repoTmp, "src/features/boot-probe/feature.ts"))) as {
|
|
167
|
+
readonly bootProbeFeature: Parameters<typeof validateBoot>[0][number];
|
|
168
|
+
};
|
|
169
|
+
expect(() => validateBoot([mod.bootProbeFeature])).not.toThrow();
|
|
170
|
+
} finally {
|
|
171
|
+
rmSync(repoTmp, { recursive: true, force: true });
|
|
172
|
+
}
|
|
173
|
+
});
|
|
174
|
+
|
|
122
175
|
test("rejects non-kebab-case", () => {
|
|
123
176
|
expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
|
|
124
177
|
expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
|