@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.
- package/dist/common/src/collections/CollectionRegistry.d.ts +8 -0
- package/dist/common/src/util/entities.d.ts +22 -0
- package/dist/common/src/util/relations.d.ts +14 -4
- package/dist/common/src/util/resolutions.d.ts +1 -1
- package/dist/index-DXVBFp5V.js +37 -0
- package/dist/index-DXVBFp5V.js.map +1 -0
- package/dist/index.es.js +49931 -55
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +28475 -34569
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/openapi-generator.d.ts +15 -1
- package/dist/server-core/src/api/types.d.ts +1 -1
- package/dist/server-core/src/auth/admin-routes.d.ts +10 -1
- package/dist/server-core/src/auth/apple-oauth.d.ts +30 -0
- package/dist/server-core/src/auth/bitbucket-oauth.d.ts +11 -0
- package/dist/server-core/src/auth/discord-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/facebook-oauth.d.ts +14 -0
- package/dist/server-core/src/auth/github-oauth.d.ts +15 -0
- package/dist/server-core/src/auth/gitlab-oauth.d.ts +13 -0
- package/dist/server-core/src/auth/google-oauth.d.ts +5 -11
- package/dist/server-core/src/auth/index.d.ts +13 -2
- package/dist/server-core/src/auth/interfaces.d.ts +45 -6
- package/dist/server-core/src/auth/jwt.d.ts +1 -0
- package/dist/server-core/src/auth/linkedin-oauth.d.ts +18 -0
- package/dist/server-core/src/auth/microsoft-oauth.d.ts +16 -0
- package/dist/server-core/src/auth/middleware.d.ts +36 -11
- package/dist/server-core/src/auth/rate-limiter.d.ts +2 -2
- package/dist/server-core/src/auth/routes.d.ts +13 -3
- package/dist/server-core/src/auth/slack-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/spotify-oauth.d.ts +12 -0
- package/dist/server-core/src/auth/twitter-oauth.d.ts +18 -0
- package/dist/server-core/src/cron/cron-loader.d.ts +17 -0
- package/dist/server-core/src/cron/cron-routes.d.ts +14 -0
- package/dist/server-core/src/cron/cron-scheduler.d.ts +61 -0
- package/dist/server-core/src/cron/cron-store.d.ts +32 -0
- package/dist/server-core/src/cron/index.d.ts +6 -0
- package/dist/server-core/src/email/index.d.ts +2 -2
- package/dist/server-core/src/email/templates.d.ts +9 -0
- package/dist/server-core/src/email/types.d.ts +20 -23
- package/dist/server-core/src/history/history-routes.d.ts +2 -2
- package/dist/server-core/src/index.d.ts +5 -0
- package/dist/server-core/src/init.d.ts +112 -2
- package/dist/server-core/src/singleton.d.ts +35 -0
- package/dist/server-core/src/storage/LocalStorageController.d.ts +9 -9
- package/dist/server-core/src/storage/S3StorageController.d.ts +8 -8
- package/dist/server-core/src/storage/index.d.ts +18 -11
- package/dist/server-core/src/storage/routes.d.ts +3 -3
- package/dist/server-core/src/storage/types.d.ts +28 -16
- package/dist/server-core/src/utils/dev-port.d.ts +35 -0
- package/dist/server-core/src/utils/logger.d.ts +31 -0
- package/dist/server-core/src/utils/request-logger.d.ts +19 -0
- package/dist/types/src/controllers/auth.d.ts +2 -0
- package/dist/types/src/controllers/client.d.ts +119 -7
- package/dist/types/src/controllers/collection_registry.d.ts +4 -3
- package/dist/types/src/controllers/customization_controller.d.ts +7 -1
- package/dist/types/src/controllers/data.d.ts +34 -7
- package/dist/types/src/controllers/data_driver.d.ts +20 -28
- package/dist/types/src/controllers/database_admin.d.ts +2 -2
- package/dist/types/src/controllers/email.d.ts +34 -0
- package/dist/types/src/controllers/index.d.ts +1 -0
- package/dist/types/src/controllers/local_config_persistence.d.ts +4 -4
- package/dist/types/src/controllers/navigation.d.ts +5 -5
- package/dist/types/src/controllers/registry.d.ts +6 -3
- package/dist/types/src/controllers/side_entity_controller.d.ts +7 -6
- package/dist/types/src/controllers/storage.d.ts +24 -26
- package/dist/types/src/rebase_context.d.ts +8 -4
- package/dist/types/src/types/backend.d.ts +4 -1
- package/dist/types/src/types/builders.d.ts +5 -4
- package/dist/types/src/types/chips.d.ts +1 -1
- package/dist/types/src/types/collections.d.ts +169 -125
- package/dist/types/src/types/cron.d.ts +102 -0
- package/dist/types/src/types/data_source.d.ts +1 -1
- package/dist/types/src/types/entity_actions.d.ts +8 -8
- package/dist/types/src/types/entity_callbacks.d.ts +15 -15
- package/dist/types/src/types/entity_link_builder.d.ts +1 -1
- package/dist/types/src/types/entity_overrides.d.ts +2 -1
- package/dist/types/src/types/entity_views.d.ts +8 -8
- package/dist/types/src/types/export_import.d.ts +3 -3
- package/dist/types/src/types/index.d.ts +1 -0
- package/dist/types/src/types/plugins.d.ts +72 -18
- package/dist/types/src/types/properties.d.ts +118 -33
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/slots.d.ts +30 -6
- package/dist/types/src/types/translations.d.ts +44 -0
- package/dist/types/src/types/user_management_delegate.d.ts +1 -0
- package/package.json +84 -84
- package/scratch.ts +6 -5
- package/src/api/ast-schema-editor.ts +11 -11
- package/src/api/collections_for_test/callbacks_test_collection.ts +5 -2
- package/src/api/errors.ts +38 -14
- package/src/api/graphql/graphql-schema-generator.ts +5 -3
- package/src/api/openapi-generator.ts +613 -58
- package/src/api/rest/api-generator.ts +17 -11
- package/src/api/rest/query-parser.ts +1 -1
- package/src/api/schema-editor-routes.ts +2 -0
- package/src/api/server.ts +8 -5
- package/src/api/types.ts +1 -1
- package/src/auth/admin-routes.ts +58 -17
- package/src/auth/apple-oauth.ts +130 -0
- package/src/auth/bitbucket-oauth.ts +82 -0
- package/src/auth/discord-oauth.ts +83 -0
- package/src/auth/facebook-oauth.ts +72 -0
- package/src/auth/github-oauth.ts +110 -0
- package/src/auth/gitlab-oauth.ts +70 -0
- package/src/auth/google-oauth.ts +34 -46
- package/src/auth/index.ts +15 -2
- package/src/auth/interfaces.ts +54 -7
- package/src/auth/jwt.ts +22 -5
- package/src/auth/linkedin-oauth.ts +81 -0
- package/src/auth/microsoft-oauth.ts +88 -0
- package/src/auth/middleware.ts +194 -45
- package/src/auth/password.ts +3 -1
- package/src/auth/rate-limiter.ts +4 -4
- package/src/auth/routes.ts +197 -139
- package/src/auth/slack-oauth.ts +71 -0
- package/src/auth/spotify-oauth.ts +67 -0
- package/src/auth/twitter-oauth.ts +120 -0
- package/src/collections/loader.ts +6 -6
- package/src/cron/cron-loader.ts +89 -0
- package/src/cron/cron-routes.test.ts +265 -0
- package/src/cron/cron-routes.ts +85 -0
- package/src/cron/cron-scheduler.test.ts +421 -0
- package/src/cron/cron-scheduler.ts +413 -0
- package/src/cron/cron-store.ts +163 -0
- package/src/cron/index.ts +6 -0
- package/src/db/interfaces.ts +2 -2
- package/src/email/index.ts +3 -2
- package/src/email/smtp-email-service.ts +5 -2
- package/src/email/templates.ts +93 -6
- package/src/email/types.ts +17 -24
- package/src/functions/function-loader.ts +34 -6
- package/src/functions/function-routes.ts +2 -2
- package/src/history/history-routes.ts +4 -3
- package/src/index.ts +10 -0
- package/src/init.ts +475 -57
- package/src/services/driver-registry.ts +1 -1
- package/src/singleton.test.ts +28 -0
- package/src/singleton.ts +70 -0
- package/src/storage/LocalStorageController.ts +50 -53
- package/src/storage/S3StorageController.ts +49 -46
- package/src/storage/index.ts +27 -16
- package/src/storage/routes.ts +76 -59
- package/src/storage/storage-registry.ts +5 -5
- package/src/storage/types.ts +44 -32
- package/src/types/index.ts +4 -4
- package/src/utils/dev-port.ts +176 -0
- package/src/utils/logger.ts +143 -0
- package/src/utils/logging.ts +5 -2
- package/src/utils/request-logger.ts +66 -0
- package/test/admin-routes.test.ts +114 -65
- package/test/api-generator.test.ts +73 -30
- package/test/ast-schema-editor.test.ts +5 -3
- package/test/auth-middleware-hono.test.ts +268 -33
- package/test/auth-routes.test.ts +325 -146
- package/test/driver-registry.test.ts +4 -2
- package/test/error-propagation.test.ts +226 -0
- package/test/errors.test.ts +12 -7
- package/test/jwt-security.test.ts +18 -9
- package/test/jwt.test.ts +26 -13
- package/test/middleware.test.ts +13 -8
- package/test/query-parser.test.ts +13 -8
- package/test/rate-limiter.test.ts +7 -7
- package/test/safe-compare.test.ts +66 -0
- package/test/singleton.test.ts +59 -0
- package/test/storage-local.test.ts +36 -43
- package/test/storage-registry.test.ts +38 -36
- package/test/storage-routes.test.ts +9 -5
- package/test/storage-s3.test.ts +51 -48
- package/test.ts +6 -0
- package/tsconfig.json +1 -1
- package/vite.config.ts +7 -5
- package/dist/index-BeMqpmfQ.js +0 -239
- package/dist/index-BeMqpmfQ.js.map +0 -1
- package/dist/index-bl4J3lNb.js +0 -55823
- package/dist/index-bl4J3lNb.js.map +0 -1
- package/vite.config.ts.timestamp-1775065397568-8a853255edf6e.mjs +0 -46
package/test/auth-routes.test.ts
CHANGED
|
@@ -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 {
|
|
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,
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
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),
|
|
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",
|
|
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,
|
|
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",
|
|
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
|
|
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",
|
|
182
|
-
|
|
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("
|
|
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",
|
|
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("
|
|
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",
|
|
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",
|
|
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,
|
|
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",
|
|
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",
|
|
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
|
|
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",
|
|
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("
|
|
288
|
+
it("blocks registration even on empty database when allowRegistration=false", async () => {
|
|
250
289
|
const app = createApp({ allowRegistration: false });
|
|
251
|
-
|
|
252
|
-
|
|
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",
|
|
256
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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
|
|
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(
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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",
|
|
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",
|
|
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",
|
|
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,
|
|
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",
|
|
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",
|
|
597
|
-
|
|
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",
|
|
609
|
-
|
|
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",
|
|
621
|
-
|
|
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,
|
|
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",
|
|
633
|
-
|
|
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",
|
|
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",
|
|
650
|
-
|
|
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",
|
|
769
|
-
|
|
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",
|
|
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
|
|
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);
|
|
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
|
});
|