@rebasepro/server-core 0.2.3 → 0.2.5
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 +9 -0
- package/dist/common/src/collections/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 +2309 -370
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +2298 -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 +4 -26
- package/dist/types/src/controllers/client.d.ts +25 -43
- package/dist/types/src/controllers/collection_registry.d.ts +1 -1
- package/dist/types/src/controllers/data.d.ts +4 -0
- package/dist/types/src/controllers/data_driver.d.ts +23 -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 +5 -60
- package/dist/types/src/types/backend.d.ts +2 -2
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- 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 +9 -7
- package/dist/types/src/types/translations.d.ts +28 -12
- package/dist/types/src/types/user_management_delegate.d.ts +22 -57
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +8 -6
- 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 +36 -100
- 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 +35 -73
- package/src/auth/custom-auth-adapter.ts +1 -1
- 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 +56 -8
- 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 +62 -164
- 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 -29
- package/test/custom-auth-adapter.test.ts +2 -10
- 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/dist/types/src/users/roles.d.ts +0 -22
- 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,221 +420,118 @@ displayName: "Updated" }),
|
|
|
421
420
|
expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["admin"]);
|
|
422
421
|
});
|
|
423
422
|
|
|
424
|
-
it("
|
|
423
|
+
it("prevents demoting the last admin", async () => {
|
|
425
424
|
const app = createApp();
|
|
426
|
-
mockAuthRepo.getUserById.mockResolvedValueOnce(
|
|
425
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
|
|
426
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
427
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
|
|
427
428
|
|
|
428
|
-
const res = await app.request("/admin/users/
|
|
429
|
+
const res = await app.request("/admin/users/u1", {
|
|
429
430
|
method: "PUT",
|
|
430
431
|
headers: { "Content-Type": "application/json",
|
|
431
432
|
...adminAuth() },
|
|
432
|
-
body: JSON.stringify({
|
|
433
|
-
});
|
|
434
|
-
expect(res.status).toBe(404);
|
|
435
|
-
});
|
|
436
|
-
});
|
|
437
|
-
|
|
438
|
-
describe("DELETE /admin/users/:userId", () => {
|
|
439
|
-
it("deletes a user", async () => {
|
|
440
|
-
const app = createApp();
|
|
441
|
-
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
|
|
442
|
-
|
|
443
|
-
const res = await app.request("/admin/users/u1", {
|
|
444
|
-
method: "DELETE",
|
|
445
|
-
headers: { ...adminAuth("admin-1") }
|
|
446
|
-
});
|
|
447
|
-
expect(res.status).toBe(200);
|
|
448
|
-
expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u1");
|
|
449
|
-
});
|
|
450
|
-
|
|
451
|
-
it("prevents self-deletion", async () => {
|
|
452
|
-
const app = createApp();
|
|
453
|
-
|
|
454
|
-
const res = await app.request("/admin/users/admin-1", {
|
|
455
|
-
method: "DELETE",
|
|
456
|
-
headers: { ...adminAuth("admin-1") }
|
|
433
|
+
body: JSON.stringify({ roles: ["editor"] })
|
|
457
434
|
});
|
|
458
|
-
expect(res.status).toBe(
|
|
435
|
+
expect(res.status).toBe(403);
|
|
459
436
|
const body = await res.json() as any;
|
|
460
|
-
expect(body.error.code).toBe("
|
|
437
|
+
expect(body.error.code).toBe("LAST_ADMIN");
|
|
438
|
+
expect(mockAuthRepo.setUserRoles).not.toHaveBeenCalled();
|
|
461
439
|
});
|
|
462
440
|
|
|
463
|
-
it("
|
|
464
|
-
const app = createApp();
|
|
465
|
-
mockAuthRepo.getUserById.mockResolvedValueOnce(null);
|
|
466
|
-
|
|
467
|
-
const res = await app.request("/admin/users/missing", {
|
|
468
|
-
method: "DELETE",
|
|
469
|
-
headers: { ...adminAuth() }
|
|
470
|
-
});
|
|
471
|
-
expect(res.status).toBe(404);
|
|
472
|
-
});
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
// ── Role CRUD ───────────────────────────────────────────────────────
|
|
477
|
-
describe("Role Management", () => {
|
|
478
|
-
describe("GET /admin/roles", () => {
|
|
479
|
-
it("returns list of roles", async () => {
|
|
441
|
+
it("allows demoting an admin if there are other admins", async () => {
|
|
480
442
|
const app = createApp();
|
|
481
|
-
mockAuthRepo.
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
|
|
488
|
-
expect(res.status).toBe(200);
|
|
489
|
-
const body = await res.json() as any;
|
|
490
|
-
expect(body.roles).toHaveLength(3);
|
|
491
|
-
expect(body.roles[0].isAdmin).toBe(true);
|
|
492
|
-
});
|
|
493
|
-
});
|
|
494
|
-
|
|
495
|
-
describe("GET /admin/roles/:roleId", () => {
|
|
496
|
-
it("returns role by ID", async () => {
|
|
497
|
-
const app = createApp();
|
|
498
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("admin", true));
|
|
499
|
-
|
|
500
|
-
const res = await app.request("/admin/roles/admin", { headers: { ...adminAuth() } });
|
|
501
|
-
expect(res.status).toBe(200);
|
|
502
|
-
const body = await res.json() as any;
|
|
503
|
-
expect(body.role.id).toBe("admin");
|
|
504
|
-
expect(body.role.isAdmin).toBe(true);
|
|
505
|
-
});
|
|
506
|
-
|
|
507
|
-
it("returns 404 for non-existent role", async () => {
|
|
508
|
-
const app = createApp();
|
|
509
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
|
|
510
|
-
|
|
511
|
-
const res = await app.request("/admin/roles/missing", { headers: { ...adminAuth() } });
|
|
512
|
-
expect(res.status).toBe(404);
|
|
513
|
-
});
|
|
514
|
-
});
|
|
515
|
-
|
|
516
|
-
describe("POST /admin/roles", () => {
|
|
517
|
-
it("creates a new role", async () => {
|
|
518
|
-
const app = createApp();
|
|
519
|
-
|
|
520
|
-
const res = await app.request("/admin/roles", {
|
|
521
|
-
...json({ id: "custom",
|
|
522
|
-
name: "Custom Role" }),
|
|
523
|
-
headers: { ...json({}).headers,
|
|
524
|
-
...adminAuth() }
|
|
525
|
-
});
|
|
526
|
-
expect(res.status).toBe(201);
|
|
527
|
-
const body = await res.json() as any;
|
|
528
|
-
expect(body.role.id).toBe("custom");
|
|
529
|
-
});
|
|
530
|
-
|
|
531
|
-
it("returns 400 for missing id or name", async () => {
|
|
532
|
-
const app = createApp();
|
|
533
|
-
|
|
534
|
-
const res = await app.request("/admin/roles", {
|
|
535
|
-
...json({ id: "nope" }),
|
|
536
|
-
headers: { ...json({}).headers,
|
|
537
|
-
...adminAuth() }
|
|
538
|
-
});
|
|
539
|
-
expect(res.status).toBe(400);
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
it("returns 409 when role already exists", async () => {
|
|
543
|
-
const app = createApp();
|
|
544
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
|
|
545
|
-
|
|
546
|
-
const res = await app.request("/admin/roles", {
|
|
547
|
-
...json({ id: "custom",
|
|
548
|
-
name: "Dup" }),
|
|
549
|
-
headers: { ...json({}).headers,
|
|
550
|
-
...adminAuth() }
|
|
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")]
|
|
551
449
|
});
|
|
552
|
-
expect(res.status).toBe(409);
|
|
553
|
-
const body = await res.json() as any;
|
|
554
|
-
expect(body.error.code).toBe("ROLE_EXISTS");
|
|
555
|
-
});
|
|
556
|
-
});
|
|
557
|
-
|
|
558
|
-
describe("PUT /admin/roles/:roleId", () => {
|
|
559
|
-
it("updates an existing role", async () => {
|
|
560
|
-
const app = createApp();
|
|
561
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("editor"));
|
|
562
450
|
|
|
563
|
-
const res = await app.request("/admin/
|
|
451
|
+
const res = await app.request("/admin/users/u1", {
|
|
564
452
|
method: "PUT",
|
|
565
453
|
headers: { "Content-Type": "application/json",
|
|
566
454
|
...adminAuth() },
|
|
567
|
-
body: JSON.stringify({
|
|
455
|
+
body: JSON.stringify({ roles: ["editor"] })
|
|
568
456
|
});
|
|
569
457
|
expect(res.status).toBe(200);
|
|
570
|
-
expect(mockAuthRepo.
|
|
571
|
-
name: "Super Editor"
|
|
572
|
-
}));
|
|
458
|
+
expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["editor"]);
|
|
573
459
|
});
|
|
574
460
|
|
|
575
|
-
it("returns 404 for non-existent
|
|
461
|
+
it("returns 404 for non-existent user", async () => {
|
|
576
462
|
const app = createApp();
|
|
577
|
-
mockAuthRepo.
|
|
463
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(null);
|
|
578
464
|
|
|
579
|
-
const res = await app.request("/admin/
|
|
465
|
+
const res = await app.request("/admin/users/missing", {
|
|
580
466
|
method: "PUT",
|
|
581
467
|
headers: { "Content-Type": "application/json",
|
|
582
468
|
...adminAuth() },
|
|
583
|
-
body: JSON.stringify({
|
|
469
|
+
body: JSON.stringify({ displayName: "Updated" })
|
|
584
470
|
});
|
|
585
471
|
expect(res.status).toBe(404);
|
|
586
472
|
});
|
|
587
473
|
});
|
|
588
474
|
|
|
589
|
-
describe("DELETE /admin/
|
|
590
|
-
it("deletes a
|
|
475
|
+
describe("DELETE /admin/users/:userId", () => {
|
|
476
|
+
it("deletes a user", async () => {
|
|
591
477
|
const app = createApp();
|
|
592
|
-
mockAuthRepo.
|
|
478
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
|
|
593
479
|
|
|
594
|
-
const res = await app.request("/admin/
|
|
480
|
+
const res = await app.request("/admin/users/u1", {
|
|
595
481
|
method: "DELETE",
|
|
596
|
-
headers: { ...adminAuth() }
|
|
482
|
+
headers: { ...adminAuth("admin-1") }
|
|
597
483
|
});
|
|
598
484
|
expect(res.status).toBe(200);
|
|
599
|
-
expect(mockAuthRepo.
|
|
485
|
+
expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u1");
|
|
600
486
|
});
|
|
601
487
|
|
|
602
|
-
it("prevents deletion
|
|
488
|
+
it("prevents self-deletion", async () => {
|
|
603
489
|
const app = createApp();
|
|
604
490
|
|
|
605
|
-
const res = await app.request("/admin/
|
|
491
|
+
const res = await app.request("/admin/users/admin-1", {
|
|
606
492
|
method: "DELETE",
|
|
607
|
-
headers: { ...adminAuth() }
|
|
493
|
+
headers: { ...adminAuth("admin-1") }
|
|
608
494
|
});
|
|
609
495
|
expect(res.status).toBe(400);
|
|
610
496
|
const body = await res.json() as any;
|
|
611
|
-
expect(body.error.code).toBe("
|
|
497
|
+
expect(body.error.code).toBe("SELF_DELETE");
|
|
612
498
|
});
|
|
613
499
|
|
|
614
|
-
it("prevents
|
|
500
|
+
it("prevents last-admin deletion", async () => {
|
|
615
501
|
const app = createApp();
|
|
502
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
|
|
503
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
504
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 1 });
|
|
616
505
|
|
|
617
|
-
const res = await app.request("/admin/
|
|
506
|
+
const res = await app.request("/admin/users/u2", {
|
|
618
507
|
method: "DELETE",
|
|
619
|
-
headers: { ...adminAuth() }
|
|
508
|
+
headers: { ...adminAuth("admin-1") }
|
|
620
509
|
});
|
|
621
|
-
expect(res.status).toBe(
|
|
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();
|
|
622
514
|
});
|
|
623
515
|
|
|
624
|
-
it("
|
|
516
|
+
it("allows deleting an admin if there are other admins", async () => {
|
|
625
517
|
const app = createApp();
|
|
518
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u2" }));
|
|
519
|
+
mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]);
|
|
520
|
+
mockAuthRepo.listUsersPaginated.mockResolvedValueOnce({ total: 2 });
|
|
626
521
|
|
|
627
|
-
const res = await app.request("/admin/
|
|
522
|
+
const res = await app.request("/admin/users/u2", {
|
|
628
523
|
method: "DELETE",
|
|
629
|
-
headers: { ...adminAuth() }
|
|
524
|
+
headers: { ...adminAuth("admin-1") }
|
|
630
525
|
});
|
|
631
|
-
expect(res.status).toBe(
|
|
526
|
+
expect(res.status).toBe(200);
|
|
527
|
+
expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u2");
|
|
632
528
|
});
|
|
633
529
|
|
|
634
|
-
it("returns 404 for non-existent
|
|
530
|
+
it("returns 404 for non-existent user", async () => {
|
|
635
531
|
const app = createApp();
|
|
636
|
-
mockAuthRepo.
|
|
532
|
+
mockAuthRepo.getUserById.mockResolvedValueOnce(null);
|
|
637
533
|
|
|
638
|
-
const res = await app.request("/admin/
|
|
534
|
+
const res = await app.request("/admin/users/missing", {
|
|
639
535
|
method: "DELETE",
|
|
640
536
|
headers: { ...adminAuth() }
|
|
641
537
|
});
|
|
@@ -643,4 +539,6 @@ name: "Dup" }),
|
|
|
643
539
|
});
|
|
644
540
|
});
|
|
645
541
|
});
|
|
542
|
+
|
|
543
|
+
|
|
646
544
|
});
|
|
@@ -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
|
};
|
|
@@ -363,32 +362,7 @@ describe("BackendHooks — Admin Routes", () => {
|
|
|
363
362
|
});
|
|
364
363
|
});
|
|
365
364
|
|
|
366
|
-
// ── roles.afterRead ─────────────────────────────────────────────────
|
|
367
|
-
describe("roles.afterRead", () => {
|
|
368
|
-
it("filters out roles from GET /admin/roles", async () => {
|
|
369
|
-
const hooks: BackendHooks = {
|
|
370
|
-
roles: {
|
|
371
|
-
afterRead(role) {
|
|
372
|
-
// Hide internal roles
|
|
373
|
-
if (role.id === "internal") return null;
|
|
374
|
-
return role;
|
|
375
|
-
}
|
|
376
|
-
}
|
|
377
|
-
};
|
|
378
|
-
const app = createApp(hooks);
|
|
379
|
-
mockAuthRepo.listRoles.mockResolvedValueOnce([
|
|
380
|
-
mockRole("admin", true),
|
|
381
|
-
mockRole("internal"),
|
|
382
|
-
mockRole("editor")
|
|
383
|
-
]);
|
|
384
365
|
|
|
385
|
-
const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
|
|
386
|
-
expect(res.status).toBe(200);
|
|
387
|
-
const body = await res.json() as any;
|
|
388
|
-
expect(body.roles).toHaveLength(2);
|
|
389
|
-
expect(body.roles.map((r: any) => r.id)).toEqual(["admin", "editor"]);
|
|
390
|
-
});
|
|
391
|
-
});
|
|
392
366
|
|
|
393
367
|
// ── no hooks (passthrough) ──────────────────────────────────────────
|
|
394
368
|
describe("no hooks configured", () => {
|
|
@@ -143,7 +143,7 @@ describe("createCustomAuthAdapter", () => {
|
|
|
143
143
|
expect(adapter.serviceKey).toBe("sk_live_123");
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("passes through userManagement
|
|
146
|
+
it("passes through userManagement when provided", () => {
|
|
147
147
|
const userMgmt = {
|
|
148
148
|
getUser: jest.fn(),
|
|
149
149
|
listUsers: jest.fn(),
|
|
@@ -151,27 +151,19 @@ describe("createCustomAuthAdapter", () => {
|
|
|
151
151
|
updateUser: jest.fn(),
|
|
152
152
|
deleteUser: jest.fn(),
|
|
153
153
|
};
|
|
154
|
-
const roleMgmt = {
|
|
155
|
-
listRoles: jest.fn(),
|
|
156
|
-
getUserRoles: jest.fn(),
|
|
157
|
-
setUserRoles: jest.fn(),
|
|
158
|
-
};
|
|
159
154
|
|
|
160
155
|
const adapter = createCustomAuthAdapter({
|
|
161
156
|
verifyRequest: async () => null,
|
|
162
157
|
userManagement: userMgmt as unknown as CustomAuthAdapterOptions["userManagement"],
|
|
163
|
-
roleManagement: roleMgmt as unknown as CustomAuthAdapterOptions["roleManagement"],
|
|
164
158
|
});
|
|
165
159
|
|
|
166
160
|
expect(adapter.userManagement).toBe(userMgmt);
|
|
167
|
-
expect(adapter.roleManagement).toBe(roleMgmt);
|
|
168
161
|
});
|
|
169
162
|
|
|
170
|
-
it("omits userManagement
|
|
163
|
+
it("omits userManagement when not provided", () => {
|
|
171
164
|
const adapter = createCustomAuthAdapter({
|
|
172
165
|
verifyRequest: async () => null,
|
|
173
166
|
});
|
|
174
167
|
expect(adapter.userManagement).toBeUndefined();
|
|
175
|
-
expect(adapter.roleManagement).toBeUndefined();
|
|
176
168
|
});
|
|
177
169
|
});
|