@rebasepro/server-core 0.2.1 → 0.2.4
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/common/src/collections/default-collections.d.ts +12 -0
- package/dist/common/src/collections/index.d.ts +1 -0
- package/dist/common/src/data/query_builder.d.ts +51 -0
- package/dist/common/src/index.d.ts +1 -0
- package/dist/common/src/util/permissions.d.ts +1 -0
- package/dist/{index-BZoAtuqi.js → index-Cr1D21av.js} +11 -3
- package/dist/index-Cr1D21av.js.map +1 -0
- package/dist/index.es.js +2340 -370
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2329 -355
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/logs-routes.d.ts +37 -0
- package/dist/server-core/src/api/rest/api-generator.d.ts +6 -0
- package/dist/server-core/src/api/types.d.ts +6 -1
- package/dist/server-core/src/auth/adapter-middleware.d.ts +7 -3
- package/dist/server-core/src/auth/admin-routes.d.ts +3 -3
- package/dist/server-core/src/auth/api-keys/api-key-middleware.d.ts +39 -0
- package/dist/server-core/src/auth/api-keys/api-key-permission-guard.d.ts +32 -0
- package/dist/server-core/src/auth/api-keys/api-key-routes.d.ts +20 -0
- package/dist/server-core/src/auth/api-keys/api-key-store.d.ts +35 -0
- package/dist/server-core/src/auth/api-keys/api-key-types.d.ts +88 -0
- package/dist/server-core/src/auth/api-keys/index.d.ts +17 -0
- package/dist/server-core/src/auth/{auth-overrides.d.ts → auth-hooks.d.ts} +69 -11
- package/dist/server-core/src/auth/builtin-auth-adapter.d.ts +3 -3
- package/dist/server-core/src/auth/index.d.ts +5 -3
- package/dist/server-core/src/auth/interfaces.d.ts +93 -3
- package/dist/server-core/src/auth/jwt.d.ts +3 -1
- package/dist/server-core/src/auth/mfa.d.ts +49 -0
- package/dist/server-core/src/auth/middleware.d.ts +7 -0
- package/dist/server-core/src/auth/rate-limiter.d.ts +19 -0
- package/dist/server-core/src/auth/routes.d.ts +3 -3
- package/dist/server-core/src/env.d.ts +6 -0
- package/dist/server-core/src/index.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +3 -3
- package/dist/server-core/src/services/webhook-service.d.ts +29 -0
- package/dist/server-core/src/storage/image-transform.d.ts +48 -0
- package/dist/server-core/src/storage/index.d.ts +3 -0
- package/dist/server-core/src/storage/tus-handler.d.ts +51 -0
- package/dist/types/src/controllers/auth.d.ts +2 -24
- package/dist/types/src/controllers/client.d.ts +0 -3
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data.d.ts +21 -0
- package/dist/types/src/controllers/data_driver.d.ts +18 -0
- package/dist/types/src/controllers/registry.d.ts +5 -4
- package/dist/types/src/rebase_context.d.ts +1 -1
- package/dist/types/src/types/auth_adapter.d.ts +2 -4
- package/dist/types/src/types/collections.d.ts +0 -4
- package/dist/types/src/types/component_ref.d.ts +1 -1
- package/dist/types/src/types/cron.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +1 -0
- package/dist/types/src/types/export_import.d.ts +1 -1
- package/dist/types/src/types/formex.d.ts +2 -2
- package/dist/types/src/types/properties.d.ts +2 -2
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +6 -4
- package/dist/types/src/users/roles.d.ts +0 -8
- package/jest.config.cjs +4 -1
- package/package.json +9 -7
- package/src/api/ast-schema-editor.ts +4 -4
- package/src/api/errors.ts +16 -7
- package/src/api/logs-routes.ts +129 -0
- package/src/api/rest/api-generator.ts +42 -2
- package/src/api/rest/query-parser.ts +37 -1
- package/src/api/types.ts +6 -1
- package/src/auth/adapter-middleware.ts +20 -4
- package/src/auth/admin-routes.ts +39 -14
- package/src/auth/api-keys/api-key-middleware.ts +126 -0
- package/src/auth/api-keys/api-key-permission-guard.ts +64 -0
- package/src/auth/api-keys/api-key-routes.ts +183 -0
- package/src/auth/api-keys/api-key-store.ts +317 -0
- package/src/auth/api-keys/api-key-types.ts +94 -0
- package/src/auth/api-keys/index.ts +37 -0
- package/src/auth/{auth-overrides.ts → auth-hooks.ts} +81 -14
- package/src/auth/builtin-auth-adapter.ts +31 -19
- package/src/auth/index.ts +7 -3
- package/src/auth/interfaces.ts +111 -3
- package/src/auth/jwt.ts +19 -5
- package/src/auth/mfa.ts +160 -0
- package/src/auth/middleware.ts +20 -1
- package/src/auth/rate-limiter.ts +92 -0
- package/src/auth/routes.ts +455 -24
- package/src/cron/cron-loader.ts +5 -10
- package/src/cron/cron-scheduler.ts +11 -12
- package/src/cron/cron-store.ts +8 -7
- package/src/env.ts +2 -0
- package/src/functions/function-loader.ts +6 -9
- package/src/index.ts +1 -2
- package/src/init.ts +37 -7
- package/src/serve-spa.ts +5 -4
- package/src/services/webhook-service.ts +155 -0
- package/src/storage/image-transform.ts +202 -0
- package/src/storage/index.ts +3 -0
- package/src/storage/routes.ts +56 -3
- package/src/storage/tus-handler.ts +315 -0
- package/src/utils/dev-port.ts +14 -0
- package/src/utils/logging.ts +9 -7
- package/test/admin-routes.test.ts +74 -7
- package/test/api-generator.test.ts +0 -1
- package/test/api-key-permission-guard.test.ts +132 -0
- package/test/ast-schema-editor.test.ts +26 -0
- package/test/auth-routes.test.ts +1 -2
- package/test/backend-hooks-admin.test.ts +3 -4
- package/test/email-templates.test.ts +169 -0
- package/test/function-loader.test.ts +124 -0
- package/test/jwt.test.ts +4 -2
- package/test/mfa.test.ts +197 -0
- package/test/middleware.test.ts +10 -5
- package/test/webhook-service.test.ts +249 -0
- package/vite.config.ts +3 -2
- package/dist/index-BZoAtuqi.js.map +0 -1
- package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
- package/src/bootstrappers/index.ts +0 -1
- package/src/singleton.test.ts +0 -28
|
@@ -43,8 +43,7 @@ function mockRole(id: string, isAdmin = false) {
|
|
|
43
43
|
name: id.charAt(0).toUpperCase() + id.slice(1),
|
|
44
44
|
isAdmin,
|
|
45
45
|
defaultPermissions: null,
|
|
46
|
-
collectionPermissions: null
|
|
47
|
-
config: null };
|
|
46
|
+
collectionPermissions: null };
|
|
48
47
|
}
|
|
49
48
|
|
|
50
49
|
|
|
@@ -56,7 +55,7 @@ function createApp(opts: { defaultRole?: string } = {}) {
|
|
|
56
55
|
getUserByIdentity: jest.fn().mockResolvedValue(null),
|
|
57
56
|
linkUserIdentity: jest.fn().mockResolvedValue(undefined),
|
|
58
57
|
getUserIdentities: jest.fn().mockResolvedValue([]),
|
|
59
|
-
getUserById: jest.fn().
|
|
58
|
+
getUserById: jest.fn().mockImplementation((id) => Promise.resolve(mockUser({ id }))),
|
|
60
59
|
createUser: jest.fn().mockImplementation((data) =>
|
|
61
60
|
Promise.resolve(mockUser({ email: data.email,
|
|
62
61
|
displayName: data.displayName,
|
|
@@ -98,14 +97,12 @@ roles: [mockRole("editor")] };
|
|
|
98
97
|
name: r.name,
|
|
99
98
|
isAdmin: r.isAdmin || false,
|
|
100
99
|
defaultPermissions: null,
|
|
101
|
-
collectionPermissions: null,
|
|
102
|
-
config: null })),
|
|
100
|
+
collectionPermissions: null })),
|
|
103
101
|
updateRole: jest.fn().mockImplementation((id, r) => Promise.resolve({ id,
|
|
104
102
|
name: r.name,
|
|
105
103
|
isAdmin: r.isAdmin || false,
|
|
106
104
|
defaultPermissions: null,
|
|
107
|
-
collectionPermissions: null,
|
|
108
|
-
config: null })),
|
|
105
|
+
collectionPermissions: null })),
|
|
109
106
|
deleteRole: jest.fn().mockResolvedValue(undefined)
|
|
110
107
|
} as unknown as jest.Mocked<AuthRepository>;
|
|
111
108
|
|
|
@@ -407,6 +404,8 @@ displayName: "Updated" }),
|
|
|
407
404
|
it("updates roles when specified", async () => {
|
|
408
405
|
const app = createApp();
|
|
409
406
|
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
|
|
407
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
408
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
|
|
410
409
|
mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
|
|
411
410
|
user: mockUser({ id: "u1" }),
|
|
412
411
|
roles: [mockRole("admin")]
|
|
@@ -421,6 +420,44 @@ displayName: "Updated" }),
|
|
|
421
420
|
expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["admin"]);
|
|
422
421
|
});
|
|
423
422
|
|
|
423
|
+
it("prevents demoting the last admin", async () => {
|
|
424
|
+
const app = createApp();
|
|
425
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
|
|
426
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
427
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
|
|
428
|
+
|
|
429
|
+
const res = await app.request("/admin/users/u1", {
|
|
430
|
+
method: "PUT",
|
|
431
|
+
headers: { "Content-Type": "application/json",
|
|
432
|
+
...adminAuth() },
|
|
433
|
+
body: JSON.stringify({ roles: ["editor"] })
|
|
434
|
+
});
|
|
435
|
+
expect(res.status).toBe(403);
|
|
436
|
+
const body = await res.json() as any;
|
|
437
|
+
expect(body.error.code).toBe("LAST_ADMIN");
|
|
438
|
+
expect(mockAuthRepo.setUserRoles).not.toHaveBeenCalled();
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
it("allows demoting an admin if there are other admins", async () => {
|
|
442
|
+
const app = createApp();
|
|
443
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
|
|
444
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
445
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
|
|
446
|
+
mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
|
|
447
|
+
user: mockUser({ id: "u1" }),
|
|
448
|
+
roles: [mockRole("editor")]
|
|
449
|
+
});
|
|
450
|
+
|
|
451
|
+
const res = await app.request("/admin/users/u1", {
|
|
452
|
+
method: "PUT",
|
|
453
|
+
headers: { "Content-Type": "application/json",
|
|
454
|
+
...adminAuth() },
|
|
455
|
+
body: JSON.stringify({ roles: ["editor"] })
|
|
456
|
+
});
|
|
457
|
+
expect(res.status).toBe(200);
|
|
458
|
+
expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["editor"]);
|
|
459
|
+
});
|
|
460
|
+
|
|
424
461
|
it("returns 404 for non-existent user", async () => {
|
|
425
462
|
const app = createApp();
|
|
426
463
|
mockAuthRepo.getUserById.mockResolvedValueOnce(null);
|
|
@@ -460,6 +497,36 @@ displayName: "Updated" }),
|
|
|
460
497
|
expect(body.error.code).toBe("SELF_DELETE");
|
|
461
498
|
});
|
|
462
499
|
|
|
500
|
+
it("prevents last-admin deletion", async () => {
|
|
501
|
+
const app = createApp();
|
|
502
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
|
|
503
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
504
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
|
|
505
|
+
|
|
506
|
+
const res = await app.request("/admin/users/u2", {
|
|
507
|
+
method: "DELETE",
|
|
508
|
+
headers: { ...adminAuth("admin-1") }
|
|
509
|
+
});
|
|
510
|
+
expect(res.status).toBe(403);
|
|
511
|
+
const body = await res.json() as any;
|
|
512
|
+
expect(body.error.code).toBe("LAST_ADMIN");
|
|
513
|
+
expect(mockAuthRepo.deleteUser).not.toHaveBeenCalled();
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
it("allows deleting an admin if there are other admins", async () => {
|
|
517
|
+
const app = createApp();
|
|
518
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
|
|
519
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
520
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
|
|
521
|
+
|
|
522
|
+
const res = await app.request("/admin/users/u2", {
|
|
523
|
+
method: "DELETE",
|
|
524
|
+
headers: { ...adminAuth("admin-1") }
|
|
525
|
+
});
|
|
526
|
+
expect(res.status).toBe(200);
|
|
527
|
+
expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u2");
|
|
528
|
+
});
|
|
529
|
+
|
|
463
530
|
it("returns 404 for non-existent user", async () => {
|
|
464
531
|
const app = createApp();
|
|
465
532
|
mockAuthRepo.getUserById.mockResolvedValueOnce(null);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {
|
|
2
|
+
httpMethodToOperation,
|
|
3
|
+
isOperationAllowed,
|
|
4
|
+
ApiKeyOperation
|
|
5
|
+
} from "../src/auth/api-keys/api-key-permission-guard";
|
|
6
|
+
import type { ApiKeyPermission } from "../src/auth/api-keys/api-key-types";
|
|
7
|
+
|
|
8
|
+
describe("httpMethodToOperation", () => {
|
|
9
|
+
it("maps GET to read", () => {
|
|
10
|
+
expect(httpMethodToOperation("GET")).toBe("read");
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it("maps HEAD to read", () => {
|
|
14
|
+
expect(httpMethodToOperation("HEAD")).toBe("read");
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it("maps OPTIONS to read", () => {
|
|
18
|
+
expect(httpMethodToOperation("OPTIONS")).toBe("read");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("maps POST to write", () => {
|
|
22
|
+
expect(httpMethodToOperation("POST")).toBe("write");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("maps PUT to write", () => {
|
|
26
|
+
expect(httpMethodToOperation("PUT")).toBe("write");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("maps PATCH to write", () => {
|
|
30
|
+
expect(httpMethodToOperation("PATCH")).toBe("write");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("maps DELETE to delete", () => {
|
|
34
|
+
expect(httpMethodToOperation("DELETE")).toBe("delete");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("is case-insensitive", () => {
|
|
38
|
+
expect(httpMethodToOperation("get")).toBe("read");
|
|
39
|
+
expect(httpMethodToOperation("post")).toBe("write");
|
|
40
|
+
expect(httpMethodToOperation("delete")).toBe("delete");
|
|
41
|
+
expect(httpMethodToOperation("Patch")).toBe("write");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("defaults unknown methods to read", () => {
|
|
45
|
+
expect(httpMethodToOperation("CONNECT")).toBe("read");
|
|
46
|
+
expect(httpMethodToOperation("TRACE")).toBe("read");
|
|
47
|
+
expect(httpMethodToOperation("UNKNOWN")).toBe("read");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("isOperationAllowed", () => {
|
|
52
|
+
it("allows operation with exact collection match", () => {
|
|
53
|
+
const permissions: ApiKeyPermission[] = [
|
|
54
|
+
{ collection: "users", operations: ["read", "write"] }
|
|
55
|
+
];
|
|
56
|
+
expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
|
|
57
|
+
expect(isOperationAllowed(permissions, "users", "write")).toBe(true);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("denies operation not in the operations list", () => {
|
|
61
|
+
const permissions: ApiKeyPermission[] = [
|
|
62
|
+
{ collection: "users", operations: ["read"] }
|
|
63
|
+
];
|
|
64
|
+
expect(isOperationAllowed(permissions, "users", "write")).toBe(false);
|
|
65
|
+
expect(isOperationAllowed(permissions, "users", "delete")).toBe(false);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("denies operation for non-matching collection", () => {
|
|
69
|
+
const permissions: ApiKeyPermission[] = [
|
|
70
|
+
{ collection: "users", operations: ["read", "write", "delete"] }
|
|
71
|
+
];
|
|
72
|
+
expect(isOperationAllowed(permissions, "posts", "read")).toBe(false);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("allows wildcard collection '*' to match any collection", () => {
|
|
76
|
+
const permissions: ApiKeyPermission[] = [
|
|
77
|
+
{ collection: "*", operations: ["read"] }
|
|
78
|
+
];
|
|
79
|
+
expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
|
|
80
|
+
expect(isOperationAllowed(permissions, "posts", "read")).toBe(true);
|
|
81
|
+
expect(isOperationAllowed(permissions, "anything", "read")).toBe(true);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("wildcard collection still checks operations", () => {
|
|
85
|
+
const permissions: ApiKeyPermission[] = [
|
|
86
|
+
{ collection: "*", operations: ["read"] }
|
|
87
|
+
];
|
|
88
|
+
expect(isOperationAllowed(permissions, "users", "write")).toBe(false);
|
|
89
|
+
expect(isOperationAllowed(permissions, "users", "delete")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("returns false for empty permissions array", () => {
|
|
93
|
+
expect(isOperationAllowed([], "users", "read")).toBe(false);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("checks all permission entries and grants if any match", () => {
|
|
97
|
+
const permissions: ApiKeyPermission[] = [
|
|
98
|
+
{ collection: "users", operations: ["read"] },
|
|
99
|
+
{ collection: "posts", operations: ["write"] },
|
|
100
|
+
{ collection: "comments", operations: ["read", "write", "delete"] }
|
|
101
|
+
];
|
|
102
|
+
expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
|
|
103
|
+
expect(isOperationAllowed(permissions, "posts", "write")).toBe(true);
|
|
104
|
+
expect(isOperationAllowed(permissions, "comments", "delete")).toBe(true);
|
|
105
|
+
expect(isOperationAllowed(permissions, "users", "write")).toBe(false);
|
|
106
|
+
expect(isOperationAllowed(permissions, "posts", "read")).toBe(false);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("handles overlapping permissions correctly", () => {
|
|
110
|
+
const permissions: ApiKeyPermission[] = [
|
|
111
|
+
{ collection: "users", operations: ["read"] },
|
|
112
|
+
{ collection: "users", operations: ["write"] }
|
|
113
|
+
];
|
|
114
|
+
expect(isOperationAllowed(permissions, "users", "read")).toBe(true);
|
|
115
|
+
expect(isOperationAllowed(permissions, "users", "write")).toBe(true);
|
|
116
|
+
expect(isOperationAllowed(permissions, "users", "delete")).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("handles mixed wildcard and specific permissions", () => {
|
|
120
|
+
const permissions: ApiKeyPermission[] = [
|
|
121
|
+
{ collection: "*", operations: ["read"] },
|
|
122
|
+
{ collection: "users", operations: ["write", "delete"] }
|
|
123
|
+
];
|
|
124
|
+
// Wildcard read for everything
|
|
125
|
+
expect(isOperationAllowed(permissions, "posts", "read")).toBe(true);
|
|
126
|
+
// Specific write/delete for users
|
|
127
|
+
expect(isOperationAllowed(permissions, "users", "write")).toBe(true);
|
|
128
|
+
expect(isOperationAllowed(permissions, "users", "delete")).toBe(true);
|
|
129
|
+
// No write for non-users
|
|
130
|
+
expect(isOperationAllowed(permissions, "posts", "write")).toBe(false);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -60,4 +60,30 @@ description: "Product name" },
|
|
|
60
60
|
expect(newContent).toContain("target: () => categoriesCollection");
|
|
61
61
|
expect(newContent).toContain('description: "Product name"');
|
|
62
62
|
});
|
|
63
|
+
|
|
64
|
+
it("should safely escape string values containing quotes and prevent code injection", async () => {
|
|
65
|
+
// Setup initial file
|
|
66
|
+
const fileContent = `import { EntityCollection } from "@rebasepro/types";
|
|
67
|
+
const productsCollection: EntityCollection = {
|
|
68
|
+
name: "Products",
|
|
69
|
+
slug: "products",
|
|
70
|
+
properties: {}
|
|
71
|
+
};
|
|
72
|
+
export default productsCollection;
|
|
73
|
+
`;
|
|
74
|
+
fs.writeFileSync(path.join(testDir, "products.ts"), fileContent);
|
|
75
|
+
|
|
76
|
+
// Payload attempting breakout
|
|
77
|
+
const maliciousData = {
|
|
78
|
+
name: 'Products", description: "Injected description", breakout: "true',
|
|
79
|
+
slug: "products",
|
|
80
|
+
properties: {}
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
await editor.saveCollection("products", maliciousData);
|
|
84
|
+
|
|
85
|
+
const newContent = fs.readFileSync(path.join(testDir, "products.ts"), "utf-8");
|
|
86
|
+
// Verify that the quote was safely escaped and did not break out to create a new property
|
|
87
|
+
expect(newContent).toContain('name: "Products\\", description: \\"Injected description\\", breakout: \\"true"');
|
|
88
|
+
});
|
|
63
89
|
});
|
package/test/auth-routes.test.ts
CHANGED
|
@@ -54,8 +54,7 @@ function mockRole(id: string, isAdmin = false) {
|
|
|
54
54
|
name: id.charAt(0).toUpperCase() + id.slice(1),
|
|
55
55
|
isAdmin,
|
|
56
56
|
defaultPermissions: null,
|
|
57
|
-
collectionPermissions: null
|
|
58
|
-
config: null };
|
|
57
|
+
collectionPermissions: null };
|
|
59
58
|
}
|
|
60
59
|
|
|
61
60
|
let mockAuthRepo: jest.Mocked<AuthRepository>;
|
|
@@ -49,8 +49,7 @@ function mockRole(id: string, isAdmin = false) {
|
|
|
49
49
|
name: id.charAt(0).toUpperCase() + id.slice(1),
|
|
50
50
|
isAdmin,
|
|
51
51
|
defaultPermissions: null,
|
|
52
|
-
collectionPermissions: null
|
|
53
|
-
config: null
|
|
52
|
+
collectionPermissions: null
|
|
54
53
|
};
|
|
55
54
|
}
|
|
56
55
|
|
|
@@ -98,11 +97,11 @@ function createApp(hooks?: BackendHooks) {
|
|
|
98
97
|
getRoleById: jest.fn().mockResolvedValue(null),
|
|
99
98
|
createRole: jest.fn().mockImplementation(r => Promise.resolve({
|
|
100
99
|
id: r.id, name: r.name, isAdmin: r.isAdmin || false,
|
|
101
|
-
defaultPermissions: null, collectionPermissions: null
|
|
100
|
+
defaultPermissions: null, collectionPermissions: null
|
|
102
101
|
})),
|
|
103
102
|
updateRole: jest.fn().mockImplementation((id, r) => Promise.resolve({
|
|
104
103
|
id, name: r.name, isAdmin: r.isAdmin || false,
|
|
105
|
-
defaultPermissions: null, collectionPermissions: null
|
|
104
|
+
defaultPermissions: null, collectionPermissions: null
|
|
106
105
|
})),
|
|
107
106
|
deleteRole: jest.fn().mockResolvedValue(undefined)
|
|
108
107
|
};
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getPasswordResetTemplate,
|
|
3
|
+
getEmailVerificationTemplate,
|
|
4
|
+
getUserInvitationTemplate,
|
|
5
|
+
getWelcomeEmailTemplate
|
|
6
|
+
} from "../src/email/templates";
|
|
7
|
+
|
|
8
|
+
describe("getPasswordResetTemplate", () => {
|
|
9
|
+
const user = { email: "john@example.com", displayName: "John Doe" };
|
|
10
|
+
const resetUrl = "https://example.com/reset?token=abc123";
|
|
11
|
+
|
|
12
|
+
it("returns subject, html, and text", () => {
|
|
13
|
+
const result = getPasswordResetTemplate(resetUrl, user);
|
|
14
|
+
expect(result.subject).toBeTruthy();
|
|
15
|
+
expect(result.html).toBeTruthy();
|
|
16
|
+
expect(result.text).toBeTruthy();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("subject includes default app name", () => {
|
|
20
|
+
const result = getPasswordResetTemplate(resetUrl, user);
|
|
21
|
+
expect(result.subject).toContain("Rebase");
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it("subject includes custom app name", () => {
|
|
25
|
+
const result = getPasswordResetTemplate(resetUrl, user, "MyApp");
|
|
26
|
+
expect(result.subject).toContain("MyApp");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("html contains the reset URL", () => {
|
|
30
|
+
const result = getPasswordResetTemplate(resetUrl, user);
|
|
31
|
+
expect(result.html).toContain(resetUrl);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("text contains the reset URL", () => {
|
|
35
|
+
const result = getPasswordResetTemplate(resetUrl, user);
|
|
36
|
+
expect(result.text).toContain(resetUrl);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("uses displayName for greeting", () => {
|
|
40
|
+
const result = getPasswordResetTemplate(resetUrl, user);
|
|
41
|
+
expect(result.html).toContain("John Doe");
|
|
42
|
+
expect(result.text).toContain("John Doe");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("falls back to email prefix when displayName is null", () => {
|
|
46
|
+
const userNoName = { email: "jane@example.com", displayName: null };
|
|
47
|
+
const result = getPasswordResetTemplate(resetUrl, userNoName);
|
|
48
|
+
expect(result.html).toContain("jane");
|
|
49
|
+
expect(result.text).toContain("jane");
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("html contains DOCTYPE and body tags", () => {
|
|
53
|
+
const result = getPasswordResetTemplate(resetUrl, user);
|
|
54
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
55
|
+
expect(result.html).toContain("<body");
|
|
56
|
+
expect(result.html).toContain("</body>");
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("getEmailVerificationTemplate", () => {
|
|
61
|
+
const user = { email: "alice@example.com", displayName: "Alice" };
|
|
62
|
+
const verifyUrl = "https://example.com/verify?token=xyz789";
|
|
63
|
+
|
|
64
|
+
it("returns subject, html, and text", () => {
|
|
65
|
+
const result = getEmailVerificationTemplate(verifyUrl, user);
|
|
66
|
+
expect(result.subject).toBeTruthy();
|
|
67
|
+
expect(result.html).toBeTruthy();
|
|
68
|
+
expect(result.text).toBeTruthy();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
it("subject mentions email verification", () => {
|
|
72
|
+
const result = getEmailVerificationTemplate(verifyUrl, user);
|
|
73
|
+
expect(result.subject.toLowerCase()).toContain("verify");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("html contains the verification URL", () => {
|
|
77
|
+
const result = getEmailVerificationTemplate(verifyUrl, user);
|
|
78
|
+
expect(result.html).toContain(verifyUrl);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("text contains the verification URL", () => {
|
|
82
|
+
const result = getEmailVerificationTemplate(verifyUrl, user);
|
|
83
|
+
expect(result.text).toContain(verifyUrl);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("uses displayName when available", () => {
|
|
87
|
+
const result = getEmailVerificationTemplate(verifyUrl, user);
|
|
88
|
+
expect(result.html).toContain("Alice");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("uses custom app name", () => {
|
|
92
|
+
const result = getEmailVerificationTemplate(verifyUrl, user, "CustomApp");
|
|
93
|
+
expect(result.subject).toContain("CustomApp");
|
|
94
|
+
expect(result.html).toContain("CustomApp");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("getUserInvitationTemplate", () => {
|
|
99
|
+
const user = { email: "bob@example.com", displayName: "Bob" };
|
|
100
|
+
const setPasswordUrl = "https://example.com/set-password?token=def456";
|
|
101
|
+
|
|
102
|
+
it("returns subject, html, and text", () => {
|
|
103
|
+
const result = getUserInvitationTemplate(setPasswordUrl, user);
|
|
104
|
+
expect(result.subject).toBeTruthy();
|
|
105
|
+
expect(result.html).toBeTruthy();
|
|
106
|
+
expect(result.text).toBeTruthy();
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("subject mentions invitation", () => {
|
|
110
|
+
const result = getUserInvitationTemplate(setPasswordUrl, user);
|
|
111
|
+
expect(result.subject.toLowerCase()).toContain("invited");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("html contains the set password URL", () => {
|
|
115
|
+
const result = getUserInvitationTemplate(setPasswordUrl, user);
|
|
116
|
+
expect(result.html).toContain(setPasswordUrl);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("text contains the set password URL", () => {
|
|
120
|
+
const result = getUserInvitationTemplate(setPasswordUrl, user);
|
|
121
|
+
expect(result.text).toContain(setPasswordUrl);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("falls back to email prefix when no displayName", () => {
|
|
125
|
+
const userNoName = { email: "charlie@example.com" };
|
|
126
|
+
const result = getUserInvitationTemplate(setPasswordUrl, userNoName);
|
|
127
|
+
expect(result.html).toContain("charlie");
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
describe("getWelcomeEmailTemplate", () => {
|
|
132
|
+
const user = { email: "dave@example.com", displayName: "Dave" };
|
|
133
|
+
|
|
134
|
+
it("returns subject, html, and text", () => {
|
|
135
|
+
const result = getWelcomeEmailTemplate(user);
|
|
136
|
+
expect(result.subject).toBeTruthy();
|
|
137
|
+
expect(result.html).toBeTruthy();
|
|
138
|
+
expect(result.text).toBeTruthy();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("includes the app name in subject", () => {
|
|
142
|
+
const result = getWelcomeEmailTemplate(user, "SuperApp");
|
|
143
|
+
expect(result.subject).toContain("SuperApp");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("uses displayName for greeting", () => {
|
|
147
|
+
const result = getWelcomeEmailTemplate(user);
|
|
148
|
+
expect(result.html).toContain("Dave");
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("includes login URL when provided", () => {
|
|
152
|
+
const loginUrl = "https://example.com/login";
|
|
153
|
+
const result = getWelcomeEmailTemplate(user, "Rebase", loginUrl);
|
|
154
|
+
expect(result.html).toContain(loginUrl);
|
|
155
|
+
expect(result.text).toContain(loginUrl);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("handles missing login URL gracefully", () => {
|
|
159
|
+
const result = getWelcomeEmailTemplate(user);
|
|
160
|
+
expect(result.html).toBeTruthy();
|
|
161
|
+
expect(result.text).toBeTruthy();
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("html contains proper structure", () => {
|
|
165
|
+
const result = getWelcomeEmailTemplate(user);
|
|
166
|
+
expect(result.html).toContain("<!DOCTYPE html>");
|
|
167
|
+
expect(result.html).toContain("<body");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import * as os from "os";
|
|
4
|
+
import { Hono } from "hono";
|
|
5
|
+
import { loadFunctionsFromDirectory } from "../src/functions/function-loader";
|
|
6
|
+
import { createFunctionRoutes } from "../src/functions/function-routes";
|
|
7
|
+
|
|
8
|
+
describe("Function Loader & Routes", () => {
|
|
9
|
+
let tempDir: string;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
// Create temporary directory in OS temp folder
|
|
13
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "rebase-functions-test-"));
|
|
14
|
+
|
|
15
|
+
// 1. Write a valid Hono app default export using CommonJS
|
|
16
|
+
fs.writeFileSync(
|
|
17
|
+
path.join(tempDir, "valid-app.js"),
|
|
18
|
+
`
|
|
19
|
+
const { Hono } = require("hono");
|
|
20
|
+
const app = new Hono();
|
|
21
|
+
app.get("/hello", (c) => c.text("hello from valid-app"));
|
|
22
|
+
module.exports = app;
|
|
23
|
+
`
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// 2. Write a valid Hono app factory default export using CommonJS
|
|
27
|
+
fs.writeFileSync(
|
|
28
|
+
path.join(tempDir, "valid-factory.js"),
|
|
29
|
+
`
|
|
30
|
+
const { Hono } = require("hono");
|
|
31
|
+
module.exports = () => {
|
|
32
|
+
const app = new Hono();
|
|
33
|
+
app.post("/hello", (c) => c.text("hello from valid-factory"));
|
|
34
|
+
return app;
|
|
35
|
+
};
|
|
36
|
+
`
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
// 3. Write an invalid default export
|
|
40
|
+
fs.writeFileSync(
|
|
41
|
+
path.join(tempDir, "invalid-export.js"),
|
|
42
|
+
`
|
|
43
|
+
module.exports = { foo: "bar" };
|
|
44
|
+
`
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
// 4. Write a file with no default export (exports is an object but no module.exports default value)
|
|
48
|
+
fs.writeFileSync(
|
|
49
|
+
path.join(tempDir, "no-default.js"),
|
|
50
|
+
`
|
|
51
|
+
exports.other = "value";
|
|
52
|
+
`
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
// 5. Write an ignored non-js/ts file
|
|
56
|
+
fs.writeFileSync(
|
|
57
|
+
path.join(tempDir, "ignored.txt"),
|
|
58
|
+
"some random text"
|
|
59
|
+
);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
afterAll(() => {
|
|
63
|
+
// Clean up temp directory
|
|
64
|
+
if (tempDir && fs.existsSync(tempDir)) {
|
|
65
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
describe("loadFunctionsFromDirectory", () => {
|
|
70
|
+
it("should return empty array if directory does not exist", async () => {
|
|
71
|
+
const result = await loadFunctionsFromDirectory(path.join(tempDir, "non-existent-folder"));
|
|
72
|
+
expect(result).toEqual([]);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("should load valid apps and factories while ignoring invalid files", async () => {
|
|
76
|
+
const loaded = await loadFunctionsFromDirectory(tempDir);
|
|
77
|
+
|
|
78
|
+
// We expect exactly 2 functions to be loaded: "valid-app" and "valid-factory"
|
|
79
|
+
expect(loaded).toHaveLength(2);
|
|
80
|
+
|
|
81
|
+
const names = loaded.map(f => f.name);
|
|
82
|
+
expect(names).toContain("valid-app");
|
|
83
|
+
expect(names).toContain("valid-factory");
|
|
84
|
+
|
|
85
|
+
// Verify they both look like Hono instances
|
|
86
|
+
const validApp = loaded.find(f => f.name === "valid-app")!;
|
|
87
|
+
expect(typeof validApp.app.fetch).toBe("function");
|
|
88
|
+
expect(Array.isArray(validApp.app.routes)).toBe(true);
|
|
89
|
+
|
|
90
|
+
const validFactory = loaded.find(f => f.name === "valid-factory")!;
|
|
91
|
+
expect(typeof validFactory.app.fetch).toBe("function");
|
|
92
|
+
expect(Array.isArray(validFactory.app.routes)).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe("createFunctionRoutes", () => {
|
|
97
|
+
it("should mount and route correctly", async () => {
|
|
98
|
+
const loaded = await loadFunctionsFromDirectory(tempDir);
|
|
99
|
+
const routes = createFunctionRoutes(loaded);
|
|
100
|
+
|
|
101
|
+
// 1. Verify GET / lists loaded functions
|
|
102
|
+
const listRes = await routes.request("/");
|
|
103
|
+
expect(listRes.status).toBe(200);
|
|
104
|
+
|
|
105
|
+
const listData = await listRes.json();
|
|
106
|
+
expect(listData).toEqual({
|
|
107
|
+
functions: [
|
|
108
|
+
{ name: "valid-app", endpoint: "/functions/valid-app" },
|
|
109
|
+
{ name: "valid-factory", endpoint: "/functions/valid-factory" }
|
|
110
|
+
]
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// 2. Verify routing to valid-app
|
|
114
|
+
const appRes = await routes.request("/valid-app/hello");
|
|
115
|
+
expect(appRes.status).toBe(200);
|
|
116
|
+
expect(await appRes.text()).toBe("hello from valid-app");
|
|
117
|
+
|
|
118
|
+
// 3. Verify routing to valid-factory
|
|
119
|
+
const factoryRes = await routes.request("/valid-factory/hello", { method: "POST" });
|
|
120
|
+
expect(factoryRes.status).toBe(200);
|
|
121
|
+
expect(await factoryRes.text()).toBe("hello from valid-factory");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
});
|
package/test/jwt.test.ts
CHANGED
|
@@ -56,7 +56,8 @@ accessExpiresIn: "2h" });
|
|
|
56
56
|
const payload = verifyAccessToken(token);
|
|
57
57
|
expect(payload).toEqual({
|
|
58
58
|
userId: "user-456",
|
|
59
|
-
roles: ["viewer"]
|
|
59
|
+
roles: ["viewer"],
|
|
60
|
+
aal: "aal1"
|
|
60
61
|
});
|
|
61
62
|
});
|
|
62
63
|
|
|
@@ -73,7 +74,8 @@ accessExpiresIn: "2h" });
|
|
|
73
74
|
const payload = verifyAccessToken(token);
|
|
74
75
|
expect(payload).toEqual({
|
|
75
76
|
userId: "user-123",
|
|
76
|
-
roles: ["admin"]
|
|
77
|
+
roles: ["admin"],
|
|
78
|
+
aal: "aal1"
|
|
77
79
|
});
|
|
78
80
|
});
|
|
79
81
|
|