swallowkit 1.0.0-beta.46 → 1.0.0-beta.48
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/dist/cli/commands/add-auth.d.ts +3 -0
- package/dist/cli/commands/add-auth.d.ts.map +1 -1
- package/dist/cli/commands/add-auth.js +350 -17
- package/dist/cli/commands/add-auth.js.map +1 -1
- package/dist/cli/commands/dev.d.ts +4 -0
- package/dist/cli/commands/dev.d.ts.map +1 -1
- package/dist/cli/commands/dev.js +104 -13
- package/dist/cli/commands/dev.js.map +1 -1
- package/dist/cli/commands/init.d.ts +2 -0
- package/dist/cli/commands/init.d.ts.map +1 -1
- package/dist/cli/commands/init.js +56 -30
- package/dist/cli/commands/init.js.map +1 -1
- package/dist/cli/commands/scaffold.d.ts +9 -0
- package/dist/cli/commands/scaffold.d.ts.map +1 -1
- package/dist/cli/commands/scaffold.js +84 -8
- package/dist/cli/commands/scaffold.js.map +1 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +5 -1
- package/dist/cli/index.js.map +1 -1
- package/dist/core/config.d.ts +7 -2
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +81 -1
- package/dist/core/config.js.map +1 -1
- package/dist/core/mock/connector-mock-server.js +2 -2
- package/dist/core/mock/connector-mock-server.js.map +1 -1
- package/dist/core/project/manifest.js +2 -2
- package/dist/core/project/manifest.js.map +1 -1
- package/dist/core/project/validation.d.ts.map +1 -1
- package/dist/core/project/validation.js +17 -0
- package/dist/core/project/validation.js.map +1 -1
- package/dist/core/scaffold/auth-generator.d.ts +28 -3
- package/dist/core/scaffold/auth-generator.d.ts.map +1 -1
- package/dist/core/scaffold/auth-generator.js +493 -23
- package/dist/core/scaffold/auth-generator.js.map +1 -1
- package/dist/core/scaffold/connector-functions-generator.d.ts +3 -3
- package/dist/core/scaffold/connector-functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/connector-functions-generator.js +23 -21
- package/dist/core/scaffold/connector-functions-generator.js.map +1 -1
- package/dist/core/scaffold/functions-generator.d.ts +2 -2
- package/dist/core/scaffold/functions-generator.d.ts.map +1 -1
- package/dist/core/scaffold/functions-generator.js +20 -18
- package/dist/core/scaffold/functions-generator.js.map +1 -1
- package/dist/core/scaffold/model-parser.d.ts.map +1 -1
- package/dist/core/scaffold/model-parser.js +8 -3
- package/dist/core/scaffold/model-parser.js.map +1 -1
- package/dist/core/scaffold/nextjs-generator.d.ts.map +1 -1
- package/dist/core/scaffold/nextjs-generator.js +13 -1
- package/dist/core/scaffold/nextjs-generator.js.map +1 -1
- package/dist/core/scaffold/ui-generator.d.ts +3 -0
- package/dist/core/scaffold/ui-generator.d.ts.map +1 -1
- package/dist/core/scaffold/ui-generator.js +12 -8
- package/dist/core/scaffold/ui-generator.js.map +1 -1
- package/dist/types/index.d.ts +35 -5
- package/dist/types/index.d.ts.map +1 -1
- package/dist/utils/package-manager.d.ts.map +1 -1
- package/dist/utils/package-manager.js +1 -0
- package/dist/utils/package-manager.js.map +1 -1
- package/package.json +1 -1
- package/src/__tests__/__snapshots__/functions-generator.test.ts.snap +20 -20
- package/src/__tests__/__snapshots__/nextjs-generator.test.ts.snap +13 -1
- package/src/__tests__/auth.test.ts +107 -0
- package/src/__tests__/config.test.ts +22 -1
- package/src/__tests__/dev.test.ts +27 -0
- package/src/__tests__/init.test.ts +19 -0
- package/src/__tests__/mcp.test.ts +2 -2
- package/src/__tests__/multi-auth.test.ts +133 -0
- package/src/__tests__/nextjs-generator.test.ts +8 -0
- package/src/cli/commands/add-auth.ts +351 -18
- package/src/cli/commands/dev.ts +116 -16
- package/src/cli/commands/init.ts +56 -30
- package/src/cli/commands/scaffold.ts +86 -10
- package/src/cli/index.ts +5 -1
- package/src/core/config.ts +69 -2
- package/src/core/mock/connector-mock-server.ts +2 -2
- package/src/core/project/manifest.ts +3 -3
- package/src/core/project/validation.ts +16 -0
- package/src/core/scaffold/auth-generator.ts +500 -24
- package/src/core/scaffold/connector-functions-generator.ts +26 -21
- package/src/core/scaffold/functions-generator.ts +21 -19
- package/src/core/scaffold/model-parser.ts +9 -3
- package/src/core/scaffold/nextjs-generator.ts +13 -1
- package/src/core/scaffold/ui-generator.ts +15 -8
- package/src/types/index.ts +36 -5
- package/src/utils/package-manager.ts +1 -0
|
@@ -130,7 +130,7 @@ public sealed class TodoCrudFunctions
|
|
|
130
130
|
|
|
131
131
|
[Function("todoGetAll")]
|
|
132
132
|
public async Task<HttpResponseData> GetAll(
|
|
133
|
-
[HttpTrigger(AuthorizationLevel.
|
|
133
|
+
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "todo")] HttpRequestData request)
|
|
134
134
|
{
|
|
135
135
|
try
|
|
136
136
|
{
|
|
@@ -171,7 +171,7 @@ public sealed class TodoCrudFunctions
|
|
|
171
171
|
|
|
172
172
|
[Function("todoGetById")]
|
|
173
173
|
public async Task<HttpResponseData> GetById(
|
|
174
|
-
[HttpTrigger(AuthorizationLevel.
|
|
174
|
+
[HttpTrigger(AuthorizationLevel.Function, "get", Route = "todo/{id}")] HttpRequestData request,
|
|
175
175
|
string id)
|
|
176
176
|
{
|
|
177
177
|
try
|
|
@@ -194,7 +194,7 @@ public sealed class TodoCrudFunctions
|
|
|
194
194
|
|
|
195
195
|
[Function("todoCreate")]
|
|
196
196
|
public async Task<HttpResponseData> Create(
|
|
197
|
-
[HttpTrigger(AuthorizationLevel.
|
|
197
|
+
[HttpTrigger(AuthorizationLevel.Function, "post", Route = "todo")] HttpRequestData request)
|
|
198
198
|
{
|
|
199
199
|
try
|
|
200
200
|
{
|
|
@@ -229,7 +229,7 @@ public sealed class TodoCrudFunctions
|
|
|
229
229
|
|
|
230
230
|
[Function("todoUpdate")]
|
|
231
231
|
public async Task<HttpResponseData> Update(
|
|
232
|
-
[HttpTrigger(AuthorizationLevel.
|
|
232
|
+
[HttpTrigger(AuthorizationLevel.Function, "put", Route = "todo/{id}")] HttpRequestData request,
|
|
233
233
|
string id)
|
|
234
234
|
{
|
|
235
235
|
try
|
|
@@ -274,7 +274,7 @@ public sealed class TodoCrudFunctions
|
|
|
274
274
|
|
|
275
275
|
[Function("todoDelete")]
|
|
276
276
|
public async Task<HttpResponseData> Delete(
|
|
277
|
-
[HttpTrigger(AuthorizationLevel.
|
|
277
|
+
[HttpTrigger(AuthorizationLevel.Function, "delete", Route = "todo/{id}")] HttpRequestData request,
|
|
278
278
|
string id)
|
|
279
279
|
{
|
|
280
280
|
try
|
|
@@ -466,7 +466,7 @@ const containerName = 'Todos';
|
|
|
466
466
|
app.http('todo-get-all', {
|
|
467
467
|
methods: ['GET'],
|
|
468
468
|
route: 'todo',
|
|
469
|
-
authLevel: '
|
|
469
|
+
authLevel: 'function',
|
|
470
470
|
extraInputs: [
|
|
471
471
|
{
|
|
472
472
|
type: 'cosmosDB',
|
|
@@ -500,7 +500,7 @@ app.http('todo-get-all', {
|
|
|
500
500
|
app.http('todo-get-by-id', {
|
|
501
501
|
methods: ['GET'],
|
|
502
502
|
route: 'todo/{id}',
|
|
503
|
-
authLevel: '
|
|
503
|
+
authLevel: 'function',
|
|
504
504
|
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
505
505
|
try {
|
|
506
506
|
const id = request.params.id;
|
|
@@ -542,7 +542,7 @@ app.http('todo-get-by-id', {
|
|
|
542
542
|
app.http('todo-create', {
|
|
543
543
|
methods: ['POST'],
|
|
544
544
|
route: 'todo',
|
|
545
|
-
authLevel: '
|
|
545
|
+
authLevel: 'function',
|
|
546
546
|
extraOutputs: [
|
|
547
547
|
{
|
|
548
548
|
type: 'cosmosDB',
|
|
@@ -586,7 +586,7 @@ app.http('todo-create', {
|
|
|
586
586
|
app.http('todo-update', {
|
|
587
587
|
methods: ['PUT'],
|
|
588
588
|
route: 'todo/{id}',
|
|
589
|
-
authLevel: '
|
|
589
|
+
authLevel: 'function',
|
|
590
590
|
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
591
591
|
try {
|
|
592
592
|
const id = request.params.id;
|
|
@@ -647,7 +647,7 @@ app.http('todo-update', {
|
|
|
647
647
|
app.http('todo-delete', {
|
|
648
648
|
methods: ['DELETE'],
|
|
649
649
|
route: 'todo/{id}',
|
|
650
|
-
authLevel: '
|
|
650
|
+
authLevel: 'function',
|
|
651
651
|
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
652
652
|
try {
|
|
653
653
|
const id = request.params.id;
|
|
@@ -706,7 +706,7 @@ const containerName = 'Todos';
|
|
|
706
706
|
app.http('todo-get-all', {
|
|
707
707
|
methods: ['GET'],
|
|
708
708
|
route: 'todo',
|
|
709
|
-
authLevel: '
|
|
709
|
+
authLevel: 'function',
|
|
710
710
|
extraInputs: [
|
|
711
711
|
{
|
|
712
712
|
type: 'cosmosDB',
|
|
@@ -740,7 +740,7 @@ app.http('todo-get-all', {
|
|
|
740
740
|
app.http('todo-get-by-id', {
|
|
741
741
|
methods: ['GET'],
|
|
742
742
|
route: 'todo/{id}',
|
|
743
|
-
authLevel: '
|
|
743
|
+
authLevel: 'function',
|
|
744
744
|
extraInputs: [
|
|
745
745
|
{
|
|
746
746
|
type: 'cosmosDB',
|
|
@@ -773,7 +773,7 @@ app.http('todo-get-by-id', {
|
|
|
773
773
|
app.http('todo-create', {
|
|
774
774
|
methods: ['POST'],
|
|
775
775
|
route: 'todo',
|
|
776
|
-
authLevel: '
|
|
776
|
+
authLevel: 'function',
|
|
777
777
|
extraOutputs: [
|
|
778
778
|
{
|
|
779
779
|
type: 'cosmosDB',
|
|
@@ -817,7 +817,7 @@ app.http('todo-create', {
|
|
|
817
817
|
app.http('todo-update', {
|
|
818
818
|
methods: ['PUT'],
|
|
819
819
|
route: 'todo/{id}',
|
|
820
|
-
authLevel: '
|
|
820
|
+
authLevel: 'function',
|
|
821
821
|
extraInputs: [
|
|
822
822
|
{
|
|
823
823
|
type: 'cosmosDB',
|
|
@@ -880,7 +880,7 @@ app.http('todo-update', {
|
|
|
880
880
|
app.http('todo-delete', {
|
|
881
881
|
methods: ['DELETE'],
|
|
882
882
|
route: 'todo/{id}',
|
|
883
|
-
authLevel: '
|
|
883
|
+
authLevel: 'function',
|
|
884
884
|
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
885
885
|
try {
|
|
886
886
|
const id = request.params.id;
|
|
@@ -928,7 +928,7 @@ const containerName = 'Issues';
|
|
|
928
928
|
app.http('issue-get-all', {
|
|
929
929
|
methods: ['GET'],
|
|
930
930
|
route: 'issue',
|
|
931
|
-
authLevel: '
|
|
931
|
+
authLevel: 'function',
|
|
932
932
|
extraInputs: [
|
|
933
933
|
{
|
|
934
934
|
type: 'cosmosDB',
|
|
@@ -962,7 +962,7 @@ app.http('issue-get-all', {
|
|
|
962
962
|
app.http('issue-get-by-id', {
|
|
963
963
|
methods: ['GET'],
|
|
964
964
|
route: 'issue/{id}',
|
|
965
|
-
authLevel: '
|
|
965
|
+
authLevel: 'function',
|
|
966
966
|
extraInputs: [
|
|
967
967
|
{
|
|
968
968
|
type: 'cosmosDB',
|
|
@@ -995,7 +995,7 @@ app.http('issue-get-by-id', {
|
|
|
995
995
|
app.http('issue-create', {
|
|
996
996
|
methods: ['POST'],
|
|
997
997
|
route: 'issue',
|
|
998
|
-
authLevel: '
|
|
998
|
+
authLevel: 'function',
|
|
999
999
|
extraOutputs: [
|
|
1000
1000
|
{
|
|
1001
1001
|
type: 'cosmosDB',
|
|
@@ -1039,7 +1039,7 @@ app.http('issue-create', {
|
|
|
1039
1039
|
app.http('issue-update', {
|
|
1040
1040
|
methods: ['PUT'],
|
|
1041
1041
|
route: 'issue/{id}',
|
|
1042
|
-
authLevel: '
|
|
1042
|
+
authLevel: 'function',
|
|
1043
1043
|
extraInputs: [
|
|
1044
1044
|
{
|
|
1045
1045
|
type: 'cosmosDB',
|
|
@@ -1102,7 +1102,7 @@ app.http('issue-update', {
|
|
|
1102
1102
|
app.http('issue-delete', {
|
|
1103
1103
|
methods: ['DELETE'],
|
|
1104
1104
|
route: 'issue/{id}',
|
|
1105
|
-
authLevel: '
|
|
1105
|
+
authLevel: 'function',
|
|
1106
1106
|
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
|
|
1107
1107
|
try {
|
|
1108
1108
|
const id = request.params.id;
|
|
@@ -23,6 +23,17 @@ function getFunctionsBaseUrl(): string {
|
|
|
23
23
|
return process.env.BACKEND_FUNCTIONS_BASE_URL || process.env.FUNCTIONS_BASE_URL || 'http://localhost:7071';
|
|
24
24
|
}
|
|
25
25
|
|
|
26
|
+
function isLocalFunctionsUrl(url: string): boolean {
|
|
27
|
+
return url.startsWith('http://localhost:') || url.startsWith('http://127.0.0.1:');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function getFunctionsKeyHeaders(functionsBaseUrl: string): Record<string, string> {
|
|
31
|
+
const key = process.env.BACKEND_FUNCTIONS_KEY;
|
|
32
|
+
if (key) return { 'x-functions-key': key };
|
|
33
|
+
if (isLocalFunctionsUrl(functionsBaseUrl)) return {};
|
|
34
|
+
throw new Error('BACKEND_FUNCTIONS_KEY is required for non-local Functions calls');
|
|
35
|
+
}
|
|
36
|
+
|
|
26
37
|
interface CallFunctionConfig<TInput = any, TOutput = any> {
|
|
27
38
|
/** HTTP メソッド */
|
|
28
39
|
method: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
|
@@ -61,11 +72,12 @@ export async function callFunction<TInput = any, TOutput = any>(
|
|
|
61
72
|
// Azure Functions を呼び出し
|
|
62
73
|
const functionsBaseUrl = getFunctionsBaseUrl();
|
|
63
74
|
const url = functionsBaseUrl + path;
|
|
75
|
+
const keyHeaders = getFunctionsKeyHeaders(functionsBaseUrl);
|
|
64
76
|
console.log(\`[BFF] \${method} \${url}\`);
|
|
65
77
|
|
|
66
78
|
const response = await fetch(url, {
|
|
67
79
|
method,
|
|
68
|
-
headers: { 'Content-Type': 'application/json' },
|
|
80
|
+
headers: { 'Content-Type': 'application/json', ...keyHeaders },
|
|
69
81
|
body: validatedBody !== undefined ? JSON.stringify(validatedBody) : undefined,
|
|
70
82
|
});
|
|
71
83
|
|
|
@@ -13,6 +13,17 @@ import {
|
|
|
13
13
|
generateLoginPage,
|
|
14
14
|
generateAuthContext,
|
|
15
15
|
generateBFFCallFunctionWithAuth,
|
|
16
|
+
generateBFFCallFunctionWithSwaAuth,
|
|
17
|
+
generateSwaAuthHelperTS,
|
|
18
|
+
generateSwaAuthHelperCSharp,
|
|
19
|
+
generateSwaAuthHelperPython,
|
|
20
|
+
generateExternalTokenAdapter,
|
|
21
|
+
generateAuthenticatedFetch,
|
|
22
|
+
generateExternalTokenAuthContext,
|
|
23
|
+
generateExternalTokenVerifierTS,
|
|
24
|
+
generateExternalTokenHelperTS,
|
|
25
|
+
generateExternalTokenVerifierCSharp,
|
|
26
|
+
generateExternalTokenVerifierPython,
|
|
16
27
|
generateAuthImportTS,
|
|
17
28
|
generateAuthGuardTS,
|
|
18
29
|
generateAuthGuardCSharp,
|
|
@@ -22,6 +33,7 @@ import { generateCompactAzureFunctionsCRUD } from "../core/scaffold/functions-ge
|
|
|
22
33
|
import { parseAuthPolicy } from "../core/scaffold/model-parser";
|
|
23
34
|
import { createBasicModelInfo } from "./fixtures";
|
|
24
35
|
import { ModelAuthPolicy, CustomJwtConfig } from "../types";
|
|
36
|
+
import { addAuthProviderToRootLayout, generateSwaAuthContext } from "../cli/commands/add-auth";
|
|
25
37
|
|
|
26
38
|
const defaultJwtConfig: CustomJwtConfig = {
|
|
27
39
|
userConnector: "mysql",
|
|
@@ -325,6 +337,97 @@ describe("generateBFFCallFunctionWithAuth", () => {
|
|
|
325
337
|
const code = generateBFFCallFunctionWithAuth();
|
|
326
338
|
expect(code).toContain("catch");
|
|
327
339
|
});
|
|
340
|
+
|
|
341
|
+
it("also attaches the Functions host key", () => {
|
|
342
|
+
const code = generateBFFCallFunctionWithAuth();
|
|
343
|
+
expect(code).toContain("BACKEND_FUNCTIONS_KEY");
|
|
344
|
+
expect(code).toContain("'x-functions-key'");
|
|
345
|
+
});
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
describe("SWA built-in auth generators", () => {
|
|
349
|
+
it("wraps the root layout with AuthProvider exactly once", () => {
|
|
350
|
+
const layout = `import './globals.css';\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return <html><body>{children}</body></html>;\n}\n`;
|
|
351
|
+
const once = addAuthProviderToRootLayout(layout);
|
|
352
|
+
const twice = addAuthProviderToRootLayout(once);
|
|
353
|
+
|
|
354
|
+
expect(twice).toBe(once);
|
|
355
|
+
expect(once.match(/import \{ AuthProvider \}/g)).toHaveLength(1);
|
|
356
|
+
expect(once.match(/<AuthProvider>/g)).toHaveLength(1);
|
|
357
|
+
expect(once).toContain("<AuthProvider>{children}</AuthProvider>");
|
|
358
|
+
});
|
|
359
|
+
|
|
360
|
+
it("preserves a React directive before the generated import", () => {
|
|
361
|
+
const layout = `'use client';\nimport { useAuth } from '@/lib/auth/auth-context';\nexport default function RootLayout({ children }: { children: React.ReactNode }) { return <body>{children}</body>; }`;
|
|
362
|
+
const updated = addAuthProviderToRootLayout(layout);
|
|
363
|
+
|
|
364
|
+
expect(updated).toMatch(/^'use client';\nimport \{ AuthProvider \}/);
|
|
365
|
+
expect(updated.match(/AuthProvider \} from/g)).toHaveLength(1);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
it("treats unavailable or invalid /.auth/me responses as anonymous", () => {
|
|
369
|
+
const code = generateSwaAuthContext();
|
|
370
|
+
expect(code).toContain("if (!response.ok)");
|
|
371
|
+
expect(code).toContain("typeof body !== 'object'");
|
|
372
|
+
expect(code).toContain(".catch(() => setUser(null))");
|
|
373
|
+
expect(code).toContain(".finally(() => setLoading(false))");
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
it("normalizes a valid SWA principal and its string roles", () => {
|
|
377
|
+
const code = generateSwaAuthContext();
|
|
378
|
+
expect(code).toContain("typeof value.userId !== 'string'");
|
|
379
|
+
expect(code).toContain("value.userRoles.filter");
|
|
380
|
+
expect(code).toContain("satisfies AuthUser");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("forwards a normalized platform principal with /.auth/me fallback", () => {
|
|
384
|
+
const code = generateBFFCallFunctionWithSwaAuth();
|
|
385
|
+
expect(code).toContain("x-ms-client-principal");
|
|
386
|
+
expect(code).toContain("/.auth/me");
|
|
387
|
+
expect(code).toContain("BACKEND_FUNCTIONS_KEY");
|
|
388
|
+
expect(code).not.toContain("fetchHeaders['Authorization']");
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it.each([
|
|
392
|
+
["typescript", generateSwaAuthHelperTS()],
|
|
393
|
+
["csharp", generateSwaAuthHelperCSharp()],
|
|
394
|
+
["python", generateSwaAuthHelperPython()],
|
|
395
|
+
])("generates %s principal validation and role checks", (_language, code) => {
|
|
396
|
+
expect(code).toContain("x-ms-client-principal");
|
|
397
|
+
expect(code).toContain("authenticated");
|
|
398
|
+
expect(code.toLowerCase()).toContain("role");
|
|
399
|
+
expect(code).not.toContain("JWT_SECRET");
|
|
400
|
+
});
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
describe("external token auth generators", () => {
|
|
404
|
+
it("generates a fail-closed frontend adapter and authenticated fetch", () => {
|
|
405
|
+
expect(generateExternalTokenAdapter()).toContain("return null");
|
|
406
|
+
const fetchCode = generateAuthenticatedFetch();
|
|
407
|
+
expect(fetchCode).toContain("Authorization");
|
|
408
|
+
expect(fetchCode).toContain("Bearer");
|
|
409
|
+
expect(fetchCode).toContain("status: 401");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
it("handles non-JSON auth/me responses without parsing them", () => {
|
|
413
|
+
const code = generateExternalTokenAuthContext();
|
|
414
|
+
expect(code).toContain("content-type");
|
|
415
|
+
expect(code).toContain(".catch(() => setUser(null))");
|
|
416
|
+
});
|
|
417
|
+
|
|
418
|
+
it.each([
|
|
419
|
+
["typescript", generateExternalTokenVerifierTS()],
|
|
420
|
+
["csharp", generateExternalTokenVerifierCSharp()],
|
|
421
|
+
["python", generateExternalTokenVerifierPython()],
|
|
422
|
+
])("generates a fail-closed %s verifier", (_language, code) => {
|
|
423
|
+
expect(code).toContain("External token verifier is not configured");
|
|
424
|
+
expect(code).not.toContain("JWT_SECRET");
|
|
425
|
+
});
|
|
426
|
+
|
|
427
|
+
it("awaits external token verification in TypeScript guards", () => {
|
|
428
|
+
expect(generateAuthGuardTS({}, "read", "external-token")).toContain("await requireAuth(request)");
|
|
429
|
+
expect(generateExternalTokenHelperTS()).toContain("await verifyExternalToken");
|
|
430
|
+
});
|
|
328
431
|
});
|
|
329
432
|
|
|
330
433
|
// ============================================================
|
|
@@ -340,6 +443,10 @@ describe("generateAuthImportTS", () => {
|
|
|
340
443
|
});
|
|
341
444
|
|
|
342
445
|
describe("generateAuthGuardTS", () => {
|
|
446
|
+
it("generates a named read/write policy guard", () => {
|
|
447
|
+
expect(generateAuthGuardTS({ read: "adminOnly", write: "lineUserOnly" }, "read")).toContain("requireAuth(request, \"adminOnly\")");
|
|
448
|
+
expect(generateAuthGuardTS({ policy: "adminOnly" }, "write")).toContain("requireAuth(request, \"adminOnly\")");
|
|
449
|
+
});
|
|
343
450
|
it("generates authentication-only guard for empty policy", () => {
|
|
344
451
|
const policy: ModelAuthPolicy = {};
|
|
345
452
|
const readGuard = generateAuthGuardTS(policy, "read");
|
|
@@ -1,7 +1,28 @@
|
|
|
1
|
-
import { validateConfig, loadConfigFromEnv } from "../core/config";
|
|
1
|
+
import { validateConfig, loadConfigFromEnv, normalizeAuthConfig } from "../core/config";
|
|
2
2
|
import { SwallowKitConfig } from "../types";
|
|
3
3
|
|
|
4
4
|
describe("validateConfig", () => {
|
|
5
|
+
it("normalizes a legacy provider as the default scheme", () => {
|
|
6
|
+
const auth = normalizeAuthConfig({ provider: "external-token", authorization: { defaultPolicy: "authenticated" } });
|
|
7
|
+
expect(auth?.schemes.default.provider).toBe("external-token");
|
|
8
|
+
expect(auth?.authorization.defaultPolicy).toBe("authenticated");
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("accepts SWA and external token named schemes", () => {
|
|
12
|
+
const result = validateConfig({ auth: { schemes: { admin: { provider: "swa" }, lineUser: { provider: "external-token" } }, authorization: { defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"] }, lineUserOnly: { schemes: ["lineUser"] } } } } });
|
|
13
|
+
expect(result).toEqual({ valid: true, errors: [] });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("rejects undefined scheme references and ambiguous bearer schemes", () => {
|
|
17
|
+
const missing = validateConfig({ auth: { schemes: { admin: { provider: "swa" } }, authorization: { defaultPolicy: "anonymous", policies: { bad: { schemes: ["missing"] } } } } });
|
|
18
|
+
expect(missing.errors.join("\n")).toContain("auth.authorization.policies.bad.schemes");
|
|
19
|
+
const ambiguous = validateConfig({ auth: { schemes: { a: { provider: "external-token" }, b: { provider: "external-token" } }, authorization: { defaultPolicy: "anonymous", policies: { bad: { schemes: ["a", "b"] } } } } });
|
|
20
|
+
expect(ambiguous.errors.join("\n")).toContain("deterministic");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("normalizes public to anonymous", () => {
|
|
24
|
+
expect(normalizeAuthConfig({ provider: "none", authorization: { defaultPolicy: "public" } })?.authorization.defaultPolicy).toBe("anonymous");
|
|
25
|
+
});
|
|
5
26
|
it("returns valid for a complete config", () => {
|
|
6
27
|
const config: SwallowKitConfig = {
|
|
7
28
|
database: { connectionString: "AccountEndpoint=https://..." },
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
buildFunctionsStartArgs,
|
|
7
7
|
buildFunctionsCoreToolsCommand,
|
|
8
8
|
buildNextDevArgs,
|
|
9
|
+
buildSwaStartArgs,
|
|
9
10
|
buildDevCommand,
|
|
10
11
|
buildPythonFunctionsEnv,
|
|
11
12
|
compareVersionNumbers,
|
|
@@ -13,6 +14,7 @@ import {
|
|
|
13
14
|
getFunctionsReadinessTimeoutMs,
|
|
14
15
|
getCSharpFunctionsBuildArtifactPaths,
|
|
15
16
|
getPythonVirtualEnvPaths,
|
|
17
|
+
getSwaCliInstallCommand,
|
|
16
18
|
parseCoreToolsVersion,
|
|
17
19
|
waitForHttpServerReady,
|
|
18
20
|
} from "../cli/commands/dev";
|
|
@@ -70,6 +72,23 @@ describe("dev command helpers", () => {
|
|
|
70
72
|
expect(buildFunctionsBaseUrl("127.0.0.1", "7072")).toBe("http://127.0.0.1:7072");
|
|
71
73
|
});
|
|
72
74
|
|
|
75
|
+
it("builds SWA CLI arguments without bypassing the Next.js BFF", () => {
|
|
76
|
+
expect(buildSwaStartArgs(undefined, "3000", "4280")).toEqual([
|
|
77
|
+
"start",
|
|
78
|
+
"http://localhost:3000",
|
|
79
|
+
"--swa-config-location",
|
|
80
|
+
".",
|
|
81
|
+
"--port",
|
|
82
|
+
"4280",
|
|
83
|
+
]);
|
|
84
|
+
expect(buildSwaStartArgs(undefined, "3000", "4280")).not.toContain("--api-devserver-url");
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("provides a project-local SWA CLI install command", () => {
|
|
88
|
+
expect(getSwaCliInstallCommand("pnpm")).toBe("pnpm add -Dw @azure/static-web-apps-cli");
|
|
89
|
+
expect(getSwaCliInstallCommand("npm")).toBe("npm install -D @azure/static-web-apps-cli");
|
|
90
|
+
});
|
|
91
|
+
|
|
73
92
|
it("gives C# more startup time before reporting readiness timeout", () => {
|
|
74
93
|
expect(getFunctionsReadinessTimeoutMs("csharp")).toBeGreaterThan(getFunctionsReadinessTimeoutMs("typescript"));
|
|
75
94
|
expect(getFunctionsReadinessTimeoutMs("csharp")).toBeGreaterThan(getFunctionsReadinessTimeoutMs("python"));
|
|
@@ -197,6 +216,8 @@ describe("dev CLI parser", () => {
|
|
|
197
216
|
noFunctions: true,
|
|
198
217
|
seedEnv: "local",
|
|
199
218
|
mockConnectors: true,
|
|
219
|
+
swaPort: "4290",
|
|
220
|
+
noSwa: true,
|
|
200
221
|
};
|
|
201
222
|
|
|
202
223
|
const optionsAfterCommand = await parseDevOptions([
|
|
@@ -215,6 +236,9 @@ describe("dev CLI parser", () => {
|
|
|
215
236
|
"--seed-env",
|
|
216
237
|
"local",
|
|
217
238
|
"--mock-connectors",
|
|
239
|
+
"--swa-port",
|
|
240
|
+
"4290",
|
|
241
|
+
"--no-swa",
|
|
218
242
|
]);
|
|
219
243
|
|
|
220
244
|
const optionsBeforeCommand = await parseDevOptions([
|
|
@@ -226,6 +250,9 @@ describe("dev CLI parser", () => {
|
|
|
226
250
|
"--seed-env",
|
|
227
251
|
"local",
|
|
228
252
|
"--mock-connectors",
|
|
253
|
+
"--swa-port",
|
|
254
|
+
"4290",
|
|
255
|
+
"--no-swa",
|
|
229
256
|
"--no-functions",
|
|
230
257
|
"--port",
|
|
231
258
|
"3001",
|
|
@@ -17,6 +17,8 @@ import {
|
|
|
17
17
|
injectSwallowKitNextConfig,
|
|
18
18
|
parseIgnoredBuilds,
|
|
19
19
|
buildCosmosDbFreeTierBicepSource,
|
|
20
|
+
buildFunctionsHostKeyBicepExpression,
|
|
21
|
+
buildStaticWebAppConfigBicepSource,
|
|
20
22
|
withNpmLockfileSafeManifests,
|
|
21
23
|
} from "../cli/commands/init";
|
|
22
24
|
|
|
@@ -100,6 +102,7 @@ module.exports = nextConfig;
|
|
|
100
102
|
expect(dependencies).toEqual({
|
|
101
103
|
"@azure/cosmos": "^4.0.0",
|
|
102
104
|
applicationinsights: "^3.3.0",
|
|
105
|
+
zod: "^4.0.0",
|
|
103
106
|
"@sample-app/shared": "workspace:*",
|
|
104
107
|
});
|
|
105
108
|
expect(dependencies).not.toHaveProperty("swallowkit");
|
|
@@ -212,6 +215,22 @@ describe("buildCosmosDbFreeTierBicepSource", () => {
|
|
|
212
215
|
});
|
|
213
216
|
});
|
|
214
217
|
|
|
218
|
+
describe("Functions host key infrastructure", () => {
|
|
219
|
+
it("reads the generated default host key without exposing a literal value", () => {
|
|
220
|
+
const expression = buildFunctionsHostKeyBicepExpression();
|
|
221
|
+
expect(expression).toContain("listKeys(");
|
|
222
|
+
expect(expression).toContain("functionKeys.default");
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it("passes the key to SWA through a secure module parameter", () => {
|
|
226
|
+
const source = buildStaticWebAppConfigBicepSource();
|
|
227
|
+
expect(source).toContain("@secure()");
|
|
228
|
+
expect(source).toContain("param functionsHostKey string");
|
|
229
|
+
expect(source).toContain("BACKEND_FUNCTIONS_KEY: functionsHostKey");
|
|
230
|
+
expect(source).not.toContain("output functionsHostKey");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
215
234
|
describe("GitHub Actions workflow generation", () => {
|
|
216
235
|
it("uses npm ci for the SWA workflow when the project was initialized with pnpm", () => {
|
|
217
236
|
const workflow = buildGitHubSwaWorkflow("npm", "pnpm");
|
|
@@ -9,7 +9,7 @@ const itWhenBuiltEntrypointExists = fs.existsSync(builtMcpEntrypoint) ? it : it.
|
|
|
9
9
|
|
|
10
10
|
describe("SwallowKit MCP tool definitions", () => {
|
|
11
11
|
it("delegates inspect_project to the machine CLI", async () => {
|
|
12
|
-
const runner = jest.fn(async (
|
|
12
|
+
const runner = jest.fn(async () => ({
|
|
13
13
|
stdout: JSON.stringify({
|
|
14
14
|
ok: true,
|
|
15
15
|
command: "inspect-project",
|
|
@@ -48,7 +48,7 @@ describe("SwallowKit MCP tool definitions", () => {
|
|
|
48
48
|
});
|
|
49
49
|
|
|
50
50
|
it("delegates scaffold_model with explicit args", async () => {
|
|
51
|
-
const runner = jest.fn(async (
|
|
51
|
+
const runner = jest.fn(async () => ({
|
|
52
52
|
stdout: JSON.stringify({
|
|
53
53
|
ok: true,
|
|
54
54
|
command: "generate-scaffold",
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as os from "os";
|
|
3
|
+
import * as path from "path";
|
|
4
|
+
import { addAuthCommand } from "../cli/commands/add-auth";
|
|
5
|
+
import {
|
|
6
|
+
generateBFFCallFunctionWithMultipleAuth,
|
|
7
|
+
generateNamedAuthRouterCSharp,
|
|
8
|
+
generateNamedAuthRouterPython,
|
|
9
|
+
generateNamedAuthRouterTS,
|
|
10
|
+
} from "../core/scaffold/auth-generator";
|
|
11
|
+
import { normalizeAuthConfig } from "../core/config";
|
|
12
|
+
import { parseAuthPolicy } from "../core/scaffold/model-parser";
|
|
13
|
+
import { createBasicModelInfo } from "./fixtures";
|
|
14
|
+
import { generateCSharpAzureFunctionsCRUD, generateCompactAzureFunctionsCRUD, generatePythonAzureFunctionsCRUD } from "../core/scaffold/functions-generator";
|
|
15
|
+
|
|
16
|
+
const auth = normalizeAuthConfig({
|
|
17
|
+
schemes: {
|
|
18
|
+
admin: { provider: "swa" },
|
|
19
|
+
lineUser: { provider: "external-token" },
|
|
20
|
+
},
|
|
21
|
+
authorization: {
|
|
22
|
+
defaultPolicy: "anonymous",
|
|
23
|
+
policies: {
|
|
24
|
+
adminOnly: { schemes: ["admin"], roles: ["authenticated"] },
|
|
25
|
+
lineUserOnly: { schemes: ["lineUser"], roles: ["authenticated"] },
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
})!;
|
|
29
|
+
|
|
30
|
+
describe("named authentication routers", () => {
|
|
31
|
+
it("generates deterministic TypeScript policy routing and normalized principals", () => {
|
|
32
|
+
const code = generateNamedAuthRouterTS(auth);
|
|
33
|
+
expect(code).toContain("policy.schemes.filter");
|
|
34
|
+
expect(code).toContain("candidates.length !== 1");
|
|
35
|
+
expect(code).toContain("subject: value.userId");
|
|
36
|
+
expect(code).toContain("scheme + ':' + principal.subject");
|
|
37
|
+
expect(code).toContain("new AuthError(403");
|
|
38
|
+
expect(code).toContain("new AuthError(503");
|
|
39
|
+
expect(code).not.toContain("JWT_SECRET");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("generates equivalent C# and Python named guards", () => {
|
|
43
|
+
const csharp = generateNamedAuthRouterCSharp(auth);
|
|
44
|
+
const python = generateNamedAuthRouterPython(auth);
|
|
45
|
+
expect(csharp).toContain("record AuthPrincipal(string Subject, string Scheme, string Issuer");
|
|
46
|
+
expect(csharp).toContain("candidates.Length != 1");
|
|
47
|
+
expect(csharp).toContain("HttpStatusCode.Forbidden");
|
|
48
|
+
expect(python).toContain('principal["scheme"] + ":" + principal["subject"]');
|
|
49
|
+
expect(python).toContain("len(candidates) != 1");
|
|
50
|
+
expect(python).toContain("AuthError(503");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("generates valid branch structure for an SWA-only Python router", () => {
|
|
54
|
+
const swaOnly = normalizeAuthConfig({ schemes: { admin: { provider: "swa", swa: { allowedProviders: ["github"] } } }, authorization: { defaultPolicy: "anonymous", policies: { adminOnly: { schemes: ["admin"] } } } })!;
|
|
55
|
+
const python = generateNamedAuthRouterPython(swaOnly);
|
|
56
|
+
expect(python).toContain(' if PROVIDERS[scheme] == "swa":');
|
|
57
|
+
expect(python).not.toContain(' elif PROVIDERS[scheme] == "swa":');
|
|
58
|
+
expect(python).toContain("ALLOWED_PROVIDERS");
|
|
59
|
+
expect(generateNamedAuthRouterCSharp(swaOnly)).toContain("AllowedProviders[scheme]");
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("scaffolds named read/write guards for all Functions languages", () => {
|
|
63
|
+
const policy = { read: "adminOnly", write: "lineUserOnly" };
|
|
64
|
+
const model = createBasicModelInfo();
|
|
65
|
+
const ts = generateCompactAzureFunctionsCRUD(model, "@fixture/shared", policy);
|
|
66
|
+
const cs = generateCSharpAzureFunctionsCRUD(model, policy);
|
|
67
|
+
const py = generatePythonAzureFunctionsCRUD(model, policy).blueprint;
|
|
68
|
+
expect(ts).toContain("./auth/auth-router");
|
|
69
|
+
expect(ts).toContain('requireAuth(request, "adminOnly")');
|
|
70
|
+
expect(ts).toContain('requireAuth(request, "lineUserOnly")');
|
|
71
|
+
expect(cs).toContain('AuthRouter.Authorize(request, "adminOnly")');
|
|
72
|
+
expect(cs).toContain('AuthRouter.Authorize(request, "lineUserOnly")');
|
|
73
|
+
expect(py).toContain("from auth.auth_router import");
|
|
74
|
+
expect(py).toContain('require_auth(req, "adminOnly")');
|
|
75
|
+
expect(py).toContain('require_auth(req, "lineUserOnly")');
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("forwards both credential sources and the Functions host key", () => {
|
|
79
|
+
const code = generateBFFCallFunctionWithMultipleAuth();
|
|
80
|
+
expect(code).toContain("fetchHeaders['Authorization']");
|
|
81
|
+
expect(code).toContain("fetchHeaders['x-ms-client-principal']");
|
|
82
|
+
expect(code).toContain("'x-functions-key'");
|
|
83
|
+
expect(code).not.toMatch(/console\.log\([^\n]*(authorization|principal|cookie)/i);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("parses model-wide and operation-specific named policies", () => {
|
|
87
|
+
expect(parseAuthPolicy("export const authPolicy = { policy: 'adminOnly' };"))
|
|
88
|
+
.toEqual({ policy: "adminOnly" });
|
|
89
|
+
expect(parseAuthPolicy("export const authPolicy = { read: 'adminOnly', write: 'lineUserOnly' };"))
|
|
90
|
+
.toEqual({ read: "adminOnly", write: "lineUserOnly" });
|
|
91
|
+
});
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
describe("add-auth named scheme CLI", () => {
|
|
95
|
+
const originalCwd = process.cwd();
|
|
96
|
+
let root: string;
|
|
97
|
+
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
root = fs.mkdtempSync(path.join(os.tmpdir(), "swallowkit-multi-auth-"));
|
|
100
|
+
fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "fixture" }));
|
|
101
|
+
fs.writeFileSync(path.join(root, "swallowkit.config.json"), JSON.stringify({ backend: { language: "typescript" }, auth: { schemes: {}, authorization: { defaultPolicy: "anonymous", policies: {} } } }, null, 2));
|
|
102
|
+
process.chdir(root);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
afterEach(() => {
|
|
106
|
+
process.chdir(originalCwd);
|
|
107
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("adds two schemes without overwriting the first verifier", async () => {
|
|
111
|
+
await addAuthCommand({ scheme: "admin", provider: "swa" });
|
|
112
|
+
await addAuthCommand({ scheme: "lineUser", provider: "external-token" });
|
|
113
|
+
const verifier = path.join(root, "functions", "src", "auth", "schemes", "line-user", "verifier.ts");
|
|
114
|
+
fs.writeFileSync(verifier, "// user verifier\n", "utf-8");
|
|
115
|
+
const config = JSON.parse(fs.readFileSync(path.join(root, "swallowkit.config.json"), "utf-8"));
|
|
116
|
+
expect(config.auth.schemes.admin.provider).toBe("swa");
|
|
117
|
+
expect(config.auth.schemes.lineUser.provider).toBe("external-token");
|
|
118
|
+
await expect(addAuthCommand({ scheme: "lineUser", provider: "external-token" })).rejects.toThrow("already exists");
|
|
119
|
+
expect(fs.readFileSync(verifier, "utf-8")).toBe("// user verifier\n");
|
|
120
|
+
expect(fs.readFileSync(path.join(root, "lib", "api", "call-function.ts"), "utf-8")).toContain("x-functions-key");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("updates a JavaScript config that already has a trailing comma", async () => {
|
|
124
|
+
fs.rmSync(path.join(root, "swallowkit.config.json"));
|
|
125
|
+
fs.writeFileSync(path.join(root, "swallowkit.config.js"), "module.exports = {\n backend: { language: 'typescript' },\n api: { endpoint: '/api/test' },\n};\n");
|
|
126
|
+
await addAuthCommand({ scheme: "admin", provider: "swa" });
|
|
127
|
+
const content = fs.readFileSync(path.join(root, "swallowkit.config.js"), "utf-8");
|
|
128
|
+
expect(content).not.toContain(",,");
|
|
129
|
+
const configModule = { exports: {} as Record<string, unknown> };
|
|
130
|
+
new Function("module", content)(configModule);
|
|
131
|
+
expect((configModule.exports as { auth: { schemes: { admin: { provider: string } } } }).auth.schemes.admin.provider).toBe("swa");
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -21,6 +21,14 @@ describe("generateBFFCallFunction", () => {
|
|
|
21
21
|
expect(code).toContain("export async function callFunction");
|
|
22
22
|
});
|
|
23
23
|
|
|
24
|
+
it("uses a server-side Functions key and fails closed outside localhost", () => {
|
|
25
|
+
const code = generateBFFCallFunction();
|
|
26
|
+
expect(code).toContain("BACKEND_FUNCTIONS_KEY");
|
|
27
|
+
expect(code).toContain("'x-functions-key'");
|
|
28
|
+
expect(code).toContain("required for non-local Functions calls");
|
|
29
|
+
expect(code).not.toContain("?code=");
|
|
30
|
+
});
|
|
31
|
+
|
|
24
32
|
it("includes z.ZodSchema typings", () => {
|
|
25
33
|
const code = generateBFFCallFunction();
|
|
26
34
|
expect(code).toContain("z.ZodSchema");
|