@rebasepro/server-core 0.0.1-canary.4d4fb3e

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.
Files changed (254) hide show
  1. package/LICENSE +6 -0
  2. package/README.md +40 -0
  3. package/build-errors.txt +52 -0
  4. package/coverage/clover.xml +3739 -0
  5. package/coverage/coverage-final.json +31 -0
  6. package/coverage/lcov-report/base.css +224 -0
  7. package/coverage/lcov-report/block-navigation.js +87 -0
  8. package/coverage/lcov-report/favicon.png +0 -0
  9. package/coverage/lcov-report/index.html +266 -0
  10. package/coverage/lcov-report/prettify.css +1 -0
  11. package/coverage/lcov-report/prettify.js +2 -0
  12. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  13. package/coverage/lcov-report/sorter.js +210 -0
  14. package/coverage/lcov-report/src/api/ast-schema-editor.ts.html +952 -0
  15. package/coverage/lcov-report/src/api/errors.ts.html +472 -0
  16. package/coverage/lcov-report/src/api/graphql/graphql-schema-generator.ts.html +1069 -0
  17. package/coverage/lcov-report/src/api/graphql/index.html +116 -0
  18. package/coverage/lcov-report/src/api/index.html +176 -0
  19. package/coverage/lcov-report/src/api/openapi-generator.ts.html +565 -0
  20. package/coverage/lcov-report/src/api/rest/api-generator.ts.html +994 -0
  21. package/coverage/lcov-report/src/api/rest/index.html +131 -0
  22. package/coverage/lcov-report/src/api/rest/query-parser.ts.html +550 -0
  23. package/coverage/lcov-report/src/api/schema-editor-routes.ts.html +202 -0
  24. package/coverage/lcov-report/src/api/server.ts.html +823 -0
  25. package/coverage/lcov-report/src/auth/admin-routes.ts.html +973 -0
  26. package/coverage/lcov-report/src/auth/index.html +176 -0
  27. package/coverage/lcov-report/src/auth/jwt.ts.html +574 -0
  28. package/coverage/lcov-report/src/auth/middleware.ts.html +745 -0
  29. package/coverage/lcov-report/src/auth/password.ts.html +310 -0
  30. package/coverage/lcov-report/src/auth/services.ts.html +2074 -0
  31. package/coverage/lcov-report/src/collections/index.html +116 -0
  32. package/coverage/lcov-report/src/collections/loader.ts.html +232 -0
  33. package/coverage/lcov-report/src/db/auth-schema.ts.html +523 -0
  34. package/coverage/lcov-report/src/db/data-transformer.ts.html +1753 -0
  35. package/coverage/lcov-report/src/db/entityService.ts.html +700 -0
  36. package/coverage/lcov-report/src/db/index.html +146 -0
  37. package/coverage/lcov-report/src/db/services/EntityFetchService.ts.html +4048 -0
  38. package/coverage/lcov-report/src/db/services/EntityPersistService.ts.html +883 -0
  39. package/coverage/lcov-report/src/db/services/RelationService.ts.html +3121 -0
  40. package/coverage/lcov-report/src/db/services/entity-helpers.ts.html +442 -0
  41. package/coverage/lcov-report/src/db/services/index.html +176 -0
  42. package/coverage/lcov-report/src/db/services/index.ts.html +124 -0
  43. package/coverage/lcov-report/src/generate-drizzle-schema-logic.ts.html +1960 -0
  44. package/coverage/lcov-report/src/index.html +116 -0
  45. package/coverage/lcov-report/src/services/driver-registry.ts.html +631 -0
  46. package/coverage/lcov-report/src/services/index.html +131 -0
  47. package/coverage/lcov-report/src/services/postgresDataDriver.ts.html +3025 -0
  48. package/coverage/lcov-report/src/storage/LocalStorageController.ts.html +1189 -0
  49. package/coverage/lcov-report/src/storage/S3StorageController.ts.html +970 -0
  50. package/coverage/lcov-report/src/storage/index.html +161 -0
  51. package/coverage/lcov-report/src/storage/storage-registry.ts.html +646 -0
  52. package/coverage/lcov-report/src/storage/types.ts.html +451 -0
  53. package/coverage/lcov-report/src/utils/drizzle-conditions.ts.html +3082 -0
  54. package/coverage/lcov-report/src/utils/index.html +116 -0
  55. package/coverage/lcov.info +7179 -0
  56. package/dist/common/src/collections/CollectionRegistry.d.ts +48 -0
  57. package/dist/common/src/collections/index.d.ts +1 -0
  58. package/dist/common/src/data/buildRebaseData.d.ts +14 -0
  59. package/dist/common/src/index.d.ts +3 -0
  60. package/dist/common/src/util/builders.d.ts +57 -0
  61. package/dist/common/src/util/callbacks.d.ts +6 -0
  62. package/dist/common/src/util/collections.d.ts +11 -0
  63. package/dist/common/src/util/common.d.ts +2 -0
  64. package/dist/common/src/util/conditions.d.ts +26 -0
  65. package/dist/common/src/util/entities.d.ts +36 -0
  66. package/dist/common/src/util/enums.d.ts +3 -0
  67. package/dist/common/src/util/index.d.ts +16 -0
  68. package/dist/common/src/util/navigation_from_path.d.ts +34 -0
  69. package/dist/common/src/util/navigation_utils.d.ts +20 -0
  70. package/dist/common/src/util/parent_references_from_path.d.ts +6 -0
  71. package/dist/common/src/util/paths.d.ts +14 -0
  72. package/dist/common/src/util/permissions.d.ts +5 -0
  73. package/dist/common/src/util/references.d.ts +2 -0
  74. package/dist/common/src/util/relations.d.ts +12 -0
  75. package/dist/common/src/util/resolutions.d.ts +72 -0
  76. package/dist/common/src/util/storage.d.ts +24 -0
  77. package/dist/index-BeMqpmfQ.js +239 -0
  78. package/dist/index-BeMqpmfQ.js.map +1 -0
  79. package/dist/index-bl4J3lNb.js +55823 -0
  80. package/dist/index-bl4J3lNb.js.map +1 -0
  81. package/dist/index.es.js +58 -0
  82. package/dist/index.es.js.map +1 -0
  83. package/dist/index.umd.js +56062 -0
  84. package/dist/index.umd.js.map +1 -0
  85. package/dist/server-core/src/api/ast-schema-editor.d.ts +21 -0
  86. package/dist/server-core/src/api/collections_for_test/callbacks_test_collection.d.ts +2 -0
  87. package/dist/server-core/src/api/errors.d.ts +35 -0
  88. package/dist/server-core/src/api/graphql/graphql-schema-generator.d.ts +35 -0
  89. package/dist/server-core/src/api/graphql/index.d.ts +1 -0
  90. package/dist/server-core/src/api/index.d.ts +9 -0
  91. package/dist/server-core/src/api/openapi-generator.d.ts +2 -0
  92. package/dist/server-core/src/api/rest/api-generator.d.ts +64 -0
  93. package/dist/server-core/src/api/rest/index.d.ts +1 -0
  94. package/dist/server-core/src/api/rest/query-parser.d.ts +9 -0
  95. package/dist/server-core/src/api/schema-editor-routes.d.ts +3 -0
  96. package/dist/server-core/src/api/server.d.ts +40 -0
  97. package/dist/server-core/src/api/types.d.ts +90 -0
  98. package/dist/server-core/src/auth/admin-routes.d.ts +7 -0
  99. package/dist/server-core/src/auth/google-oauth.d.ts +20 -0
  100. package/dist/server-core/src/auth/index.d.ts +12 -0
  101. package/dist/server-core/src/auth/interfaces.d.ts +270 -0
  102. package/dist/server-core/src/auth/jwt.d.ts +42 -0
  103. package/dist/server-core/src/auth/middleware.d.ts +56 -0
  104. package/dist/server-core/src/auth/password.d.ts +22 -0
  105. package/dist/server-core/src/auth/rate-limiter.d.ts +31 -0
  106. package/dist/server-core/src/auth/routes.d.ts +17 -0
  107. package/dist/server-core/src/bootstrappers/index.d.ts +0 -0
  108. package/dist/server-core/src/collections/BackendCollectionRegistry.d.ts +13 -0
  109. package/dist/server-core/src/collections/loader.d.ts +5 -0
  110. package/dist/server-core/src/db/interfaces.d.ts +18 -0
  111. package/dist/server-core/src/email/index.d.ts +6 -0
  112. package/dist/server-core/src/email/smtp-email-service.d.ts +25 -0
  113. package/dist/server-core/src/email/templates.d.ts +33 -0
  114. package/dist/server-core/src/email/types.d.ts +110 -0
  115. package/dist/server-core/src/functions/function-loader.d.ts +17 -0
  116. package/dist/server-core/src/functions/function-routes.d.ts +10 -0
  117. package/dist/server-core/src/functions/index.d.ts +3 -0
  118. package/dist/server-core/src/history/history-routes.d.ts +23 -0
  119. package/dist/server-core/src/history/index.d.ts +1 -0
  120. package/dist/server-core/src/index.d.ts +24 -0
  121. package/dist/server-core/src/init.d.ts +49 -0
  122. package/dist/server-core/src/serve-spa.d.ts +30 -0
  123. package/dist/server-core/src/services/driver-registry.d.ts +78 -0
  124. package/dist/server-core/src/storage/LocalStorageController.d.ts +46 -0
  125. package/dist/server-core/src/storage/S3StorageController.d.ts +36 -0
  126. package/dist/server-core/src/storage/index.d.ts +18 -0
  127. package/dist/server-core/src/storage/routes.d.ts +38 -0
  128. package/dist/server-core/src/storage/storage-registry.d.ts +78 -0
  129. package/dist/server-core/src/storage/types.d.ts +91 -0
  130. package/dist/server-core/src/types/index.d.ts +11 -0
  131. package/dist/server-core/src/utils/logging.d.ts +9 -0
  132. package/dist/server-core/src/utils/sql.d.ts +27 -0
  133. package/dist/types/src/controllers/analytics_controller.d.ts +7 -0
  134. package/dist/types/src/controllers/auth.d.ts +117 -0
  135. package/dist/types/src/controllers/client.d.ts +58 -0
  136. package/dist/types/src/controllers/collection_registry.d.ts +44 -0
  137. package/dist/types/src/controllers/customization_controller.d.ts +54 -0
  138. package/dist/types/src/controllers/data.d.ts +141 -0
  139. package/dist/types/src/controllers/data_driver.d.ts +168 -0
  140. package/dist/types/src/controllers/database_admin.d.ts +11 -0
  141. package/dist/types/src/controllers/dialogs_controller.d.ts +36 -0
  142. package/dist/types/src/controllers/effective_role.d.ts +4 -0
  143. package/dist/types/src/controllers/index.d.ts +17 -0
  144. package/dist/types/src/controllers/local_config_persistence.d.ts +20 -0
  145. package/dist/types/src/controllers/navigation.d.ts +213 -0
  146. package/dist/types/src/controllers/registry.d.ts +51 -0
  147. package/dist/types/src/controllers/side_dialogs_controller.d.ts +67 -0
  148. package/dist/types/src/controllers/side_entity_controller.d.ts +89 -0
  149. package/dist/types/src/controllers/snackbar.d.ts +24 -0
  150. package/dist/types/src/controllers/storage.d.ts +173 -0
  151. package/dist/types/src/index.d.ts +4 -0
  152. package/dist/types/src/rebase_context.d.ts +101 -0
  153. package/dist/types/src/types/backend.d.ts +533 -0
  154. package/dist/types/src/types/builders.d.ts +14 -0
  155. package/dist/types/src/types/chips.d.ts +5 -0
  156. package/dist/types/src/types/collections.d.ts +812 -0
  157. package/dist/types/src/types/data_source.d.ts +64 -0
  158. package/dist/types/src/types/entities.d.ts +145 -0
  159. package/dist/types/src/types/entity_actions.d.ts +98 -0
  160. package/dist/types/src/types/entity_callbacks.d.ts +173 -0
  161. package/dist/types/src/types/entity_link_builder.d.ts +7 -0
  162. package/dist/types/src/types/entity_overrides.d.ts +9 -0
  163. package/dist/types/src/types/entity_views.d.ts +61 -0
  164. package/dist/types/src/types/export_import.d.ts +21 -0
  165. package/dist/types/src/types/index.d.ts +22 -0
  166. package/dist/types/src/types/locales.d.ts +4 -0
  167. package/dist/types/src/types/modify_collections.d.ts +5 -0
  168. package/dist/types/src/types/plugins.d.ts +225 -0
  169. package/dist/types/src/types/properties.d.ts +1091 -0
  170. package/dist/types/src/types/property_config.d.ts +70 -0
  171. package/dist/types/src/types/relations.d.ts +336 -0
  172. package/dist/types/src/types/slots.d.ts +228 -0
  173. package/dist/types/src/types/translations.d.ts +826 -0
  174. package/dist/types/src/types/user_management_delegate.d.ts +120 -0
  175. package/dist/types/src/types/websockets.d.ts +78 -0
  176. package/dist/types/src/users/index.d.ts +2 -0
  177. package/dist/types/src/users/roles.d.ts +22 -0
  178. package/dist/types/src/users/user.d.ts +46 -0
  179. package/history_diff.log +385 -0
  180. package/jest.config.cjs +16 -0
  181. package/package.json +86 -0
  182. package/scratch.ts +8 -0
  183. package/src/api/ast-schema-editor.ts +289 -0
  184. package/src/api/collections_for_test/callbacks_test_collection.ts +57 -0
  185. package/src/api/errors.ts +155 -0
  186. package/src/api/graphql/graphql-schema-generator.ts +334 -0
  187. package/src/api/graphql/index.ts +2 -0
  188. package/src/api/index.ts +11 -0
  189. package/src/api/openapi-generator.ts +160 -0
  190. package/src/api/rest/api-generator.ts +466 -0
  191. package/src/api/rest/index.ts +2 -0
  192. package/src/api/rest/query-parser.ts +155 -0
  193. package/src/api/schema-editor-routes.ts +39 -0
  194. package/src/api/server.ts +245 -0
  195. package/src/api/types.ts +90 -0
  196. package/src/auth/admin-routes.ts +488 -0
  197. package/src/auth/google-oauth.ts +60 -0
  198. package/src/auth/index.ts +21 -0
  199. package/src/auth/interfaces.ts +316 -0
  200. package/src/auth/jwt.ts +164 -0
  201. package/src/auth/middleware.ts +235 -0
  202. package/src/auth/password.ts +75 -0
  203. package/src/auth/rate-limiter.ts +129 -0
  204. package/src/auth/routes.ts +730 -0
  205. package/src/bootstrappers/index.ts +1 -0
  206. package/src/collections/BackendCollectionRegistry.ts +20 -0
  207. package/src/collections/loader.ts +49 -0
  208. package/src/db/interfaces.ts +60 -0
  209. package/src/email/index.ts +17 -0
  210. package/src/email/smtp-email-service.ts +88 -0
  211. package/src/email/templates.ts +301 -0
  212. package/src/email/types.ts +112 -0
  213. package/src/functions/function-loader.ts +91 -0
  214. package/src/functions/function-routes.ts +31 -0
  215. package/src/functions/index.ts +3 -0
  216. package/src/history/history-routes.ts +128 -0
  217. package/src/history/index.ts +2 -0
  218. package/src/index.ts +56 -0
  219. package/src/init.ts +309 -0
  220. package/src/serve-spa.ts +81 -0
  221. package/src/services/driver-registry.ts +182 -0
  222. package/src/storage/LocalStorageController.ts +368 -0
  223. package/src/storage/S3StorageController.ts +295 -0
  224. package/src/storage/index.ts +32 -0
  225. package/src/storage/routes.ts +247 -0
  226. package/src/storage/storage-registry.ts +187 -0
  227. package/src/storage/types.ts +122 -0
  228. package/src/types/index.ts +27 -0
  229. package/src/utils/logging.ts +35 -0
  230. package/src/utils/sql.ts +38 -0
  231. package/test/admin-routes.test.ts +591 -0
  232. package/test/api-generator.test.ts +458 -0
  233. package/test/ast-schema-editor.test.ts +61 -0
  234. package/test/auth-middleware-hono.test.ts +321 -0
  235. package/test/auth-routes.test.ts +868 -0
  236. package/test/driver-registry.test.ts +280 -0
  237. package/test/errors-hono.test.ts +133 -0
  238. package/test/errors.test.ts +150 -0
  239. package/test/jwt-security.test.ts +173 -0
  240. package/test/jwt.test.ts +311 -0
  241. package/test/middleware.test.ts +295 -0
  242. package/test/password.test.ts +165 -0
  243. package/test/query-parser.test.ts +258 -0
  244. package/test/rate-limiter.test.ts +102 -0
  245. package/test/storage-local.test.ts +278 -0
  246. package/test/storage-registry.test.ts +280 -0
  247. package/test/storage-routes.test.ts +218 -0
  248. package/test/storage-s3.test.ts +301 -0
  249. package/test-ast.ts +28 -0
  250. package/test_output.txt +1133 -0
  251. package/tsconfig.json +49 -0
  252. package/tsconfig.prod.json +20 -0
  253. package/vite.config.ts +78 -0
  254. package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +46 -0
