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

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 (176) hide show
  1. package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
  2. package/dist/common/src/util/entities.d.ts +22 -0
  3. package/dist/common/src/util/relations.d.ts +14 -4
  4. package/dist/common/src/util/resolutions.d.ts +1 -1
  5. package/dist/index-DXVBFp5V.js +37 -0
  6. package/dist/index-DXVBFp5V.js.map +1 -0
  7. package/dist/index.es.js +49931 -55
  8. package/dist/index.es.js.map +1 -1
  9. package/dist/index.umd.js +28475 -34569
  10. package/dist/index.umd.js.map +1 -1
  11. package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
  12. package/dist/server-core/src/api/types.d.ts +1 -1
  13. package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
  14. package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
  15. package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
  16. package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
  17. package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
  18. package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
  19. package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
  20. package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
  21. package/dist/server-core/src/auth/index.d.ts +13 -2
  22. package/dist/server-core/src/auth/interfaces.d.ts +45 -6
  23. package/dist/server-core/src/auth/jwt.d.ts +1 -0
  24. package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
  25. package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
  26. package/dist/server-core/src/auth/middleware.d.ts +36 -11
  27. package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
  28. package/dist/server-core/src/auth/routes.d.ts +13 -3
  29. package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
  30. package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
  31. package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
  32. package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
  33. package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
  34. package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
  35. package/dist/server-core/src/cron/cron-store.d.ts +32 -0
  36. package/dist/server-core/src/cron/index.d.ts +6 -0
  37. package/dist/server-core/src/email/index.d.ts +2 -2
  38. package/dist/server-core/src/email/templates.d.ts +9 -0
  39. package/dist/server-core/src/email/types.d.ts +20 -23
  40. package/dist/server-core/src/history/history-routes.d.ts +2 -2
  41. package/dist/server-core/src/index.d.ts +5 -0
  42. package/dist/server-core/src/init.d.ts +112 -2
  43. package/dist/server-core/src/singleton.d.ts +35 -0
  44. package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
  45. package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
  46. package/dist/server-core/src/storage/index.d.ts +18 -11
  47. package/dist/server-core/src/storage/routes.d.ts +3 -3
  48. package/dist/server-core/src/storage/types.d.ts +28 -16
  49. package/dist/server-core/src/utils/dev-port.d.ts +35 -0
  50. package/dist/server-core/src/utils/logger.d.ts +31 -0
  51. package/dist/server-core/src/utils/request-logger.d.ts +19 -0
  52. package/dist/types/src/controllers/auth.d.ts +2 -0
  53. package/dist/types/src/controllers/client.d.ts +119 -7
  54. package/dist/types/src/controllers/collection_registry.d.ts +4 -3
  55. package/dist/types/src/controllers/customization_controller.d.ts +7 -1
  56. package/dist/types/src/controllers/data.d.ts +34 -7
  57. package/dist/types/src/controllers/data_driver.d.ts +20 -28
  58. package/dist/types/src/controllers/database_admin.d.ts +2 -2
  59. package/dist/types/src/controllers/email.d.ts +34 -0
  60. package/dist/types/src/controllers/index.d.ts +1 -0
  61. package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
  62. package/dist/types/src/controllers/navigation.d.ts +5 -5
  63. package/dist/types/src/controllers/registry.d.ts +6 -3
  64. package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
  65. package/dist/types/src/controllers/storage.d.ts +24 -26
  66. package/dist/types/src/rebase_context.d.ts +8 -4
  67. package/dist/types/src/types/backend.d.ts +4 -1
  68. package/dist/types/src/types/builders.d.ts +5 -4
  69. package/dist/types/src/types/chips.d.ts +1 -1
  70. package/dist/types/src/types/collections.d.ts +169 -125
  71. package/dist/types/src/types/cron.d.ts +102 -0
  72. package/dist/types/src/types/data_source.d.ts +1 -1
  73. package/dist/types/src/types/entity_actions.d.ts +8 -8
  74. package/dist/types/src/types/entity_callbacks.d.ts +15 -15
  75. package/dist/types/src/types/entity_link_builder.d.ts +1 -1
  76. package/dist/types/src/types/entity_overrides.d.ts +2 -1
  77. package/dist/types/src/types/entity_views.d.ts +8 -8
  78. package/dist/types/src/types/export_import.d.ts +3 -3
  79. package/dist/types/src/types/index.d.ts +1 -0
  80. package/dist/types/src/types/plugins.d.ts +72 -18
  81. package/dist/types/src/types/properties.d.ts +118 -33
  82. package/dist/types/src/types/relations.d.ts +1 -1
  83. package/dist/types/src/types/slots.d.ts +30 -6
  84. package/dist/types/src/types/translations.d.ts +44 -0
  85. package/dist/types/src/types/user_management_delegate.d.ts +1 -0
  86. package/package.json +84 -84
  87. package/scratch.ts +6 -5
  88. package/src/api/ast-schema-editor.ts +11 -11
  89. package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
  90. package/src/api/errors.ts +38 -14
  91. package/src/api/graphql/graphql-schema-generator.ts +5 -3
  92. package/src/api/openapi-generator.ts +613 -58
  93. package/src/api/rest/api-generator.ts +17 -11
  94. package/src/api/rest/query-parser.ts +1 -1
  95. package/src/api/schema-editor-routes.ts +2 -0
  96. package/src/api/server.ts +8 -5
  97. package/src/api/types.ts +1 -1
  98. package/src/auth/admin-routes.ts +58 -17
  99. package/src/auth/apple-oauth.ts +130 -0
  100. package/src/auth/bitbucket-oauth.ts +82 -0
  101. package/src/auth/discord-oauth.ts +83 -0
  102. package/src/auth/facebook-oauth.ts +72 -0
  103. package/src/auth/github-oauth.ts +110 -0
  104. package/src/auth/gitlab-oauth.ts +70 -0
  105. package/src/auth/google-oauth.ts +34 -46
  106. package/src/auth/index.ts +15 -2
  107. package/src/auth/interfaces.ts +54 -7
  108. package/src/auth/jwt.ts +22 -5
  109. package/src/auth/linkedin-oauth.ts +81 -0
  110. package/src/auth/microsoft-oauth.ts +88 -0
  111. package/src/auth/middleware.ts +194 -45
  112. package/src/auth/password.ts +3 -1
  113. package/src/auth/rate-limiter.ts +4 -4
  114. package/src/auth/routes.ts +197 -139
  115. package/src/auth/slack-oauth.ts +71 -0
  116. package/src/auth/spotify-oauth.ts +67 -0
  117. package/src/auth/twitter-oauth.ts +120 -0
  118. package/src/collections/loader.ts +6 -6
  119. package/src/cron/cron-loader.ts +89 -0
  120. package/src/cron/cron-routes.test.ts +265 -0
  121. package/src/cron/cron-routes.ts +85 -0
  122. package/src/cron/cron-scheduler.test.ts +421 -0
  123. package/src/cron/cron-scheduler.ts +413 -0
  124. package/src/cron/cron-store.ts +163 -0
  125. package/src/cron/index.ts +6 -0
  126. package/src/db/interfaces.ts +2 -2
  127. package/src/email/index.ts +3 -2
  128. package/src/email/smtp-email-service.ts +5 -2
  129. package/src/email/templates.ts +93 -6
  130. package/src/email/types.ts +17 -24
  131. package/src/functions/function-loader.ts +34 -6
  132. package/src/functions/function-routes.ts +2 -2
  133. package/src/history/history-routes.ts +4 -3
  134. package/src/index.ts +10 -0
  135. package/src/init.ts +475 -57
  136. package/src/services/driver-registry.ts +1 -1
  137. package/src/singleton.test.ts +28 -0
  138. package/src/singleton.ts +70 -0
  139. package/src/storage/LocalStorageController.ts +50 -53
  140. package/src/storage/S3StorageController.ts +49 -46
  141. package/src/storage/index.ts +27 -16
  142. package/src/storage/routes.ts +76 -59
  143. package/src/storage/storage-registry.ts +5 -5
  144. package/src/storage/types.ts +44 -32
  145. package/src/types/index.ts +4 -4
  146. package/src/utils/dev-port.ts +176 -0
  147. package/src/utils/logger.ts +143 -0
  148. package/src/utils/logging.ts +5 -2
  149. package/src/utils/request-logger.ts +66 -0
  150. package/test/admin-routes.test.ts +114 -65
  151. package/test/api-generator.test.ts +73 -30
  152. package/test/ast-schema-editor.test.ts +5 -3
  153. package/test/auth-middleware-hono.test.ts +268 -33
  154. package/test/auth-routes.test.ts +325 -146
  155. package/test/driver-registry.test.ts +4 -2
  156. package/test/error-propagation.test.ts +226 -0
  157. package/test/errors.test.ts +12 -7
  158. package/test/jwt-security.test.ts +18 -9
  159. package/test/jwt.test.ts +26 -13
  160. package/test/middleware.test.ts +13 -8
  161. package/test/query-parser.test.ts +13 -8
  162. package/test/rate-limiter.test.ts +7 -7
  163. package/test/safe-compare.test.ts +66 -0
  164. package/test/singleton.test.ts +59 -0
  165. package/test/storage-local.test.ts +36 -43
  166. package/test/storage-registry.test.ts +38 -36
  167. package/test/storage-routes.test.ts +9 -5
  168. package/test/storage-s3.test.ts +51 -48
  169. package/test.ts +6 -0
  170. package/tsconfig.json +1 -1
  171. package/vite.config.ts +7 -5
  172. package/dist/index-BeMqpmfQ.js +0 -239
  173. package/dist/index-BeMqpmfQ.js.map +0 -1
  174. package/dist/index-bl4J3lNb.js +0 -55823
  175. package/dist/index-bl4J3lNb.js.map +0 -1
  176. package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +0 -46
