@cosmicdrift/kumiko-dev-server 0.329.0 → 0.330.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/README.md +17 -0
- package/bin/kumiko-build.ts +3 -2
- package/bin/kumiko-dev.ts +1 -1
- package/bin/kumiko-init-deploy.ts +1 -1
- package/bin/kumiko-schema-check.ts +4 -1
- package/dist/build-server-bundle.d.ts +31 -0
- package/dist/build-server-bundle.js +282 -0
- package/dist/build.d.ts +2 -0
- package/dist/build.js +5 -0
- package/dist/cli.d.ts +5 -0
- package/dist/cli.js +9 -0
- package/dist/codegen/index.d.ts +4 -0
- package/dist/codegen/index.js +4 -0
- package/dist/codegen/render.d.ts +55 -0
- package/dist/codegen/render.js +273 -0
- package/dist/codegen/run-codegen.d.ts +20 -0
- package/dist/codegen/run-codegen.js +219 -0
- package/dist/codegen/scan-events.d.ts +76 -0
- package/dist/codegen/scan-events.js +447 -0
- package/dist/codegen/watch.d.ts +33 -0
- package/dist/codegen/watch.js +112 -0
- package/dist/compose-stacks.d.ts +60 -0
- package/dist/compose-stacks.js +127 -0
- package/dist/crash-tracker.d.ts +17 -0
- package/dist/crash-tracker.js +37 -0
- package/dist/create-kumiko-server.d.ts +195 -0
- package/dist/create-kumiko-server.js +1040 -0
- package/dist/env-schema.d.ts +15 -0
- package/dist/env-schema.js +60 -0
- package/dist/few-shot-corpus.d.ts +72 -0
- package/dist/few-shot-corpus.js +262 -0
- package/dist/framework-version.d.ts +1 -0
- package/dist/framework-version.js +31 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.js +33 -0
- package/dist/init-deploy-cli.d.ts +10 -0
- package/dist/init-deploy-cli.js +111 -0
- package/dist/kebab.d.ts +1 -0
- package/{src/kebab.ts → dist/kebab.js} +2 -3
- package/dist/run-dev-app.d.ts +174 -0
- package/dist/run-dev-app.js +403 -0
- package/dist/scaffold-app-feature.d.ts +16 -0
- package/dist/scaffold-app-feature.js +240 -0
- package/dist/scaffold-app.d.ts +45 -0
- package/dist/scaffold-app.js +852 -0
- package/dist/scaffold-demo-tasks.d.ts +8 -0
- package/{src/scaffold-demo-tasks.ts → dist/scaffold-demo-tasks.js} +69 -98
- package/dist/scaffold-deploy.d.ts +92 -0
- package/dist/scaffold-deploy.js +254 -0
- package/dist/scaffold-feature.d.ts +26 -0
- package/dist/scaffold-feature.js +163 -0
- package/dist/schema-apply.d.ts +11 -0
- package/dist/schema-apply.js +95 -0
- package/dist/schema-check-core.d.ts +2 -0
- package/{src/schema-check-core.ts → dist/schema-check-core.js} +8 -10
- package/dist/schema-tables-auth-mode.d.ts +2 -0
- package/{src/schema-tables-auth-mode.ts → dist/schema-tables-auth-mode.js} +2 -10
- package/dist/schema-tables-minimal.d.ts +2 -0
- package/{src/schema-tables-minimal.ts → dist/schema-tables-minimal.js} +2 -11
- package/dist/setup-test-stack-from-features.d.ts +11 -0
- package/dist/setup-test-stack-from-features.js +42 -0
- package/dist/welcome-banner.d.ts +15 -0
- package/dist/welcome-banner.js +27 -0
- package/package.json +71 -24
- package/src/changes.json +6 -0
- package/src/__tests__/build-prod-bundle.integration.test.ts +0 -282
- package/src/__tests__/build-server-bundle.test.ts +0 -228
- package/src/__tests__/classify-change.test.ts +0 -87
- package/src/__tests__/compose-stacks.test.ts +0 -273
- package/src/__tests__/config-resolver-default.integration.test.ts +0 -165
- package/src/__tests__/crash-tracker.test.ts +0 -89
- package/src/__tests__/create-kumiko-server-errors.test.ts +0 -54
- package/src/__tests__/create-kumiko-server-options.test.ts +0 -33
- package/src/__tests__/create-kumiko-server.integration.test.ts +0 -1007
- package/src/__tests__/discover-format.test.ts +0 -32
- package/src/__tests__/env-schema.integration.test.ts +0 -248
- package/src/__tests__/env-schema.test.ts +0 -132
- package/src/__tests__/few-shot-corpus.test.ts +0 -316
- package/src/__tests__/framework-version.test.ts +0 -45
- package/src/__tests__/init-deploy-cli.test.ts +0 -113
- package/src/__tests__/kumiko-schema-check.test.ts +0 -56
- package/src/__tests__/merge-extra-context.test.ts +0 -67
- package/src/__tests__/resolve-stylesheet.test.ts +0 -132
- package/src/__tests__/run-dev-app-boot-validation.test.ts +0 -38
- package/src/__tests__/run-dev-app.integration.test.ts +0 -290
- package/src/__tests__/runtime-classification.test.ts +0 -26
- package/src/__tests__/saas-identity-wire.integration.test.ts +0 -427
- package/src/__tests__/scaffold-app-feature.test.ts +0 -216
- package/src/__tests__/scaffold-app.test.ts +0 -630
- package/src/__tests__/scaffold-demo-tasks.test.ts +0 -47
- package/src/__tests__/scaffold-deploy.test.ts +0 -464
- package/src/__tests__/scaffold-dev-cycle.integration.test.ts +0 -109
- package/src/__tests__/scaffold-feature.test.ts +0 -143
- package/src/__tests__/schema-apply.integration.test.ts +0 -191
- package/src/__tests__/setup-test-stack-from-features.integration.test.ts +0 -30
- package/src/__tests__/setup-test-stack-job-run-logger.integration.test.ts +0 -72
- package/src/__tests__/walkthrough.integration.test.ts +0 -140
- package/src/__tests__/welcome-banner.test.ts +0 -42
- package/src/build-server-bundle.ts +0 -346
- package/src/build.ts +0 -28
- package/src/codegen/__tests__/render-codegen.test.ts +0 -100
- package/src/codegen/__tests__/run-codegen.test.ts +0 -665
- package/src/codegen/__tests__/strict-mode-diagnostics.test.ts +0 -347
- package/src/codegen/__tests__/watch.test.ts +0 -222
- package/src/codegen/index.ts +0 -19
- package/src/codegen/render.ts +0 -305
- package/src/codegen/run-codegen.ts +0 -278
- package/src/codegen/scan-events.ts +0 -565
- package/src/codegen/watch.ts +0 -140
- package/src/compose-stacks.ts +0 -193
- package/src/crash-tracker.ts +0 -56
- package/src/create-kumiko-server.ts +0 -1397
- package/src/env-schema.ts +0 -72
- package/src/few-shot-corpus.ts +0 -370
- package/src/framework-version.ts +0 -39
- package/src/index.ts +0 -105
- package/src/init-deploy-cli.ts +0 -148
- package/src/run-dev-app.ts +0 -694
- package/src/scaffold-app-feature.ts +0 -283
- package/src/scaffold-app.ts +0 -1033
- package/src/scaffold-deploy.ts +0 -393
- package/src/scaffold-feature.ts +0 -226
- package/src/schema-apply.ts +0 -123
- package/src/setup-test-stack-from-features.ts +0 -80
- package/src/welcome-banner.ts +0 -43
|
@@ -1,427 +0,0 @@
|
|
|
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 { data: { rows: Array<{ id: string }> } };
|
|
422
|
-
expect(mineBody.data.rows.some((s) => s.id === sid)).toBe(true);
|
|
423
|
-
|
|
424
|
-
const rows = await selectMany(stack.db, userSessionTable, { userId });
|
|
425
|
-
expect(rows.length).toBeGreaterThanOrEqual(1);
|
|
426
|
-
});
|
|
427
|
-
});
|
|
@@ -1,216 +0,0 @@
|
|
|
1
|
-
// scaffoldAppFeature unit-tests (DX-2).
|
|
2
|
-
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
4
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { tmpdir } from "node:os";
|
|
6
|
-
import { join } from "node:path";
|
|
7
|
-
import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
|
|
8
|
-
import { scaffoldApp } from "../scaffold-app";
|
|
9
|
-
import { scaffoldAppFeature } from "../scaffold-app-feature";
|
|
10
|
-
|
|
11
|
-
describe("scaffoldAppFeature", () => {
|
|
12
|
-
let tmp: string;
|
|
13
|
-
let appRoot: string;
|
|
14
|
-
beforeEach(async () => {
|
|
15
|
-
tmp = mkdtempSync(join(tmpdir(), "scaffold-app-feature-"));
|
|
16
|
-
appRoot = join(tmp, "my-shop");
|
|
17
|
-
await scaffoldApp({ name: "my-shop", destination: appRoot });
|
|
18
|
-
});
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
test("scaffolds die volle App-Feature-Konvention", () => {
|
|
24
|
-
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
25
|
-
expect(result.featureName).toBe("product-catalog");
|
|
26
|
-
expect(result.files).toEqual([
|
|
27
|
-
"src/features/product-catalog/feature.ts",
|
|
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",
|
|
35
|
-
]);
|
|
36
|
-
for (const file of result.files) {
|
|
37
|
-
expect(existsSync(join(appRoot, file))).toBe(true);
|
|
38
|
-
}
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
test("feature.ts registriert nur — Entity aus schema/, Keys aus i18n.ts", () => {
|
|
42
|
-
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
43
|
-
const feature = readFileSync(join(appRoot, "src/features/product-catalog/feature.ts"), "utf-8");
|
|
44
|
-
expect(feature).toContain("defineFeature(PRODUCT_CATALOG_FEATURE");
|
|
45
|
-
expect(feature).toContain("export const productCatalogFeature");
|
|
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])");
|
|
77
|
-
});
|
|
78
|
-
|
|
79
|
-
test("auto-mounts in src/run-config.ts (import + APP_FEATURES entry)", () => {
|
|
80
|
-
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
81
|
-
expect(result.autoMounted).toBe(true);
|
|
82
|
-
const runConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
83
|
-
expect(runConfig).toContain(
|
|
84
|
-
`import { productCatalogFeature } from "./features/product-catalog";`,
|
|
85
|
-
);
|
|
86
|
-
expect(runConfig).toContain("productCatalogFeature");
|
|
87
|
-
// APP_FEATURES still ends with `as const`
|
|
88
|
-
expect(runConfig).toMatch(/\]\s*as const;/);
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
test("mounting a second feature keeps the first feature's import exactly once", () => {
|
|
92
|
-
// Cross-feature non-duplication: adding billing must not touch the
|
|
93
|
-
// product-catalog import/entry. Same-feature re-mount idempotency is the
|
|
94
|
-
// next test — the dir-exists guard blocks a direct second scaffold of the
|
|
95
|
-
// same name, so this one cannot reach the short-circuit branches.
|
|
96
|
-
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
97
|
-
const firstRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
98
|
-
scaffoldAppFeature({ name: "billing", appRoot });
|
|
99
|
-
const secondRunConfig = readFileSync(join(appRoot, "src/run-config.ts"), "utf-8");
|
|
100
|
-
expect(secondRunConfig).toContain("productCatalogFeature");
|
|
101
|
-
expect(secondRunConfig).toContain("billingFeature");
|
|
102
|
-
// Count: each feature-import appears exactly once.
|
|
103
|
-
const occurrences = secondRunConfig.match(/productCatalogFeature/g) ?? [];
|
|
104
|
-
expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
|
|
105
|
-
expect(firstRunConfig.length).toBeLessThan(secondRunConfig.length);
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
test("idempotent: re-scaffolding the same feature is a full no-op on run-config", () => {
|
|
109
|
-
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
110
|
-
const runConfigPath = join(appRoot, "src/run-config.ts");
|
|
111
|
-
const afterFirst = readFileSync(runConfigPath, "utf-8");
|
|
112
|
-
|
|
113
|
-
// Drop only the feature dir so the dir-exists guard doesn't trip; the
|
|
114
|
-
// run-config import + APP_FEATURES entry both survive. The re-scaffold must
|
|
115
|
-
// hit the full short-circuit in mountInRunConfig (import present AND
|
|
116
|
-
// alreadyListed → changed=false → no save) and duplicate neither half —
|
|
117
|
-
// the branch the cross-feature test above never reaches.
|
|
118
|
-
rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
|
|
119
|
-
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
120
|
-
expect(result.autoMounted).toBe(true);
|
|
121
|
-
|
|
122
|
-
const afterSecond = readFileSync(runConfigPath, "utf-8");
|
|
123
|
-
expect(afterSecond).toBe(afterFirst); // byte-identical: no save, no duplication
|
|
124
|
-
expect((afterSecond.match(/productCatalogFeature/g) ?? []).length).toBe(2);
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
test("self-heals a half-mounted run-config: import present but APP_FEATURES entry removed", () => {
|
|
128
|
-
scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
129
|
-
const runConfigPath = join(appRoot, "src/run-config.ts");
|
|
130
|
-
|
|
131
|
-
// Simulate a half-applied state: the feature dir is gone and the
|
|
132
|
-
// APP_FEATURES entry was hand-removed, but the import line still lingers.
|
|
133
|
-
rmSync(join(appRoot, "src/features/product-catalog"), { recursive: true, force: true });
|
|
134
|
-
const stripped = readFileSync(runConfigPath, "utf-8")
|
|
135
|
-
// Drop the APP_FEATURES array element (appended last, so `, productCatalogFeature`
|
|
136
|
-
// right before the closing `]`). The import `{ productCatalogFeature }` is
|
|
137
|
-
// followed by ` }`, not `]`/`,`, so the lookahead leaves it intact.
|
|
138
|
-
.replace(/,\s*productCatalogFeature(?=\s*\])/, "");
|
|
139
|
-
writeFileSync(runConfigPath, stripped, "utf-8");
|
|
140
|
-
// Guard the simulation itself: the import must remain, the array entry must
|
|
141
|
-
// be gone — exactly one mention left. Without this, a regex that failed to
|
|
142
|
-
// strip would leave the entry in place and the test would false-pass.
|
|
143
|
-
expect(stripped).toContain(
|
|
144
|
-
'import { productCatalogFeature } from "./features/product-catalog";',
|
|
145
|
-
);
|
|
146
|
-
expect((stripped.match(/productCatalogFeature/g) ?? []).length).toBe(1);
|
|
147
|
-
|
|
148
|
-
// Re-scaffold: dir is gone so it proceeds; mountInRunConfig sees the import
|
|
149
|
-
// already present and must still re-add the missing APP_FEATURES entry.
|
|
150
|
-
const result = scaffoldAppFeature({ name: "product-catalog", appRoot });
|
|
151
|
-
expect(result.autoMounted).toBe(true);
|
|
152
|
-
|
|
153
|
-
const healed = readFileSync(runConfigPath, "utf-8");
|
|
154
|
-
// Import stays exactly once; the array entry is back.
|
|
155
|
-
const occurrences = healed.match(/productCatalogFeature/g) ?? [];
|
|
156
|
-
expect(occurrences.length).toBe(2); // 1 import + 1 array-entry
|
|
157
|
-
expect(healed).toMatch(/\]\s*as const;/);
|
|
158
|
-
});
|
|
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
|
-
|
|
175
|
-
test("rejects non-kebab-case", () => {
|
|
176
|
-
expect(() => scaffoldAppFeature({ name: "ProductCatalog", appRoot })).toThrow(/kebab-case/);
|
|
177
|
-
expect(() => scaffoldAppFeature({ name: "product_catalog", appRoot })).toThrow(/kebab-case/);
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
test("rejects trailing- and double-hyphen names (kebabToCamel would break)", () => {
|
|
181
|
-
// `product-` → kebabToCamel leaves a trailing hyphen → `product-Feature`,
|
|
182
|
-
// an invalid identifier. The segment-strict regex must reject these.
|
|
183
|
-
expect(() => scaffoldAppFeature({ name: "product-", appRoot })).toThrow(/kebab-case/);
|
|
184
|
-
expect(() => scaffoldAppFeature({ name: "foo--bar", appRoot })).toThrow(/kebab-case/);
|
|
185
|
-
});
|
|
186
|
-
|
|
187
|
-
test("rolls back the scaffolded dir when run-config has no APP_FEATURES (re-run not blocked)", () => {
|
|
188
|
-
// run-config exists but is the wrong shape → mountInRunConfig throws. The
|
|
189
|
-
// feature files were already written; without rollback a re-run would hit
|
|
190
|
-
// the "already exists" guard and the user would be stuck.
|
|
191
|
-
const runConfigPath = join(appRoot, "src/run-config.ts");
|
|
192
|
-
writeFileSync(runConfigPath, "export const NOT_APP_FEATURES = [];\n", "utf-8");
|
|
193
|
-
|
|
194
|
-
expect(() => scaffoldAppFeature({ name: "orders", appRoot })).toThrow(/APP_FEATURES/);
|
|
195
|
-
// Rolled back: the half-written feature dir is gone.
|
|
196
|
-
expect(existsSync(join(appRoot, "src/features/orders"))).toBe(false);
|
|
197
|
-
|
|
198
|
-
// Re-run with a valid run-config now succeeds instead of "already exists".
|
|
199
|
-
writeFileSync(runConfigPath, "export const APP_FEATURES = [] as const;\n", "utf-8");
|
|
200
|
-
const result = scaffoldAppFeature({ name: "orders", appRoot });
|
|
201
|
-
expect(result.autoMounted).toBe(true);
|
|
202
|
-
expect(existsSync(join(appRoot, "src/features/orders/feature.ts"))).toBe(true);
|
|
203
|
-
});
|
|
204
|
-
|
|
205
|
-
test("refuses to overwrite existing feature dir", () => {
|
|
206
|
-
scaffoldAppFeature({ name: "billing", appRoot });
|
|
207
|
-
expect(() => scaffoldAppFeature({ name: "billing", appRoot })).toThrow(/already exists/);
|
|
208
|
-
});
|
|
209
|
-
|
|
210
|
-
test("autoMounted=false when run-config.ts is missing", () => {
|
|
211
|
-
const emptyRoot = join(tmp, "no-app");
|
|
212
|
-
expect(() => scaffoldAppFeature({ name: "foo", appRoot: emptyRoot })).not.toThrow();
|
|
213
|
-
const result = scaffoldAppFeature({ name: "bar", appRoot: emptyRoot });
|
|
214
|
-
expect(result.autoMounted).toBe(false);
|
|
215
|
-
});
|
|
216
|
-
});
|