@@ -0,0 +1,591 @@
1
+ /**
2
+ * Admin Routes — Integration Tests (Hono)
3
+ *
4
+ * Tests the full Hono request → admin route handler → JSON response cycle.
5
+ * Replaces the previous Express-based mock tests that only tested service calls.
6
+ */
7
+
8
+ import { Hono } from "hono";
9
+ import type { HonoEnv } from "../src/api/types";
10
+ import { errorHandler } from "../src/api/errors";
11
+ import { createAdminRoutes } from "../src/auth/admin-routes";
12
+ import { configureJwt, generateAccessToken } from "../src/auth/jwt";
13
+ import type { AuthModuleConfig } from "../src/auth/routes";
14
+
15
+ // ── Mocks ───────────────────────────────────────────────────────────────────
16
+
17
+ jest.mock("../src/auth/password");
18
+
19
+ import { UserService, RoleService } from "../src/auth/services";
20
+ import { hashPassword, validatePasswordStrength } from "../src/auth/password";
21
+
22
+ // ── Helpers ─────────────────────────────────────────────────────────────────
23
+
24
+ const TEST_SECRET = "admin-test-secret-key-that-is-definitely-32-chars-long!!!!!";
25
+
26
+ function mockUser(overrides: Partial<{ id: string; email: string; displayName: string | null; photoUrl: string | null; provider: string }> = {}) {
27
+ return {
28
+ id: overrides.id ?? "user-1",
29
+ email: overrides.email ?? "test@example.com",
30
+ passwordHash: "salt:hash",
31
+ displayName: overrides.displayName ?? "Test User",
32
+ photoUrl: overrides.photoUrl ?? null,
33
+ provider: overrides.provider ?? "email",
34
+ googleId: null,
35
+ emailVerified: false,
36
+ emailVerificationToken: null,
37
+ emailVerificationSentAt: null,
38
+ createdAt: new Date("2024-01-01"),
39
+ updatedAt: new Date("2024-01-01"),
40
+ };
41
+ }
42
+
43
+ function mockRole(id: string, isAdmin = false) {
44
+ return { id, name: id.charAt(0).toUpperCase() + id.slice(1), isAdmin, defaultPermissions: null, collectionPermissions: null, config: null };
45
+ }
46
+
47
+
48
+ let mockAuthRepo: jest.Mocked<any>;
49
+
50
+ function createApp(opts: { defaultRole?: string } = {}) {
51
+ mockAuthRepo = {
52
+ getUserByEmail: jest.fn().mockResolvedValue(null),
53
+ getUserByGoogleId: jest.fn().mockResolvedValue(null),
54
+ getUserById: jest.fn().mockResolvedValue(null),
55
+ createUser: jest.fn().mockImplementation((data) =>
56
+ Promise.resolve(mockUser({ email: data.email, displayName: data.displayName, passwordHash: data.passwordHash }))
57
+ ),
58
+ listUsers: jest.fn().mockResolvedValue([]),
59
+ getUserRoles: jest.fn().mockResolvedValue([mockRole("editor")]),
60
+ getUserRoleIds: jest.fn().mockResolvedValue(["editor"]),
61
+ assignDefaultRole: jest.fn().mockResolvedValue(undefined),
62
+ setUserRoles: jest.fn().mockResolvedValue(undefined),
63
+ updateUser: jest.fn().mockImplementation((id, data) =>
64
+ Promise.resolve(mockUser({ id, ...data }))
65
+ ),
66
+ deleteUser: jest.fn().mockResolvedValue(undefined),
67
+ updatePassword: jest.fn().mockResolvedValue(undefined),
68
+ setEmailVerified: jest.fn().mockResolvedValue(undefined),
69
+ setVerificationToken: jest.fn().mockResolvedValue(undefined),
70
+ getUserByVerificationToken: jest.fn().mockResolvedValue(null),
71
+ getUserWithRoles: jest.fn().mockImplementation(async (userId) => {
72
+ const user = mockUser({ id: userId });
73
+ return { user, roles: [mockRole("editor")] };
74
+ }),
75
+ createRefreshToken: jest.fn().mockResolvedValue(undefined),
76
+ findRefreshTokenByHash: jest.fn().mockResolvedValue(null),
77
+ deleteRefreshToken: jest.fn().mockResolvedValue(undefined),
78
+ deleteAllRefreshTokensForUser: jest.fn().mockResolvedValue(undefined),
79
+ listRefreshTokensForUser: jest.fn().mockResolvedValue([]),
80
+ deleteRefreshTokenById: jest.fn().mockResolvedValue(undefined),
81
+ createPasswordResetToken: jest.fn().mockResolvedValue(undefined),
82
+ findValidPasswordResetToken: jest.fn().mockResolvedValue(null),
83
+ markPasswordResetTokenUsed: jest.fn().mockResolvedValue(undefined),
84
+ deleteExpiredPasswordResetTokens: jest.fn().mockResolvedValue(undefined),
85
+ listRoles: jest.fn().mockResolvedValue([]),
86
+ getRoleById: jest.fn().mockResolvedValue(null),
87
+ createRole: jest.fn().mockImplementation(r => Promise.resolve({ id: r.id, name: r.name, isAdmin: r.isAdmin || false, defaultPermissions: null, collectionPermissions: null, config: null })),
88
+ updateRole: jest.fn().mockImplementation((id, r) => Promise.resolve({ id, name: r.name, isAdmin: r.isAdmin || false, defaultPermissions: null, collectionPermissions: null, config: null })),
89
+ deleteRole: jest.fn().mockResolvedValue(undefined)
90
+ } as unknown as jest.Mocked<AuthRepository>;
91
+
92
+
93
+ // Password mocks
94
+ (validatePasswordStrength as jest.Mock).mockReturnValue({ valid: true, errors: [] });
95
+ (hashPassword as jest.Mock).mockResolvedValue("hashed-pw");
96
+
97
+ const config: AuthModuleConfig = {
98
+ authRepo: mockAuthRepo,
99
+ defaultRole: opts.defaultRole,
100
+ };
101
+
102
+ const app = new Hono<HonoEnv>();
103
+ app.onError(errorHandler);
104
+ app.route("/admin", createAdminRoutes(config));
105
+ return app;
106
+ }
107
+
108
+ function adminAuth(userId = "admin-1") {
109
+ return { Authorization: `Bearer ${generateAccessToken(userId, ["admin"])}` };
110
+ }
111
+
112
+ function editorAuth(userId = "editor-1") {
113
+ return { Authorization: `Bearer ${generateAccessToken(userId, ["editor"])}` };
114
+ }
115
+
116
+ function json(body: Record<string, unknown>) {
117
+ return {
118
+ method: "POST" as const,
119
+ headers: { "Content-Type": "application/json" },
120
+ body: JSON.stringify(body),
121
+ };
122
+ }
123
+
124
+ // ═══════════════════════════════════════════════════════════════════════════
125
+ // TESTS
126
+ // ═══════════════════════════════════════════════════════════════════════════
127
+
128
+ describe("Admin Routes (Integration)", () => {
129
+ beforeAll(() => {
130
+ configureJwt({ secret: TEST_SECRET, accessExpiresIn: "1h" });
131
+ });
132
+
133
+ beforeEach(() => {
134
+ jest.clearAllMocks();
135
+ });
136
+
137
+ // ── Auth barriers ───────────────────────────────────────────────────
138
+ describe("Authorization", () => {
139
+ it("returns 401 for unauthenticated requests", async () => {
140
+ const app = createApp();
141
+ const res = await app.request("/admin/users");
142
+ expect(res.status).toBe(401);
143
+ });
144
+
145
+ it("returns 403 for non-admin users on admin-only endpoints", async () => {
146
+ const app = createApp();
147
+ const res = await app.request("/admin/users", {
148
+ headers: { ...editorAuth() },
149
+ });
150
+ expect(res.status).toBe(403);
151
+ });
152
+
153
+ it("allows admin users through", async () => {
154
+ const app = createApp();
155
+ const res = await app.request("/admin/users", {
156
+ headers: { ...adminAuth() },
157
+ });
158
+ expect(res.status).toBe(200);
159
+ });
160
+
161
+ it("allows schema-admin users through", async () => {
162
+ const app = createApp();
163
+ const res = await app.request("/admin/users", {
164
+ headers: { Authorization: `Bearer ${generateAccessToken("sa-1", ["schema-admin"])}` },
165
+ });
166
+ expect(res.status).toBe(200);
167
+ });
168
+ });
169
+
170
+ // ── Bootstrap ───────────────────────────────────────────────────────
171
+ describe("POST /admin/bootstrap", () => {
172
+ it("promotes current user to admin when no admins exist", async () => {
173
+ const app = createApp();
174
+ mockAuthRepo.listUsers.mockResolvedValueOnce([mockUser({ id: "user-1" })]);
175
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["editor"]); // no admin
176
+
177
+ const res = await app.request("/admin/bootstrap", {
178
+ method: "POST",
179
+ headers: { ...adminAuth("user-1") },
180
+ });
181
+ expect(res.status).toBe(200);
182
+ const body = await res.json() as any;
183
+ expect(body.user.roles).toContain("admin");
184
+ expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("user-1", ["admin"]);
185
+ });
186
+
187
+ it("returns 403 when admin already exists", async () => {
188
+ const app = createApp();
189
+ const adminUser = mockUser({ id: "existing-admin" });
190
+ mockAuthRepo.listUsers.mockResolvedValueOnce([adminUser]);
191
+ mockAuthRepo.getUserRoleIds.mockResolvedValueOnce(["admin"]); // admin exists
192
+
193
+ const res = await app.request("/admin/bootstrap", {
194
+ method: "POST",
195
+ headers: { ...adminAuth("user-1") },
196
+ });
197
+ expect(res.status).toBe(403);
198
+ const body = await res.json() as any;
199
+ expect(body.error.message).toContain("Admin users already exist");
200
+ });
201
+ });
202
+
203
+ // ── User CRUD ───────────────────────────────────────────────────────
204
+ describe("User Management", () => {
205
+ describe("GET /admin/users", () => {
206
+ it("returns list of users with roles", async () => {
207
+ const app = createApp();
208
+ mockAuthRepo.listUsers.mockResolvedValueOnce([
209
+ mockUser({ id: "u1", email: "a@test.com" }),
210
+ mockUser({ id: "u2", email: "b@test.com" }),
211
+ ]);
212
+ mockAuthRepo.getUserRoleIds
213
+ .mockResolvedValueOnce(["admin"])
214
+ .mockResolvedValueOnce(["editor"]);
215
+
216
+ const res = await app.request("/admin/users", { headers: { ...adminAuth() } });
217
+ expect(res.status).toBe(200);
218
+ const body = await res.json() as any;
219
+ expect(body.users).toHaveLength(2);
220
+ expect(body.users[0].roles).toContain("admin");
221
+ expect(body.users[1].roles).toContain("editor");
222
+ });
223
+ });
224
+
225
+ describe("GET /admin/users/:userId", () => {
226
+ it("returns user with roles", async () => {
227
+ const app = createApp();
228
+ mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
229
+ user: mockUser({ id: "u1" }),
230
+ roles: [mockRole("editor"), mockRole("viewer")],
231
+ });
232
+
233
+ const res = await app.request("/admin/users/u1", { headers: { ...adminAuth() } });
234
+ expect(res.status).toBe(200);
235
+ const body = await res.json() as any;
236
+ expect(body.user.uid).toBe("u1");
237
+ expect(body.user.roles).toEqual(["editor", "viewer"]);
238
+ });
239
+
240
+ it("returns 404 for non-existent user", async () => {
241
+ const app = createApp();
242
+ mockAuthRepo.getUserWithRoles.mockResolvedValueOnce(null);
243
+
244
+ const res = await app.request("/admin/users/missing", { headers: { ...adminAuth() } });
245
+ expect(res.status).toBe(404);
246
+ });
247
+ });
248
+
249
+ describe("POST /admin/users", () => {
250
+ it("creates a new user", async () => {
251
+ const app = createApp();
252
+
253
+ const res = await app.request("/admin/users", {
254
+ ...json({ email: "new@test.com", displayName: "New", password: "StrongPass1", roles: ["editor"] }),
255
+ headers: { ...json({}).headers, ...adminAuth() },
256
+ });
257
+ expect(res.status).toBe(201);
258
+ const body = await res.json() as any;
259
+ expect(body.user.email).toBe("new@test.com");
260
+ expect(mockAuthRepo.createUser).toHaveBeenCalledWith(expect.objectContaining({
261
+ email: "new@test.com",
262
+ }));
263
+ });
264
+
265
+ it("hashes password when provided", async () => {
266
+ const app = createApp();
267
+
268
+ await app.request("/admin/users", {
269
+ ...json({ email: "pw@test.com", password: "StrongPass1" }),
270
+ headers: { ...json({}).headers, ...adminAuth() },
271
+ });
272
+ expect(hashPassword).toHaveBeenCalledWith("StrongPass1");
273
+ });
274
+
275
+ it("returns 400 for missing email", async () => {
276
+ const app = createApp();
277
+
278
+ const res = await app.request("/admin/users", {
279
+ ...json({ displayName: "No Email" }),
280
+ headers: { ...json({}).headers, ...adminAuth() },
281
+ });
282
+ expect(res.status).toBe(400);
283
+ });
284
+
285
+ it("returns 409 when email already exists", async () => {
286
+ const app = createApp();
287
+ mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
288
+
289
+ const res = await app.request("/admin/users", {
290
+ ...json({ email: "existing@test.com" }),
291
+ headers: { ...json({}).headers, ...adminAuth() },
292
+ });
293
+ expect(res.status).toBe(409);
294
+ const body = await res.json() as any;
295
+ expect(body.error.code).toBe("EMAIL_EXISTS");
296
+ });
297
+
298
+ it("returns 400 for weak password", async () => {
299
+ const app = createApp();
300
+ (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false, errors: ["Too short"] });
301
+
302
+ const res = await app.request("/admin/users", {
303
+ ...json({ email: "weak@test.com", password: "weak" }),
304
+ headers: { ...json({}).headers, ...adminAuth() },
305
+ });
306
+ expect(res.status).toBe(400);
307
+ const body = await res.json() as any;
308
+ expect(body.error.code).toBe("WEAK_PASSWORD");
309
+ });
310
+
311
+ it("assigns configured default role when no roles specified", async () => {
312
+ const app = createApp({ defaultRole: "editor" });
313
+
314
+ await app.request("/admin/users", {
315
+ ...json({ email: "norole@test.com" }),
316
+ headers: { ...json({}).headers, ...adminAuth() },
317
+ });
318
+ expect(mockAuthRepo.assignDefaultRole).toHaveBeenCalledWith(expect.any(String), "editor");
319
+ });
320
+
321
+ it("does not assign a default role when not configured", async () => {
322
+ const app = createApp();
323
+
324
+ await app.request("/admin/users", {
325
+ ...json({ email: "nodefault@test.com" }),
326
+ headers: { ...json({}).headers, ...adminAuth() },
327
+ });
328
+ expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalled();
329
+ });
330
+
331
+ it("assigns specified roles", async () => {
332
+ const app = createApp();
333
+
334
+ await app.request("/admin/users", {
335
+ ...json({ email: "withroles@test.com", roles: ["admin", "editor"] }),
336
+ headers: { ...json({}).headers, ...adminAuth() },
337
+ });
338
+ expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith(expect.any(String), ["admin", "editor"]);
339
+ });
340
+ });
341
+
342
+ describe("PUT /admin/users/:userId", () => {
343
+ it("updates user profile", async () => {
344
+ const app = createApp();
345
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
346
+ mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
347
+ user: mockUser({ id: "u1", displayName: "Updated" }),
348
+ roles: [mockRole("editor")],
349
+ });
350
+
351
+ const res = await app.request("/admin/users/u1", {
352
+ method: "PUT",
353
+ headers: { "Content-Type": "application/json", ...adminAuth() },
354
+ body: JSON.stringify({ displayName: "Updated" }),
355
+ });
356
+ expect(res.status).toBe(200);
357
+ const body = await res.json() as any;
358
+ expect(body.user.displayName).toBe("Updated");
359
+ });
360
+
361
+ it("updates roles when specified", async () => {
362
+ const app = createApp();
363
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
364
+ mockAuthRepo.getUserWithRoles.mockResolvedValueOnce({
365
+ user: mockUser({ id: "u1" }),
366
+ roles: [mockRole("admin")],
367
+ });
368
+
369
+ await app.request("/admin/users/u1", {
370
+ method: "PUT",
371
+ headers: { "Content-Type": "application/json", ...adminAuth() },
372
+ body: JSON.stringify({ roles: ["admin"] }),
373
+ });
374
+ expect(mockAuthRepo.setUserRoles).toHaveBeenCalledWith("u1", ["admin"]);
375
+ });
376
+
377
+ it("returns 404 for non-existent user", async () => {
378
+ const app = createApp();
379
+ mockAuthRepo.getUserById.mockResolvedValueOnce(null);
380
+
381
+ const res = await app.request("/admin/users/missing", {
382
+ method: "PUT",
383
+ headers: { "Content-Type": "application/json", ...adminAuth() },
384
+ body: JSON.stringify({ displayName: "Updated" }),
385
+ });
386
+ expect(res.status).toBe(404);
387
+ });
388
+ });
389
+
390
+ describe("DELETE /admin/users/:userId", () => {
391
+ it("deletes a user", async () => {
392
+ const app = createApp();
393
+ mockAuthRepo.getUserById.mockResolvedValueOnce(mockUser({ id: "u1" }));
394
+
395
+ const res = await app.request("/admin/users/u1", {
396
+ method: "DELETE",
397
+ headers: { ...adminAuth("admin-1") },
398
+ });
399
+ expect(res.status).toBe(200);
400
+ expect(mockAuthRepo.deleteUser).toHaveBeenCalledWith("u1");
401
+ });
402
+
403
+ it("prevents self-deletion", async () => {
404
+ const app = createApp();
405
+
406
+ const res = await app.request("/admin/users/admin-1", {
407
+ method: "DELETE",
408
+ headers: { ...adminAuth("admin-1") },
409
+ });
410
+ expect(res.status).toBe(400);
411
+ const body = await res.json() as any;
412
+ expect(body.error.code).toBe("SELF_DELETE");
413
+ });
414
+
415
+ it("returns 404 for non-existent user", async () => {
416
+ const app = createApp();
417
+ mockAuthRepo.getUserById.mockResolvedValueOnce(null);
418
+
419
+ const res = await app.request("/admin/users/missing", {
420
+ method: "DELETE",
421
+ headers: { ...adminAuth() },
422
+ });
423
+ expect(res.status).toBe(404);
424
+ });
425
+ });
426
+ });
427
+
428
+ // ── Role CRUD ───────────────────────────────────────────────────────
429
+ describe("Role Management", () => {
430
+ describe("GET /admin/roles", () => {
431
+ it("returns list of roles", async () => {
432
+ const app = createApp();
433
+ mockAuthRepo.listRoles.mockResolvedValueOnce([
434
+ mockRole("admin", true),
435
+ mockRole("editor"),
436
+ mockRole("viewer"),
437
+ ]);
438
+
439
+ const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
440
+ expect(res.status).toBe(200);
441
+ const body = await res.json() as any;
442
+ expect(body.roles).toHaveLength(3);
443
+ expect(body.roles[0].isAdmin).toBe(true);
444
+ });
445
+ });
446
+
447
+ describe("GET /admin/roles/:roleId", () => {
448
+ it("returns role by ID", async () => {
449
+ const app = createApp();
450
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("admin", true));
451
+
452
+ const res = await app.request("/admin/roles/admin", { headers: { ...adminAuth() } });
453
+ expect(res.status).toBe(200);
454
+ const body = await res.json() as any;
455
+ expect(body.role.id).toBe("admin");
456
+ expect(body.role.isAdmin).toBe(true);
457
+ });
458
+
459
+ it("returns 404 for non-existent role", async () => {
460
+ const app = createApp();
461
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
462
+
463
+ const res = await app.request("/admin/roles/missing", { headers: { ...adminAuth() } });
464
+ expect(res.status).toBe(404);
465
+ });
466
+ });
467
+
468
+ describe("POST /admin/roles", () => {
469
+ it("creates a new role", async () => {
470
+ const app = createApp();
471
+
472
+ const res = await app.request("/admin/roles", {
473
+ ...json({ id: "custom", name: "Custom Role" }),
474
+ headers: { ...json({}).headers, ...adminAuth() },
475
+ });
476
+ expect(res.status).toBe(201);
477
+ const body = await res.json() as any;
478
+ expect(body.role.id).toBe("custom");
479
+ });
480
+
481
+ it("returns 400 for missing id or name", async () => {
482
+ const app = createApp();
483
+
484
+ const res = await app.request("/admin/roles", {
485
+ ...json({ id: "nope" }),
486
+ headers: { ...json({}).headers, ...adminAuth() },
487
+ });
488
+ expect(res.status).toBe(400);
489
+ });
490
+
491
+ it("returns 409 when role already exists", async () => {
492
+ const app = createApp();
493
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
494
+
495
+ const res = await app.request("/admin/roles", {
496
+ ...json({ id: "custom", name: "Dup" }),
497
+ headers: { ...json({}).headers, ...adminAuth() },
498
+ });
499
+ expect(res.status).toBe(409);
500
+ const body = await res.json() as any;
501
+ expect(body.error.code).toBe("ROLE_EXISTS");
502
+ });
503
+ });
504
+
505
+ describe("PUT /admin/roles/:roleId", () => {
506
+ it("updates an existing role", async () => {
507
+ const app = createApp();
508
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("editor"));
509
+
510
+ const res = await app.request("/admin/roles/editor", {
511
+ method: "PUT",
512
+ headers: { "Content-Type": "application/json", ...adminAuth() },
513
+ body: JSON.stringify({ name: "Super Editor" }),
514
+ });
515
+ expect(res.status).toBe(200);
516
+ expect(mockAuthRepo.updateRole).toHaveBeenCalledWith("editor", expect.objectContaining({
517
+ name: "Super Editor",
518
+ }));
519
+ });
520
+
521
+ it("returns 404 for non-existent role", async () => {
522
+ const app = createApp();
523
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
524
+
525
+ const res = await app.request("/admin/roles/missing", {
526
+ method: "PUT",
527
+ headers: { "Content-Type": "application/json", ...adminAuth() },
528
+ body: JSON.stringify({ name: "Nope" }),
529
+ });
530
+ expect(res.status).toBe(404);
531
+ });
532
+ });
533
+
534
+ describe("DELETE /admin/roles/:roleId", () => {
535
+ it("deletes a custom role", async () => {
536
+ const app = createApp();
537
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
538
+
539
+ const res = await app.request("/admin/roles/custom", {
540
+ method: "DELETE",
541
+ headers: { ...adminAuth() },
542
+ });
543
+ expect(res.status).toBe(200);
544
+ expect(mockAuthRepo.deleteRole).toHaveBeenCalledWith("custom");
545
+ });
546
+
547
+ it("prevents deletion of built-in admin role", async () => {
548
+ const app = createApp();
549
+
550
+ const res = await app.request("/admin/roles/admin", {
551
+ method: "DELETE",
552
+ headers: { ...adminAuth() },
553
+ });
554
+ expect(res.status).toBe(400);
555
+ const body = await res.json() as any;
556
+ expect(body.error.code).toBe("BUILTIN_ROLE");
557
+ });
558
+
559
+ it("prevents deletion of built-in editor role", async () => {
560
+ const app = createApp();
561
+
562
+ const res = await app.request("/admin/roles/editor", {
563
+ method: "DELETE",
564
+ headers: { ...adminAuth() },
565
+ });
566
+ expect(res.status).toBe(400);
567
+ });
568
+
569
+ it("prevents deletion of built-in viewer role", async () => {
570
+ const app = createApp();
571
+
572
+ const res = await app.request("/admin/roles/viewer", {
573
+ method: "DELETE",
574
+ headers: { ...adminAuth() },
575
+ });
576
+ expect(res.status).toBe(400);
577
+ });
578
+
579
+ it("returns 404 for non-existent role", async () => {
580
+ const app = createApp();
581
+ mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
582
+
583
+ const res = await app.request("/admin/roles/ghost", {
584
+ method: "DELETE",
585
+ headers: { ...adminAuth() },
586
+ });
587
+ expect(res.status).toBe(404);
588
+ });
589
+ });
590
+ });
591
+ });