@@ -16,7 +16,6 @@ import { configureJwt, generateAccessToken, hashRefreshToken } from "../src/auth
16
16
  // ── Mocks ───────────────────────────────────────────────────────────────────
17
17
 
18
18
  jest.mock("../src/auth/password");
19
- jest.mock("../src/auth/google-oauth");
20
19
 
21
20
  // Bypass rate limiters — they share state across tests and cause 429s
22
21
  jest.mock("../src/auth/rate-limiter", () => {
@@ -24,12 +23,12 @@ jest.mock("../src/auth/rate-limiter", () => {
24
23
  return {
25
24
  createRateLimiter: () => passthrough,
26
25
  defaultAuthLimiter: passthrough,
27
- strictAuthLimiter: passthrough,
26
+ strictAuthLimiter: passthrough
28
27
  };
29
28
  });
30
29
 
31
30
  import { hashPassword, verifyPassword, validatePasswordStrength } from "../src/auth/password";
32
- import { verifyGoogleIdToken, isGoogleOAuthConfigured } from "../src/auth/google-oauth";
31
+ import { z } from "zod";
33
32
 
34
33
  // ── Helpers ─────────────────────────────────────────────────────────────────
35
34
 
@@ -42,36 +41,43 @@ function mockUser(overrides: Partial<{ id: string; email: string; passwordHash:
42
41
  passwordHash: "passwordHash" in overrides ? overrides.passwordHash : "salt:hash",
43
42
  displayName: overrides.displayName ?? "Test User",
44
43
  photoUrl: overrides.photoUrl ?? null,
45
- provider: overrides.provider ?? "email",
46
- googleId: null,
47
44
  emailVerified: overrides.emailVerified ?? false,
48
45
  emailVerificationToken: overrides.emailVerificationToken ?? null,
49
46
  emailVerificationSentAt: null,
50
47
  createdAt: new Date(),
51
- updatedAt: new Date(),
48
+ updatedAt: new Date()
52
49
  };
53
50
  }
54
51
 
55
52
  function mockRole(id: string, isAdmin = false) {
56
- return { id, name: id.charAt(0).toUpperCase() + id.slice(1), isAdmin, defaultPermissions: null, collectionPermissions: null, config: null };
53
+ return { id,
54
+ name: id.charAt(0).toUpperCase() + id.slice(1),
55
+ isAdmin,
56
+ defaultPermissions: null,
57
+ collectionPermissions: null,
58
+ config: null };
57
59
  }
58
60
 
59
61
  let mockAuthRepo: jest.Mocked<AuthRepository>;
60
62
  let mockEmailService: { send: jest.Mock; isConfigured: jest.Mock };
61
63
 
62
- function createApp(opts: { allowRegistration?: boolean; withEmail?: boolean; defaultRole?: string } = {}) {
64
+ function createApp(opts: { allowRegistration?: boolean; withEmail?: boolean; defaultRole?: string; isBootstrapCompleted?: () => Promise<boolean> } = {}) {
63
65
  // Re-create mocked service instances each time
64
-
66
+
65
67
  // Wire constructor mocks to return our instances
66
-
68
+
67
69
  // Default returns for mocked services
68
-
70
+
69
71
  mockAuthRepo = {
70
72
  getUserByEmail: jest.fn().mockResolvedValue(null),
71
- getUserByGoogleId: jest.fn().mockResolvedValue(null),
73
+ getUserByIdentity: jest.fn().mockResolvedValue(null),
74
+ linkUserIdentity: jest.fn().mockResolvedValue(undefined),
75
+ getUserIdentities: jest.fn().mockResolvedValue([]),
72
76
  getUserById: jest.fn().mockResolvedValue(null),
73
77
  createUser: jest.fn().mockImplementation((data) =>
74
- Promise.resolve(mockUser({ email: data.email, displayName: data.displayName, passwordHash: data.passwordHash }))
78
+ Promise.resolve(mockUser({ email: data.email,
79
+ displayName: data.displayName,
80
+ passwordHash: data.passwordHash }))
75
81
  ),
76
82
  listUsers: jest.fn().mockResolvedValue([]),
77
83
  getUserRoles: jest.fn().mockResolvedValue([mockRole("editor")]),
@@ -79,7 +85,8 @@ function createApp(opts: { allowRegistration?: boolean; withEmail?: boolean; def
79
85
  assignDefaultRole: jest.fn().mockResolvedValue(undefined),
80
86
  setUserRoles: jest.fn().mockResolvedValue(undefined),
81
87
  updateUser: jest.fn().mockImplementation((id, data) =>
82
- Promise.resolve(mockUser({ id, ...data }))
88
+ Promise.resolve(mockUser({ id,
89
+ ...data }))
83
90
  ),
84
91
  deleteUser: jest.fn().mockResolvedValue(undefined),
85
92
  updatePassword: jest.fn().mockResolvedValue(undefined),
@@ -88,7 +95,8 @@ function createApp(opts: { allowRegistration?: boolean; withEmail?: boolean; def
88
95
  getUserByVerificationToken: jest.fn().mockResolvedValue(null),
89
96
  getUserWithRoles: jest.fn().mockImplementation(async (userId) => {
90
97
  const user = mockUser({ id: userId });
91
- return { user, roles: [mockRole("editor")] };
98
+ return { user,
99
+ roles: [mockRole("editor")] };
92
100
  }),
93
101
  createRefreshToken: jest.fn().mockResolvedValue(undefined),
94
102
  findRefreshTokenByHash: jest.fn().mockResolvedValue(null),
@@ -101,28 +109,57 @@ function createApp(opts: { allowRegistration?: boolean; withEmail?: boolean; def
101
109
  markPasswordResetTokenUsed: jest.fn().mockResolvedValue(undefined),
102
110
  deleteExpiredPasswordResetTokens: jest.fn().mockResolvedValue(undefined)
103
111
  } as unknown as jest.Mocked<AuthRepository>;
104
-
112
+
105
113
 
106
114
  // Password mocks
107
- (validatePasswordStrength as jest.Mock).mockReturnValue({ valid: true, errors: [] });
115
+ (validatePasswordStrength as jest.Mock).mockReturnValue({ valid: true,
116
+ errors: [] });
108
117
  (hashPassword as jest.Mock).mockResolvedValue("hashed-pw");
109
118
  (verifyPassword as jest.Mock).mockResolvedValue(true);
110
119
 
111
- // Google mocks
112
- (isGoogleOAuthConfigured as jest.Mock).mockReturnValue(false);
113
- (verifyGoogleIdToken as jest.Mock).mockResolvedValue(null);
114
-
115
120
  // Email mock
116
- mockEmailService = { send: jest.fn().mockResolvedValue(undefined), isConfigured: jest.fn().mockReturnValue(opts.withEmail ?? false) };
121
+ mockEmailService = { send: jest.fn().mockResolvedValue(undefined),
122
+ isConfigured: jest.fn().mockReturnValue(opts.withEmail ?? false) };
117
123
 
118
124
  const config: AuthModuleConfig = {
119
125
  authRepo: mockAuthRepo,
120
126
  allowRegistration: opts.allowRegistration ?? true,
121
127
  defaultRole: opts.defaultRole,
122
128
  emailService: opts.withEmail ? mockEmailService as any : undefined,
123
- emailConfig: opts.withEmail ? { from: "test@test.com", appName: "TestApp", resetPasswordUrl: "https://app.test", verifyEmailUrl: "https://app.test" } : undefined,
129
+ emailConfig: opts.withEmail ? { from: "test@test.com",
130
+ appName: "TestApp",
131
+ resetPasswordUrl: "https://app.test",
132
+ verifyEmailUrl: "https://app.test" } : undefined,
133
+ oauthProviders: opts.withEmail === false && opts.allowRegistration === false ? [] : [
134
+ {
135
+ id: "google",
136
+ schema: z.object({ idToken: z.string().min(1) }),
137
+ verify: async (payload: any) => {
138
+ const idToken = payload.idToken;
139
+ if (idToken === "bad-token") return null;
140
+ if (idToken === "link-token") return { providerId: "g-456",
141
+ email: "existing@test.com",
142
+ displayName: "Existing",
143
+ photoUrl: null };
144
+ if (idToken === "returning-token") return { providerId: "g-789",
145
+ email: "returning@test.com",
146
+ displayName: "Updated Name",
147
+ photoUrl: "https://new-photo.url" };
148
+ if (idToken === "valid-token") return { providerId: "g-123",
149
+ email: "google@test.com",
150
+ displayName: "Google User",
151
+ photoUrl: "https://photo.url" };
152
+ return null;
153
+ }
154
+ }
155
+ ]
124
156
  };
125
157
 
158
+ if (opts.isBootstrapCompleted) {
159
+ config.isBootstrapCompleted = opts.isBootstrapCompleted;
160
+ }
161
+
162
+
126
163
  const app = new Hono<HonoEnv>();
127
164
  app.onError(errorHandler);
128
165
  app.route("/auth", createAuthRoutes(config));
@@ -133,7 +170,7 @@ function json(body: Record<string, unknown>) {
133
170
  return {
134
171
  method: "POST" as const,
135
172
  headers: { "Content-Type": "application/json" },
136
- body: JSON.stringify(body),
173
+ body: JSON.stringify(body)
137
174
  };
138
175
  }
139
176
 
@@ -147,23 +184,27 @@ function authHeader(userId = "user-1", roles = ["editor"]) {
147
184
 
148
185
  describe("Auth Routes (Integration)", () => {
149
186
  beforeAll(() => {
150
- configureJwt({ secret: TEST_SECRET, accessExpiresIn: "1h" });
187
+ configureJwt({ secret: TEST_SECRET,
188
+ accessExpiresIn: "1h" });
151
189
  });
152
190
 
153
191
  beforeEach(() => {
154
192
  jest.clearAllMocks();
155
193
  });
156
194
 
195
+ describe("Configuration Security", () => {
196
+ it("throws an error when defaultRole is set to 'admin'", () => {
197
+ expect(() => createApp({ defaultRole: "admin" })).toThrowError(/CRITICAL SECURITY ERROR/);
198
+ });
199
+ });
200
+
157
201
  // ── Registration ────────────────────────────────────────────────────
158
202
  describe("POST /auth/register", () => {
159
203
  it("registers a new user and returns 201 with tokens", async () => {
160
204
  const app = createApp();
161
- // allowRegistration=true → isRegistrationAllowed() returns immediately
162
- // Only the isFirstUser check calls listUsers
163
- mockAuthRepo.listUsers
164
- .mockResolvedValueOnce([mockUser()]); // isFirstUser check
165
205
 
166
- const res = await app.request("/auth/register", json({ email: "new@test.com", password: "StrongPass1" }));
206
+ const res = await app.request("/auth/register", json({ email: "new@test.com",
207
+ password: "StrongPass1" }));
167
208
  expect(res.status).toBe(201);
168
209
  const body = await res.json() as any;
169
210
  expect(body.tokens.accessToken).toBeTruthy();
@@ -171,34 +212,28 @@ describe("Auth Routes (Integration)", () => {
171
212
  expect(body.user.email).toBe("new@test.com");
172
213
  });
173
214
 
174
- it("first user gets admin role", async () => {
215
+ it("first user does NOT get admin role (must use bootstrap)", async () => {
175
216
  const app = createApp();
176
- // allowRegistration=true → isRegistrationAllowed() returns immediately
177
- // Only the isFirstUser check calls listUsers (after createUser)
178
- mockAuthRepo.listUsers
179
- .mockResolvedValueOnce([mockUser()]); // allUsers.length === 1 → isFirstUser
180
217
 
181
- await app.request("/auth/register", json({ email: "first@test.com", password: "StrongPass1" }));
182
- expect(mockAuthRepo.assignDefaultRole).toHaveBeenCalledWith(expect.any(String), "admin");
218
+ await app.request("/auth/register", json({ email: "first@test.com",
219
+ password: "StrongPass1" }));
220
+ // No admin assignment — users must bootstrap via POST /admin/bootstrap
221
+ expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalled();
183
222
  });
184
223
 
185
- it("second user gets configured default role", async () => {
224
+ it("assigns configured default role to new users", async () => {
186
225
  const app = createApp({ defaultRole: "editor" });
187
- // allowRegistration=true → isRegistrationAllowed() returns immediately
188
- // isFirstUser check: 2 users → not first
189
- mockAuthRepo.listUsers
190
- .mockResolvedValueOnce([mockUser(), mockUser({ id: "user-2" })]);
191
226
 
192
- await app.request("/auth/register", json({ email: "second@test.com", password: "StrongPass1" }));
227
+ await app.request("/auth/register", json({ email: "second@test.com",
228
+ password: "StrongPass1" }));
193
229
  expect(mockAuthRepo.assignDefaultRole).toHaveBeenCalledWith(expect.any(String), "editor");
194
230
  });
195
231
 
196
- it("second user gets no role by default when not configured", async () => {
232
+ it("does not assign role when defaultRole is not configured", async () => {
197
233
  const app = createApp();
198
- mockAuthRepo.listUsers
199
- .mockResolvedValueOnce([mockUser(), mockUser({ id: "user-2" })]);
200
234
 
201
- await app.request("/auth/register", json({ email: "third@test.com", password: "StrongPass1" }));
235
+ await app.request("/auth/register", json({ email: "third@test.com",
236
+ password: "StrongPass1" }));
202
237
  expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalled();
203
238
  });
204
239
 
@@ -206,7 +241,8 @@ describe("Auth Routes (Integration)", () => {
206
241
  const app = createApp();
207
242
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
208
243
 
209
- const res = await app.request("/auth/register", json({ email: "existing@test.com", password: "StrongPass1" }));
244
+ const res = await app.request("/auth/register", json({ email: "existing@test.com",
245
+ password: "StrongPass1" }));
210
246
  expect(res.status).toBe(409);
211
247
  const body = await res.json() as any;
212
248
  expect(body.error.code).toBe("EMAIL_EXISTS");
@@ -214,9 +250,11 @@ describe("Auth Routes (Integration)", () => {
214
250
 
215
251
  it("returns 400 for weak password", async () => {
216
252
  const app = createApp();
217
- (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false, errors: ["Too short"] });
253
+ (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false,
254
+ errors: ["Too short"] });
218
255
 
219
- const res = await app.request("/auth/register", json({ email: "new@test.com", password: "weak" }));
256
+ const res = await app.request("/auth/register", json({ email: "new@test.com",
257
+ password: "weak" }));
220
258
  expect(res.status).toBe(400);
221
259
  const body = await res.json() as any;
222
260
  expect(body.error.code).toBe("WEAK_PASSWORD");
@@ -224,7 +262,8 @@ describe("Auth Routes (Integration)", () => {
224
262
 
225
263
  it("returns 400 for invalid email (Zod)", async () => {
226
264
  const app = createApp();
227
- const res = await app.request("/auth/register", json({ email: "not-an-email", password: "StrongPass1" }));
265
+ const res = await app.request("/auth/register", json({ email: "not-an-email",
266
+ password: "StrongPass1" }));
228
267
  expect(res.status).toBe(400);
229
268
  const body = await res.json() as any;
230
269
  expect(body.error.code).toBe("INVALID_INPUT");
@@ -236,31 +275,33 @@ describe("Auth Routes (Integration)", () => {
236
275
  expect(res.status).toBe(400);
237
276
  });
238
277
 
239
- it("returns 403 when registration is disabled and users exist", async () => {
278
+ it("returns 403 when registration is disabled", async () => {
240
279
  const app = createApp({ allowRegistration: false });
241
- mockAuthRepo.listUsers.mockResolvedValueOnce([mockUser()]); // users exist
242
280
 
243
- const res = await app.request("/auth/register", json({ email: "new@test.com", password: "StrongPass1" }));
281
+ const res = await app.request("/auth/register", json({ email: "new@test.com",
282
+ password: "StrongPass1" }));
244
283
  expect(res.status).toBe(403);
245
284
  const body = await res.json() as any;
246
285
  expect(body.error.code).toBe("REGISTRATION_DISABLED");
247
286
  });
248
287
 
249
- it("allows first-user registration even when registration is disabled", async () => {
288
+ it("blocks registration even on empty database when allowRegistration=false", async () => {
250
289
  const app = createApp({ allowRegistration: false });
251
- mockAuthRepo.listUsers
252
- .mockResolvedValueOnce([]) // isRegistrationAllowed → empty = allow
253
- .mockResolvedValueOnce([mockUser()]); // isFirstUser
290
+ // Even with no users, registration should be denied
291
+ mockAuthRepo.listUsers.mockResolvedValueOnce([]);
254
292
 
255
- const res = await app.request("/auth/register", json({ email: "first@test.com", password: "StrongPass1" }));
256
- expect(res.status).toBe(201);
293
+ const res = await app.request("/auth/register", json({ email: "first@test.com",
294
+ password: "StrongPass1" }));
295
+ expect(res.status).toBe(403);
296
+ const body = await res.json() as any;
297
+ expect(body.error.code).toBe("REGISTRATION_DISABLED");
257
298
  });
258
299
 
259
300
  it("stores refresh token after registration", async () => {
260
301
  const app = createApp();
261
- mockAuthRepo.listUsers.mockResolvedValueOnce([mockUser()]);
262
302
 
263
- await app.request("/auth/register", json({ email: "a@b.com", password: "StrongPass1" }));
303
+ await app.request("/auth/register", json({ email: "a@b.com",
304
+ password: "StrongPass1" }));
264
305
  expect(mockAuthRepo.createRefreshToken).toHaveBeenCalledTimes(1);
265
306
  });
266
307
  });
@@ -272,7 +313,8 @@ describe("Auth Routes (Integration)", () => {
272
313
  const user = mockUser({ passwordHash: "salt:hash" });
273
314
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(user);
274
315
 
275
- const res = await app.request("/auth/login", json({ email: "test@example.com", password: "ValidPass1" }));
316
+ const res = await app.request("/auth/login", json({ email: "test@example.com",
317
+ password: "ValidPass1" }));
276
318
  expect(res.status).toBe(200);
277
319
  const body = await res.json() as any;
278
320
  expect(body.tokens.accessToken).toBeTruthy();
@@ -283,7 +325,8 @@ describe("Auth Routes (Integration)", () => {
283
325
  const app = createApp();
284
326
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(null);
285
327
 
286
- const res = await app.request("/auth/login", json({ email: "nobody@test.com", password: "Any1" }));
328
+ const res = await app.request("/auth/login", json({ email: "nobody@test.com",
329
+ password: "Any1" }));
287
330
  expect(res.status).toBe(401);
288
331
  const body = await res.json() as any;
289
332
  expect(body.error.code).toBe("INVALID_CREDENTIALS");
@@ -294,7 +337,8 @@ describe("Auth Routes (Integration)", () => {
294
337
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
295
338
  (verifyPassword as jest.Mock).mockResolvedValueOnce(false);
296
339
 
297
- const res = await app.request("/auth/login", json({ email: "test@example.com", password: "Wrong1" }));
340
+ const res = await app.request("/auth/login", json({ email: "test@example.com",
341
+ password: "Wrong1" }));
298
342
  expect(res.status).toBe(401);
299
343
  });
300
344
 
@@ -302,7 +346,8 @@ describe("Auth Routes (Integration)", () => {
302
346
  const app = createApp();
303
347
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser({ passwordHash: null }));
304
348
 
305
- const res = await app.request("/auth/login", json({ email: "google@test.com", password: "Any1" }));
349
+ const res = await app.request("/auth/login", json({ email: "google@test.com",
350
+ password: "Any1" }));
306
351
  expect(res.status).toBe(401);
307
352
  });
308
353
 
@@ -316,25 +361,23 @@ describe("Auth Routes (Integration)", () => {
316
361
  const app = createApp();
317
362
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(mockUser());
318
363
 
319
- await app.request("/auth/login", json({ email: "test@example.com", password: "ValidPass1" }));
364
+ await app.request("/auth/login", json({ email: "test@example.com",
365
+ password: "ValidPass1" }));
320
366
  expect(mockAuthRepo.createRefreshToken).toHaveBeenCalledTimes(1);
321
367
  });
322
368
  });
323
369
 
324
370
  // ── Google OAuth ────────────────────────────────────────────────────
325
371
  describe("POST /auth/google", () => {
326
- it("returns 503 when Google OAuth is not configured", async () => {
327
- const app = createApp();
372
+ it("returns 404 when OAuth provider is not injected", async () => {
373
+ const app = createApp({ allowRegistration: false,
374
+ withEmail: false }); // Hack to pass empty list of providers
328
375
  const res = await app.request("/auth/google", json({ idToken: "google-token" }));
329
- expect(res.status).toBe(503);
330
- const body = await res.json() as any;
331
- expect(body.error.code).toBe("NOT_CONFIGURED");
376
+ expect(res.status).toBe(404);
332
377
  });
333
378
 
334
379
  it("returns 401 for invalid Google token", async () => {
335
380
  const app = createApp();
336
- (isGoogleOAuthConfigured as jest.Mock).mockReturnValueOnce(true);
337
- (verifyGoogleIdToken as jest.Mock).mockResolvedValueOnce(null);
338
381
 
339
382
  const res = await app.request("/auth/google", json({ idToken: "bad-token" }));
340
383
  expect(res.status).toBe(401);
@@ -344,64 +387,40 @@ describe("Auth Routes (Integration)", () => {
344
387
 
345
388
  it("creates a new user for new Google sign-in", async () => {
346
389
  const app = createApp();
347
- (isGoogleOAuthConfigured as jest.Mock).mockReturnValue(true);
348
- (verifyGoogleIdToken as jest.Mock).mockResolvedValueOnce({
349
- googleId: "g-123",
350
- email: "google@test.com",
351
- displayName: "Google User",
352
- photoUrl: "https://photo.url",
353
- emailVerified: true,
354
- });
355
- mockAuthRepo.getUserByGoogleId.mockResolvedValueOnce(null);
390
+ mockAuthRepo.getUserByIdentity.mockResolvedValueOnce(null);
356
391
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(null);
357
- mockAuthRepo.listUsers.mockResolvedValueOnce([mockUser()]); // not first user
358
392
 
359
393
  const res = await app.request("/auth/google", json({ idToken: "valid-token" }));
360
394
  expect(res.status).toBe(200);
361
395
  expect(mockAuthRepo.createUser).toHaveBeenCalledWith(expect.objectContaining({
362
- email: "google@test.com",
363
- provider: "google",
364
- googleId: "g-123",
396
+ email: "google@test.com"
365
397
  }));
398
+ expect(mockAuthRepo.linkUserIdentity).toHaveBeenCalledWith(expect.any(String), "google", "g-123", expect.any(Object));
399
+ // Verify no admin auto-assignment
400
+ expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalled();
366
401
  });
367
402
 
368
403
  it("links Google to existing account by email", async () => {
369
404
  const app = createApp();
370
- (isGoogleOAuthConfigured as jest.Mock).mockReturnValue(true);
371
- (verifyGoogleIdToken as jest.Mock).mockResolvedValueOnce({
372
- googleId: "g-456",
373
- email: "existing@test.com",
374
- displayName: "Existing",
375
- photoUrl: null,
376
- emailVerified: true,
377
- });
378
405
  const existing = mockUser({ email: "existing@test.com" });
379
- mockAuthRepo.getUserByGoogleId.mockResolvedValueOnce(null);
406
+ mockAuthRepo.getUserByIdentity.mockResolvedValueOnce(null);
380
407
  mockAuthRepo.getUserByEmail.mockResolvedValueOnce(existing);
381
408
 
382
409
  const res = await app.request("/auth/google", json({ idToken: "link-token" }));
383
410
  expect(res.status).toBe(200);
384
- expect(mockAuthRepo.updateUser).toHaveBeenCalledWith(existing.id, { googleId: "g-456" });
411
+ expect(mockAuthRepo.linkUserIdentity).toHaveBeenCalledWith(existing.id, "google", "g-456", expect.any(Object));
385
412
  });
386
413
 
387
414
  it("updates profile for returning Google user", async () => {
388
415
  const app = createApp();
389
- (isGoogleOAuthConfigured as jest.Mock).mockReturnValue(true);
390
416
  const existingUser = mockUser({ id: "g-user-1" });
391
- (verifyGoogleIdToken as jest.Mock).mockResolvedValueOnce({
392
- googleId: "g-789",
393
- email: "returning@test.com",
394
- displayName: "Updated Name",
395
- photoUrl: "https://new-photo.url",
396
- emailVerified: true,
397
- });
398
- mockAuthRepo.getUserByGoogleId.mockResolvedValueOnce(existingUser);
417
+ mockAuthRepo.getUserByIdentity.mockResolvedValueOnce(existingUser);
399
418
 
400
419
  const res = await app.request("/auth/google", json({ idToken: "returning-token" }));
401
420
  expect(res.status).toBe(200);
402
421
  expect(mockAuthRepo.updateUser).toHaveBeenCalledWith(existingUser.id, expect.objectContaining({
403
422
  displayName: "Updated Name",
404
- photoUrl: "https://new-photo.url",
423
+ photoUrl: "https://new-photo.url"
405
424
  }));
406
425
  });
407
426
  });
@@ -417,7 +436,7 @@ describe("Auth Routes (Integration)", () => {
417
436
  expiresAt: new Date(Date.now() + 86400000),
418
437
  createdAt: new Date(),
419
438
  userAgent: "",
420
- ipAddress: "",
439
+ ipAddress: ""
421
440
  });
422
441
  mockAuthRepo.getUserRoles.mockResolvedValueOnce([mockRole("editor")]);
423
442
 
@@ -437,7 +456,7 @@ describe("Auth Routes (Integration)", () => {
437
456
  expiresAt: new Date(Date.now() + 86400000),
438
457
  createdAt: new Date(),
439
458
  userAgent: "",
440
- ipAddress: "",
459
+ ipAddress: ""
441
460
  });
442
461
 
443
462
  await app.request("/auth/refresh", json({ refreshToken: "the-token" }));
@@ -466,7 +485,7 @@ describe("Auth Routes (Integration)", () => {
466
485
  expiresAt: new Date(Date.now() - 1000), // expired
467
486
  createdAt: new Date(),
468
487
  userAgent: "",
469
- ipAddress: "",
488
+ ipAddress: ""
470
489
  });
471
490
 
472
491
  const res = await app.request("/auth/refresh", json({ refreshToken: "expired-token" }));
@@ -545,10 +564,11 @@ describe("Auth Routes (Integration)", () => {
545
564
  const app = createApp();
546
565
  mockAuthRepo.findValidPasswordResetToken.mockResolvedValueOnce({
547
566
  userId: "user-1",
548
- expiresAt: new Date(Date.now() + 3600000),
567
+ expiresAt: new Date(Date.now() + 3600000)
549
568
  });
550
569
 
551
- const res = await app.request("/auth/reset-password", json({ token: "valid-reset-token", password: "NewStrong1" }));
570
+ const res = await app.request("/auth/reset-password", json({ token: "valid-reset-token",
571
+ password: "NewStrong1" }));
552
572
  expect(res.status).toBe(200);
553
573
  expect(mockAuthRepo.updatePassword).toHaveBeenCalledWith("user-1", "hashed-pw");
554
574
  expect(mockAuthRepo.markPasswordResetTokenUsed).toHaveBeenCalled();
@@ -558,10 +578,11 @@ describe("Auth Routes (Integration)", () => {
558
578
  const app = createApp();
559
579
  mockAuthRepo.findValidPasswordResetToken.mockResolvedValueOnce({
560
580
  userId: "user-1",
561
- expiresAt: new Date(Date.now() + 3600000),
581
+ expiresAt: new Date(Date.now() + 3600000)
562
582
  });
563
583
 
564
- await app.request("/auth/reset-password", json({ token: "token", password: "NewStrong1" }));
584
+ await app.request("/auth/reset-password", json({ token: "token",
585
+ password: "NewStrong1" }));
565
586
  expect(mockAuthRepo.deleteAllRefreshTokensForUser).toHaveBeenCalledWith("user-1");
566
587
  });
567
588
 
@@ -569,7 +590,8 @@ describe("Auth Routes (Integration)", () => {
569
590
  const app = createApp();
570
591
  mockAuthRepo.findValidPasswordResetToken.mockResolvedValueOnce(null);
571
592
 
572
- const res = await app.request("/auth/reset-password", json({ token: "expired", password: "NewStrong1" }));
593
+ const res = await app.request("/auth/reset-password", json({ token: "expired",
594
+ password: "NewStrong1" }));
573
595
  expect(res.status).toBe(400);
574
596
  const body = await res.json() as any;
575
597
  expect(body.error.code).toBe("INVALID_TOKEN");
@@ -577,9 +599,11 @@ describe("Auth Routes (Integration)", () => {
577
599
 
578
600
  it("returns 400 for weak new password", async () => {
579
601
  const app = createApp();
580
- (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false, errors: ["Too weak"] });
602
+ (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false,
603
+ errors: ["Too weak"] });
581
604
 
582
- const res = await app.request("/auth/reset-password", json({ token: "token", password: "weak" }));
605
+ const res = await app.request("/auth/reset-password", json({ token: "token",
606
+ password: "weak" }));
583
607
  expect(res.status).toBe(400);
584
608
  const body = await res.json() as any;
585
609
  expect(body.error.code).toBe("WEAK_PASSWORD");
@@ -593,8 +617,10 @@ describe("Auth Routes (Integration)", () => {
593
617
  mockAuthRepo.getUserById.mockResolvedValue(mockUser());
594
618
 
595
619
  const res = await app.request("/auth/change-password", {
596
- ...json({ oldPassword: "OldPass1", newPassword: "NewPass1" }),
597
- headers: { ...json({}).headers, ...authHeader() },
620
+ ...json({ oldPassword: "OldPass1",
621
+ newPassword: "NewPass1" }),
622
+ headers: { ...json({}).headers,
623
+ ...authHeader() }
598
624
  });
599
625
  expect(res.status).toBe(200);
600
626
  expect(mockAuthRepo.updatePassword).toHaveBeenCalled();
@@ -605,8 +631,10 @@ describe("Auth Routes (Integration)", () => {
605
631
  mockAuthRepo.getUserById.mockResolvedValue(mockUser());
606
632
 
607
633
  await app.request("/auth/change-password", {
608
- ...json({ oldPassword: "Old1", newPassword: "New1Pass" }),
609
- headers: { ...json({}).headers, ...authHeader() },
634
+ ...json({ oldPassword: "Old1",
635
+ newPassword: "New1Pass" }),
636
+ headers: { ...json({}).headers,
637
+ ...authHeader() }
610
638
  });
611
639
  expect(mockAuthRepo.deleteAllRefreshTokensForUser).toHaveBeenCalledWith("user-1");
612
640
  });
@@ -617,8 +645,10 @@ describe("Auth Routes (Integration)", () => {
617
645
  (verifyPassword as jest.Mock).mockResolvedValueOnce(false);
618
646
 
619
647
  const res = await app.request("/auth/change-password", {
620
- ...json({ oldPassword: "Wrong1", newPassword: "New1Pass" }),
621
- headers: { ...json({}).headers, ...authHeader() },
648
+ ...json({ oldPassword: "Wrong1",
649
+ newPassword: "New1Pass" }),
650
+ headers: { ...json({}).headers,
651
+ ...authHeader() }
622
652
  });
623
653
  expect(res.status).toBe(401);
624
654
  });
@@ -626,18 +656,22 @@ describe("Auth Routes (Integration)", () => {
626
656
  it("returns 400 for weak new password", async () => {
627
657
  const app = createApp();
628
658
  mockAuthRepo.getUserById.mockResolvedValue(mockUser());
629
- (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false, errors: ["Too short"] });
659
+ (validatePasswordStrength as jest.Mock).mockReturnValueOnce({ valid: false,
660
+ errors: ["Too short"] });
630
661
 
631
662
  const res = await app.request("/auth/change-password", {
632
- ...json({ oldPassword: "Old1", newPassword: "x" }),
633
- headers: { ...json({}).headers, ...authHeader() },
663
+ ...json({ oldPassword: "Old1",
664
+ newPassword: "x" }),
665
+ headers: { ...json({}).headers,
666
+ ...authHeader() }
634
667
  });
635
668
  expect(res.status).toBe(400);
636
669
  });
637
670
 
638
671
  it("returns 401 without auth", async () => {
639
672
  const app = createApp();
640
- const res = await app.request("/auth/change-password", json({ oldPassword: "Old1", newPassword: "New1Pass" }));
673
+ const res = await app.request("/auth/change-password", json({ oldPassword: "Old1",
674
+ newPassword: "New1Pass" }));
641
675
  expect(res.status).toBe(401);
642
676
  });
643
677
 
@@ -646,8 +680,10 @@ describe("Auth Routes (Integration)", () => {
646
680
  mockAuthRepo.getUserById.mockResolvedValue(mockUser({ passwordHash: null }));
647
681
 
648
682
  const res = await app.request("/auth/change-password", {
649
- ...json({ oldPassword: "Old1", newPassword: "New1Pass" }),
650
- headers: { ...json({}).headers, ...authHeader() },
683
+ ...json({ oldPassword: "Old1",
684
+ newPassword: "New1Pass" }),
685
+ headers: { ...json({}).headers,
686
+ ...authHeader() }
651
687
  });
652
688
  expect(res.status).toBe(400);
653
689
  const body = await res.json() as any;
@@ -664,7 +700,7 @@ describe("Auth Routes (Integration)", () => {
664
700
 
665
701
  const res = await app.request("/auth/send-verification", {
666
702
  method: "POST",
667
- headers: { ...authHeader() },
703
+ headers: { ...authHeader() }
668
704
  });
669
705
  expect(res.status).toBe(200);
670
706
  expect(mockAuthRepo.setVerificationToken).toHaveBeenCalled();
@@ -677,7 +713,7 @@ describe("Auth Routes (Integration)", () => {
677
713
 
678
714
  const res = await app.request("/auth/send-verification", {
679
715
  method: "POST",
680
- headers: { ...authHeader() },
716
+ headers: { ...authHeader() }
681
717
  });
682
718
  expect(res.status).toBe(400);
683
719
  const body = await res.json() as any;
@@ -694,7 +730,7 @@ describe("Auth Routes (Integration)", () => {
694
730
  const app = createApp({ withEmail: false });
695
731
  const res = await app.request("/auth/send-verification", {
696
732
  method: "POST",
697
- headers: { ...authHeader() },
733
+ headers: { ...authHeader() }
698
734
  });
699
735
  expect(res.status).toBe(503);
700
736
  });
@@ -733,7 +769,7 @@ describe("Auth Routes (Integration)", () => {
733
769
  it("returns authenticated user with roles", async () => {
734
770
  const app = createApp();
735
771
  const res = await app.request("/auth/me", {
736
- headers: { ...authHeader("user-1", ["admin"]) },
772
+ headers: { ...authHeader("user-1", ["admin"]) }
737
773
  });
738
774
  expect(res.status).toBe(200);
739
775
  const body = await res.json() as any;
@@ -752,7 +788,7 @@ describe("Auth Routes (Integration)", () => {
752
788
  mockAuthRepo.getUserWithRoles.mockResolvedValueOnce(null);
753
789
 
754
790
  const res = await app.request("/auth/me", {
755
- headers: { ...authHeader() },
791
+ headers: { ...authHeader() }
756
792
  });
757
793
  expect(res.status).toBe(404);
758
794
  });
@@ -765,12 +801,13 @@ describe("Auth Routes (Integration)", () => {
765
801
 
766
802
  const res = await app.request("/auth/me", {
767
803
  method: "PATCH",
768
- headers: { "Content-Type": "application/json", ...authHeader() },
769
- body: JSON.stringify({ displayName: "New Name" }),
804
+ headers: { "Content-Type": "application/json",
805
+ ...authHeader() },
806
+ body: JSON.stringify({ displayName: "New Name" })
770
807
  });
771
808
  expect(res.status).toBe(200);
772
809
  expect(mockAuthRepo.updateUser).toHaveBeenCalledWith("user-1", expect.objectContaining({
773
- displayName: "New Name",
810
+ displayName: "New Name"
774
811
  }));
775
812
  });
776
813
 
@@ -779,7 +816,7 @@ describe("Auth Routes (Integration)", () => {
779
816
  const res = await app.request("/auth/me", {
780
817
  method: "PATCH",
781
818
  headers: { "Content-Type": "application/json" },
782
- body: JSON.stringify({ displayName: "Name" }),
819
+ body: JSON.stringify({ displayName: "Name" })
783
820
  });
784
821
  expect(res.status).toBe(401);
785
822
  });
@@ -790,7 +827,13 @@ describe("Auth Routes (Integration)", () => {
790
827
  it("GET /auth/sessions lists active sessions", async () => {
791
828
  const app = createApp();
792
829
  mockAuthRepo.listRefreshTokensForUser.mockResolvedValueOnce([
793
- { id: "s1", userId: "user-1", tokenHash: "h1", expiresAt: new Date(), createdAt: new Date(), userAgent: "Chrome", ipAddress: "1.2.3.4" },
830
+ { id: "s1",
831
+ userId: "user-1",
832
+ tokenHash: "h1",
833
+ expiresAt: new Date(),
834
+ createdAt: new Date(),
835
+ userAgent: "Chrome",
836
+ ipAddress: "1.2.3.4" }
794
837
  ]);
795
838
 
796
839
  const res = await app.request("/auth/sessions", { headers: { ...authHeader() } });
@@ -804,7 +847,7 @@ describe("Auth Routes (Integration)", () => {
804
847
  const app = createApp();
805
848
  const res = await app.request("/auth/sessions", {
806
849
  method: "DELETE",
807
- headers: { ...authHeader() },
850
+ headers: { ...authHeader() }
808
851
  });
809
852
  expect(res.status).toBe(200);
810
853
  expect(mockAuthRepo.deleteAllRefreshTokensForUser).toHaveBeenCalledWith("user-1");
@@ -814,7 +857,7 @@ describe("Auth Routes (Integration)", () => {
814
857
  const app = createApp();
815
858
  const res = await app.request("/auth/sessions/s123", {
816
859
  method: "DELETE",
817
- headers: { ...authHeader() },
860
+ headers: { ...authHeader() }
818
861
  });
819
862
  expect(res.status).toBe(200);
820
863
  expect(mockAuthRepo.deleteRefreshTokenById).toHaveBeenCalledWith("s123", "user-1");
@@ -832,7 +875,27 @@ describe("Auth Routes (Integration)", () => {
832
875
 
833
876
  // ── Auth Config ─────────────────────────────────────────────────────
834
877
  describe("GET /auth/config", () => {
835
- it("returns setup status when no users exist", async () => {
878
+ it("returns needsSetup=true when isBootstrapCompleted returns false", async () => {
879
+ const app = createApp({ isBootstrapCompleted: async () => false });
880
+
881
+ const res = await app.request("/auth/config");
882
+ expect(res.status).toBe(200);
883
+ const body = await res.json() as any;
884
+ expect(body.needsSetup).toBe(true);
885
+ expect(body.registrationEnabled).toBe(true); // always true when needsSetup
886
+ });
887
+
888
+ it("returns needsSetup=false when isBootstrapCompleted returns true", async () => {
889
+ const app = createApp({ allowRegistration: false, isBootstrapCompleted: async () => true });
890
+
891
+ const res = await app.request("/auth/config");
892
+ expect(res.status).toBe(200);
893
+ const body = await res.json() as any;
894
+ expect(body.needsSetup).toBe(false);
895
+ expect(body.registrationEnabled).toBe(false);
896
+ });
897
+
898
+ it("falls back to user-count check when no isBootstrapCompleted callback", async () => {
836
899
  const app = createApp();
837
900
  mockAuthRepo.listUsers.mockResolvedValueOnce([]);
838
901
 
@@ -840,11 +903,12 @@ describe("Auth Routes (Integration)", () => {
840
903
  expect(res.status).toBe(200);
841
904
  const body = await res.json() as any;
842
905
  expect(body.needsSetup).toBe(true);
843
- expect(body.registrationEnabled).toBe(true); // always true when needsSetup
906
+ expect(body.registrationEnabled).toBe(true);
844
907
  });
845
908
 
846
- it("returns correct flags when users exist", async () => {
847
- const app = createApp({ allowRegistration: false });
909
+ it("returns correct flags when users exist and no callback", async () => {
910
+ const app = createApp({ allowRegistration: false,
911
+ withEmail: false });
848
912
  mockAuthRepo.listUsers.mockResolvedValueOnce([mockUser()]);
849
913
 
850
914
  const res = await app.request("/auth/config");
@@ -857,7 +921,6 @@ describe("Auth Routes (Integration)", () => {
857
921
 
858
922
  it("reports Google enabled when configured", async () => {
859
923
  const app = createApp();
860
- (isGoogleOAuthConfigured as jest.Mock).mockReturnValue(true);
861
924
  mockAuthRepo.listUsers.mockResolvedValueOnce([mockUser()]);
862
925
 
863
926
  const res = await app.request("/auth/config");
@@ -865,4 +928,120 @@ describe("Auth Routes (Integration)", () => {
865
928
  expect(body.googleEnabled).toBe(true);
866
929
  });
867
930
  });
931
+
932
+ // ═════════════════════════════════════════════════════════════════════
933
+ // SECURITY REGRESSION TESTS — CVE: First-User Admin Privilege Escalation
934
+ // These tests directly verify the exploit scenarios from the security audit.
935
+ // ═════════════════════════════════════════════════════════════════════
936
+
937
+ describe("Security: privilege escalation prevention", () => {
938
+ it("CVE-FIX: registration NEVER assigns admin role, even for first user", async () => {
939
+ const app = createApp({ allowRegistration: true });
940
+ // Simulate first user (empty database before, one user after create)
941
+ mockAuthRepo.listUsers.mockResolvedValueOnce([]);
942
+
943
+ const res = await app.request("/auth/register", json({
944
+ email: "hacker@evil.com",
945
+ password: "HackRebase2026!",
946
+ displayName: "Hacker"
947
+ }));
948
+
949
+ expect(res.status).toBe(201);
950
+ const body = await res.json() as any;
951
+
952
+ // The critical assertion: admin must NOT be in the roles
953
+ expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalledWith(
954
+ expect.any(String), "admin"
955
+ );
956
+ // Verify the user was created but NOT given admin
957
+ expect(mockAuthRepo.createUser).toHaveBeenCalledTimes(1);
958
+ });
959
+
960
+ it("CVE-FIX: OAuth registration NEVER assigns admin role, even for first user", async () => {
961
+ const app = createApp({ allowRegistration: true });
962
+ mockAuthRepo.getUserByIdentity.mockResolvedValueOnce(null);
963
+ mockAuthRepo.getUserByEmail.mockResolvedValueOnce(null);
964
+
965
+ const res = await app.request("/auth/google", json({ idToken: "valid-token" }));
966
+ expect(res.status).toBe(200);
967
+
968
+ // The critical assertion: admin must NOT be assigned
969
+ expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalledWith(
970
+ expect.any(String), "admin"
971
+ );
972
+ });
973
+
974
+ it("CVE-FIX: empty database does NOT bypass allowRegistration=false", async () => {
975
+ const app = createApp({ allowRegistration: false });
976
+ // Even with zero users, the registration endpoint must reject
977
+ mockAuthRepo.listUsers.mockResolvedValueOnce([]);
978
+
979
+ const res = await app.request("/auth/register", json({
980
+ email: "hacker@evil.com",
981
+ password: "HackRebase2026!",
982
+ displayName: "Hacker"
983
+ }));
984
+
985
+ expect(res.status).toBe(403);
986
+ const body = await res.json() as any;
987
+ expect(body.error.code).toBe("REGISTRATION_DISABLED");
988
+ // createUser must NOT have been called
989
+ expect(mockAuthRepo.createUser).not.toHaveBeenCalled();
990
+ });
991
+
992
+ it("CVE-FIX: registration is blocked when allowRegistration defaults to false", async () => {
993
+ const app = createApp({ allowRegistration: false });
994
+
995
+ const res = await app.request("/auth/register", json({
996
+ email: "hacker@evil.com",
997
+ password: "HackRebase2026!",
998
+ displayName: "Hacker"
999
+ }));
1000
+
1001
+ expect(res.status).toBe(403);
1002
+ const body = await res.json() as any;
1003
+ expect(body.error.code).toBe("REGISTRATION_DISABLED");
1004
+ expect(mockAuthRepo.createUser).not.toHaveBeenCalled();
1005
+ });
1006
+
1007
+ it("CVE-FIX: concurrent registration attempts cannot produce multiple admins", async () => {
1008
+ const app = createApp({ allowRegistration: true });
1009
+
1010
+ // Simulate 5 concurrent registration requests
1011
+ const requests = Array.from({ length: 5 }, (_, i) =>
1012
+ app.request("/auth/register", json({
1013
+ email: `concurrent-${i}@evil.com`,
1014
+ password: "StrongPass1",
1015
+ displayName: `Concurrent ${i}`
1016
+ }))
1017
+ );
1018
+
1019
+ const responses = await Promise.all(requests);
1020
+ const successfulRegistrations = responses.filter(r => r.status === 201);
1021
+
1022
+ // Even if multiple registrations succeed, NONE should get admin
1023
+ expect(mockAuthRepo.assignDefaultRole).not.toHaveBeenCalledWith(
1024
+ expect.any(String), "admin"
1025
+ );
1026
+ });
1027
+
1028
+ it("CVE-FIX: defaultRole cannot be set to 'admin' to grant admin via registration", async () => {
1029
+ // Even if someone misconfigures defaultRole as 'admin',
1030
+ // this test documents the current behavior — it would assign admin.
1031
+ // The fix is in the config validation at startup (outside routes).
1032
+ // Here we verify the defaultRole IS what's passed, and the auto-escalation is gone.
1033
+ const app = createApp({ allowRegistration: true, defaultRole: "viewer" });
1034
+
1035
+ await app.request("/auth/register", json({
1036
+ email: "new@test.com",
1037
+ password: "StrongPass1"
1038
+ }));
1039
+
1040
+ // Only the configured default role is assigned, never auto-admin
1041
+ expect(mockAuthRepo.assignDefaultRole).toHaveBeenCalledWith(
1042
+ expect.any(String), "viewer"
1043
+ );
1044
+ expect(mockAuthRepo.assignDefaultRole).toHaveBeenCalledTimes(1);
1045
+ });
1046
+ });
868
1047
  });