@rebasepro/server-core 0.3.0 → 0.5.0
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/README.md +62 -25
- package/dist/common/src/collections/default-collections.d.ts +5 -8
- package/dist/common/src/data/query_builder.d.ts +6 -2
- package/dist/common/src/util/permissions.d.ts +14 -6
- package/dist/index.es.js +393 -315
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +393 -315
- package/dist/index.umd.js.map +1 -1
- package/dist/server-core/src/api/errors.d.ts +15 -0
- package/dist/server-core/src/api/rest/query-parser.d.ts +2 -0
- package/dist/server-core/src/api/types.d.ts +2 -1
- package/dist/server-core/src/auth/jwt.d.ts +10 -0
- package/dist/server-core/src/email/types.d.ts +1 -0
- package/dist/server-core/src/init.d.ts +31 -1
- package/dist/types/src/controllers/auth.d.ts +2 -2
- package/dist/types/src/controllers/client.d.ts +25 -40
- package/dist/types/src/controllers/data.d.ts +21 -3
- package/dist/types/src/controllers/data_driver.d.ts +5 -0
- package/dist/types/src/controllers/email.d.ts +2 -0
- package/dist/types/src/types/auth_adapter.d.ts +3 -56
- package/dist/types/src/types/backend.d.ts +38 -3
- package/dist/types/src/types/backend_hooks.d.ts +2 -17
- package/dist/types/src/types/collections.d.ts +30 -6
- package/dist/types/src/types/entity_views.d.ts +19 -28
- package/dist/types/src/types/properties.d.ts +9 -15
- package/dist/types/src/types/user_management_delegate.d.ts +16 -53
- package/dist/types/src/users/index.d.ts +0 -1
- package/dist/types/src/users/user.d.ts +0 -1
- package/package.json +5 -5
- package/src/api/errors.ts +20 -1
- package/src/api/openapi-generator.ts +1 -1
- package/src/api/rest/api-generator.ts +82 -24
- package/src/api/rest/query-parser.ts +184 -63
- package/src/api/server.ts +1 -1
- package/src/api/types.ts +2 -1
- package/src/auth/admin-routes.ts +2 -91
- package/src/auth/builtin-auth-adapter.ts +9 -70
- package/src/auth/custom-auth-adapter.ts +1 -1
- package/src/auth/jwt.ts +10 -0
- package/src/auth/routes.ts +5 -9
- package/src/email/smtp-email-service.ts +31 -0
- package/src/email/types.ts +1 -0
- package/src/init.ts +135 -31
- package/src/storage/image-transform.ts +2 -1
- package/test/admin-routes.test.ts +0 -169
- package/test/backend-hooks-admin.test.ts +0 -25
- package/test/custom-auth-adapter.test.ts +2 -10
- package/test/smtp-email-service.test.ts +169 -0
- package/dist/types/src/users/roles.d.ts +0 -14
- package/test.ts +0 -6
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { createTransport, Transporter } from "nodemailer";
|
|
2
2
|
import { EmailService, EmailSendOptions, EmailConfig } from "./types";
|
|
3
3
|
|
|
4
|
+
/**
|
|
5
|
+
* Safely parse a hostname from a URL string
|
|
6
|
+
*/
|
|
7
|
+
function getHostname(urlStr: string): string | undefined {
|
|
8
|
+
try {
|
|
9
|
+
const url = new URL(urlStr.includes("://") ? urlStr : `https://${urlStr}`);
|
|
10
|
+
return url.hostname;
|
|
11
|
+
} catch {
|
|
12
|
+
return undefined;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* SMTP Email Service implementation using Nodemailer
|
|
6
18
|
*/
|
|
@@ -12,7 +24,26 @@ export class SMTPEmailService implements EmailService {
|
|
|
12
24
|
this.config = config;
|
|
13
25
|
|
|
14
26
|
if (config.smtp) {
|
|
27
|
+
let smtpName = config.smtp.name;
|
|
28
|
+
if (!smtpName) {
|
|
29
|
+
const urlsToTry = [
|
|
30
|
+
process.env.FRONTEND_URL,
|
|
31
|
+
config.resetPasswordUrl,
|
|
32
|
+
config.verifyEmailUrl
|
|
33
|
+
];
|
|
34
|
+
for (const urlStr of urlsToTry) {
|
|
35
|
+
if (urlStr) {
|
|
36
|
+
const hostname = getHostname(urlStr);
|
|
37
|
+
if (hostname) {
|
|
38
|
+
smtpName = hostname;
|
|
39
|
+
break;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
15
45
|
this.transporter = createTransport({
|
|
46
|
+
name: smtpName,
|
|
16
47
|
host: config.smtp.host,
|
|
17
48
|
port: config.smtp.port,
|
|
18
49
|
secure: config.smtp.secure ?? (config.smtp.port === 465),
|
package/src/email/types.ts
CHANGED
package/src/init.ts
CHANGED
|
@@ -1,7 +1,21 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
AuthAdapter,
|
|
3
|
+
BackendBootstrapper,
|
|
4
|
+
BackendHooks,
|
|
5
|
+
BootstrappedAuth,
|
|
6
|
+
DatabaseAdapter,
|
|
7
|
+
DataDriver,
|
|
8
|
+
EntityCollection,
|
|
9
|
+
HealthCheckResult,
|
|
10
|
+
InitializedDriver,
|
|
11
|
+
isPostgresCollection,
|
|
12
|
+
isSQLAdmin,
|
|
13
|
+
RealtimeProvider,
|
|
14
|
+
SecurityRule
|
|
15
|
+
} from "@rebasepro/types";
|
|
2
16
|
import { BackendCollectionRegistry } from "./collections/BackendCollectionRegistry";
|
|
3
17
|
import { loadCollectionsFromDirectory } from "./collections/loader";
|
|
4
|
-
import {
|
|
18
|
+
import { DEFAULT_DRIVER_ID, DefaultDriverRegistry, DriverRegistry } from "./services/driver-registry";
|
|
5
19
|
import { Server } from "http";
|
|
6
20
|
|
|
7
21
|
import { RestApiGenerator } from "./api/rest/api-generator";
|
|
@@ -16,21 +30,46 @@ import { HonoEnv } from "./api/types";
|
|
|
16
30
|
import { configureLogLevel } from "./utils/logging";
|
|
17
31
|
import { logger } from "./utils/logger";
|
|
18
32
|
import { requestLogger } from "./utils/request-logger";
|
|
19
|
-
import {
|
|
20
|
-
import {
|
|
33
|
+
import { configureJwt, requireAdmin, requireAuth } from "./auth";
|
|
34
|
+
import {
|
|
35
|
+
BackendStorageConfig,
|
|
36
|
+
createStorageController,
|
|
37
|
+
createStorageRoutes,
|
|
38
|
+
DEFAULT_STORAGE_ID,
|
|
39
|
+
DefaultStorageRegistry,
|
|
40
|
+
StorageController,
|
|
41
|
+
StorageRegistry
|
|
42
|
+
} from "./storage";
|
|
43
|
+
import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
21
44
|
import { createApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
22
45
|
import { createApiKeyRoutes } from "./auth/api-keys/api-key-routes";
|
|
23
|
-
import type { ApiKeyStore } from "./auth/api-keys/api-key-store";
|
|
24
46
|
import { createApiKeyRateLimiter } from "./auth/rate-limiter";
|
|
25
47
|
import { createRebaseClient } from "@rebasepro/client";
|
|
48
|
+
|
|
26
49
|
import { createHistoryRoutes } from "./history";
|
|
27
|
-
import { EmailConfig, createEmailService } from "./email";
|
|
28
50
|
import type { EmailService } from "./email";
|
|
51
|
+
import { createEmailService, EmailConfig } from "./email";
|
|
29
52
|
import type { OAuthProvider } from "./auth/interfaces";
|
|
30
53
|
import type { AuthHooks } from "./auth/auth-hooks";
|
|
31
54
|
import { _initRebase } from "./singleton";
|
|
32
55
|
|
|
33
56
|
export interface RebaseAuthConfig {
|
|
57
|
+
/**
|
|
58
|
+
* The collection that represents auth users.
|
|
59
|
+
*
|
|
60
|
+
* When provided, this collection's underlying database table is used
|
|
61
|
+
* for all auth operations (login, registration, password reset, etc.).
|
|
62
|
+
*
|
|
63
|
+
* Import the built-in default:
|
|
64
|
+
* ```ts
|
|
65
|
+
* import { defaultUsersCollection } from "@rebasepro/common";
|
|
66
|
+
* auth: { collection: defaultUsersCollection, jwtSecret: "..." }
|
|
67
|
+
* ```
|
|
68
|
+
*
|
|
69
|
+
* Or pass your own collection with the required auth fields
|
|
70
|
+
* (email, passwordHash, displayName, etc.).
|
|
71
|
+
*/
|
|
72
|
+
collection?: EntityCollection;
|
|
34
73
|
jwtSecret?: string;
|
|
35
74
|
accessExpiresIn?: string;
|
|
36
75
|
refreshExpiresIn?: string;
|
|
@@ -83,6 +122,7 @@ export interface RebaseAuthConfig {
|
|
|
83
122
|
* ```
|
|
84
123
|
*/
|
|
85
124
|
hooks?: AuthHooks;
|
|
125
|
+
|
|
86
126
|
[key: string]: unknown;
|
|
87
127
|
}
|
|
88
128
|
|
|
@@ -135,6 +175,20 @@ export interface RebaseBackendConfig {
|
|
|
135
175
|
*/
|
|
136
176
|
storage?: BackendStorageConfig | StorageController | Record<string, BackendStorageConfig | StorageController>;
|
|
137
177
|
history?: unknown;
|
|
178
|
+
/**
|
|
179
|
+
* Default security rules applied to any collection that does not define
|
|
180
|
+
* its own `securityRules`. Opt-in — if not set, collections without
|
|
181
|
+
* explicit rules remain unrestricted (beyond `requireAuth`).
|
|
182
|
+
*
|
|
183
|
+
* @example
|
|
184
|
+
* ```ts
|
|
185
|
+
* defaultSecurityRules: [
|
|
186
|
+
* { operation: "select", access: "public" },
|
|
187
|
+
* { operations: ["insert", "update", "delete"], roles: ["admin"] }
|
|
188
|
+
* ]
|
|
189
|
+
* ```
|
|
190
|
+
*/
|
|
191
|
+
defaultSecurityRules?: SecurityRule[];
|
|
138
192
|
enableSwagger?: boolean;
|
|
139
193
|
functionsDir?: string;
|
|
140
194
|
cronsDir?: string;
|
|
@@ -218,11 +272,13 @@ export interface RebaseBackendInstance {
|
|
|
218
272
|
storageController?: StorageController;
|
|
219
273
|
collectionRegistry: BackendCollectionRegistry;
|
|
220
274
|
cronScheduler?: import("./cron").CronScheduler;
|
|
275
|
+
|
|
221
276
|
/**
|
|
222
277
|
* Deep health check that verifies database connectivity.
|
|
223
278
|
* Returns latency and component status.
|
|
224
279
|
*/
|
|
225
280
|
healthCheck(): Promise<HealthCheckResult>;
|
|
281
|
+
|
|
226
282
|
/**
|
|
227
283
|
* Graceful shutdown helper for the BaaS instance.
|
|
228
284
|
* Stops the cron scheduler and closes the HTTP server, allowing
|
|
@@ -288,8 +344,20 @@ async function _initializeRebaseBackend(config: RebaseBackendConfig): Promise<Re
|
|
|
288
344
|
let activeCollections = config.collections || [];
|
|
289
345
|
if (config.collectionsDir && activeCollections.length === 0) {
|
|
290
346
|
activeCollections = await loadCollectionsFromDirectory(config.collectionsDir);
|
|
291
|
-
logger.info("Auto-discovered collections", {
|
|
292
|
-
|
|
347
|
+
logger.info("Auto-discovered collections", {
|
|
348
|
+
count: activeCollections.length,
|
|
349
|
+
dir: config.collectionsDir
|
|
350
|
+
});
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Apply default security rules to collections that don't define their own
|
|
354
|
+
if (config.defaultSecurityRules?.length) {
|
|
355
|
+
for (const collection of activeCollections) {
|
|
356
|
+
if (isPostgresCollection(collection) && (!collection.securityRules || collection.securityRules.length === 0)) {
|
|
357
|
+
collection.securityRules = config.defaultSecurityRules;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
logger.info("Default security rules applied to collections without explicit rules");
|
|
293
361
|
}
|
|
294
362
|
|
|
295
363
|
const realtimeServices: Record<string, RealtimeProvider> = {};
|
|
@@ -327,14 +395,16 @@ dir: config.collectionsDir });
|
|
|
327
395
|
|
|
328
396
|
// 1. Initialize all drivers
|
|
329
397
|
for (const bootstrapper of bootstrappers) {
|
|
330
|
-
const b = bootstrapper
|
|
398
|
+
const b = bootstrapper;
|
|
331
399
|
logger.info("Running bootstrapper for driver", { driverId: b.id || bootstrapper.type });
|
|
332
400
|
if (b.isDefault) {
|
|
333
401
|
defaultDriverId = b.id || bootstrapper.type;
|
|
334
402
|
}
|
|
335
403
|
|
|
336
|
-
const driverResult = await bootstrapper.initializeDriver({
|
|
337
|
-
|
|
404
|
+
const driverResult = await bootstrapper.initializeDriver({
|
|
405
|
+
collections: activeCollections,
|
|
406
|
+
collectionRegistry
|
|
407
|
+
});
|
|
338
408
|
delegates[b.id || bootstrapper.type] = driverResult.driver;
|
|
339
409
|
|
|
340
410
|
if ((b.id || bootstrapper.type) === defaultDriverId || !defaultDriverResult) {
|
|
@@ -343,7 +413,9 @@ collectionRegistry });
|
|
|
343
413
|
|
|
344
414
|
if (bootstrapper.initializeRealtime) {
|
|
345
415
|
const realtime = await bootstrapper.initializeRealtime({}, driverResult);
|
|
346
|
-
|
|
416
|
+
if (realtime) {
|
|
417
|
+
realtimeServices[b.id || bootstrapper.type] = realtime;
|
|
418
|
+
}
|
|
347
419
|
}
|
|
348
420
|
}
|
|
349
421
|
|
|
@@ -354,7 +426,7 @@ collectionRegistry });
|
|
|
354
426
|
if (!defaultDriver || !defaultDriverResult) {
|
|
355
427
|
throw new Error("Default driver not initialized by bootstrappers");
|
|
356
428
|
}
|
|
357
|
-
const defaultBootstrapper = bootstrappers.find(b =>
|
|
429
|
+
const defaultBootstrapper = bootstrappers.find(b => b.id === defaultDriverId || b.type === defaultDriverId) || bootstrappers[0];
|
|
358
430
|
const defaultRealtimeService = defaultDriverResult.realtimeProvider;
|
|
359
431
|
|
|
360
432
|
// 2. Initialize Auth & History via the default driver's bootstrapper
|
|
@@ -378,7 +450,6 @@ collectionRegistry });
|
|
|
378
450
|
// (the return type still exposes `auth?: BootstrappedAuth`)
|
|
379
451
|
authConfigResult = {
|
|
380
452
|
userService: authAdapter.userManagement ?? {},
|
|
381
|
-
roleService: authAdapter.roleManagement ?? {},
|
|
382
453
|
};
|
|
383
454
|
} else {
|
|
384
455
|
// ── RebaseAuthConfig — wrap in built-in adapter ──
|
|
@@ -520,7 +591,10 @@ collectionRegistry });
|
|
|
520
591
|
|
|
521
592
|
if (safeAuthConfig.linkedin?.clientId && safeAuthConfig.linkedin?.clientSecret) {
|
|
522
593
|
const { createLinkedinProvider } = await import("./auth");
|
|
523
|
-
oauthProviders.push(createLinkedinProvider(safeAuthConfig.linkedin as {
|
|
594
|
+
oauthProviders.push(createLinkedinProvider(safeAuthConfig.linkedin as {
|
|
595
|
+
clientId: string;
|
|
596
|
+
clientSecret: string
|
|
597
|
+
}));
|
|
524
598
|
}
|
|
525
599
|
|
|
526
600
|
if (safeAuthConfig.github?.clientId && safeAuthConfig.github?.clientSecret) {
|
|
@@ -647,7 +721,7 @@ collectionRegistry });
|
|
|
647
721
|
// maxFileSize (default 50MB), which overrides the global API body limit.
|
|
648
722
|
const storageMaxSize = (
|
|
649
723
|
config.storage && typeof config.storage === "object" && "type" in config.storage
|
|
650
|
-
? (config.storage as BackendStorageConfig
|
|
724
|
+
? (config.storage as BackendStorageConfig).maxFileSize
|
|
651
725
|
: undefined
|
|
652
726
|
) ?? 50 * 1024 * 1024;
|
|
653
727
|
|
|
@@ -790,6 +864,29 @@ collectionRegistry });
|
|
|
790
864
|
if (emailService) {
|
|
791
865
|
Object.assign(serverClient, { email: emailService });
|
|
792
866
|
logger.info("Email service attached to singleton", { configured: emailService.isConfigured() });
|
|
867
|
+
|
|
868
|
+
if (emailService.isConfigured() && typeof emailService.verifyConnection === "function") {
|
|
869
|
+
emailService.verifyConnection().then((success) => {
|
|
870
|
+
if (!success) {
|
|
871
|
+
logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.");
|
|
872
|
+
} else {
|
|
873
|
+
logger.info("SMTP connection verified successfully.");
|
|
874
|
+
}
|
|
875
|
+
}).catch((err) => {
|
|
876
|
+
logger.warn("Warning: SMTP connection verification failed. Email delivery may fail.", { error: err });
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// Attach raw SQL capability when the driver supports it (Postgres, MySQL).
|
|
882
|
+
// Document databases (MongoDB, Firestore) won't have this.
|
|
883
|
+
const driverAdmin = defaultBootstrapper.getAdmin?.(defaultDriverResult);
|
|
884
|
+
if (isSQLAdmin(driverAdmin)) {
|
|
885
|
+
Object.assign(serverClient, {
|
|
886
|
+
sql: (query: string, options?: { database?: string; role?: string }) =>
|
|
887
|
+
driverAdmin.executeSql(query, options)
|
|
888
|
+
});
|
|
889
|
+
logger.info("SQL capability attached to singleton");
|
|
793
890
|
}
|
|
794
891
|
|
|
795
892
|
_initRebase(serverClient);
|
|
@@ -842,8 +939,10 @@ collectionRegistry });
|
|
|
842
939
|
const fnRoutes = createFunctionRoutes(loadedFunctions);
|
|
843
940
|
functionsRouter.route("/", fnRoutes);
|
|
844
941
|
config.app.route(`${basePath}/functions`, functionsRouter);
|
|
845
|
-
logger.info("Mounted custom functions", {
|
|
846
|
-
|
|
942
|
+
logger.info("Mounted custom functions", {
|
|
943
|
+
count: loadedFunctions.length,
|
|
944
|
+
path: `${basePath}/functions`
|
|
945
|
+
});
|
|
847
946
|
}
|
|
848
947
|
}
|
|
849
948
|
|
|
@@ -892,13 +991,15 @@ path: `${basePath}/functions` });
|
|
|
892
991
|
// Start the scheduler
|
|
893
992
|
cronScheduler.start();
|
|
894
993
|
|
|
895
|
-
logger.info("Mounted cron jobs", {
|
|
896
|
-
|
|
994
|
+
logger.info("Mounted cron jobs", {
|
|
995
|
+
count: loadedCronJobs.length,
|
|
996
|
+
path: `${basePath}/cron`
|
|
997
|
+
});
|
|
897
998
|
}
|
|
898
999
|
}
|
|
899
1000
|
|
|
900
|
-
if (
|
|
901
|
-
await
|
|
1001
|
+
if (defaultBootstrapper.initializeWebsockets && defaultRealtimeService) {
|
|
1002
|
+
await defaultBootstrapper.initializeWebsockets(config.server, defaultRealtimeService, defaultDriver, config.auth, authAdapter);
|
|
902
1003
|
}
|
|
903
1004
|
|
|
904
1005
|
logger.info("Rebase Backend Initialized");
|
|
@@ -913,12 +1014,16 @@ path: `${basePath}/cron` });
|
|
|
913
1014
|
await admin.executeSql("SELECT 1");
|
|
914
1015
|
} else {
|
|
915
1016
|
// Fallback: try a lightweight fetch to confirm driver is responsive
|
|
916
|
-
await defaultDriver.fetchCollection({
|
|
917
|
-
|
|
1017
|
+
await defaultDriver.fetchCollection({
|
|
1018
|
+
path: "__health_check_nonexistent__",
|
|
1019
|
+
limit: 1
|
|
1020
|
+
});
|
|
918
1021
|
}
|
|
919
1022
|
const latencyMs = Math.round(performance.now() - start);
|
|
920
|
-
return {
|
|
921
|
-
|
|
1023
|
+
return {
|
|
1024
|
+
healthy: true,
|
|
1025
|
+
latencyMs
|
|
1026
|
+
};
|
|
922
1027
|
} catch (error: unknown) {
|
|
923
1028
|
const latencyMs = Math.round(performance.now() - start);
|
|
924
1029
|
logger.error("Health check failed", {
|
|
@@ -952,12 +1057,11 @@ latencyMs };
|
|
|
952
1057
|
// timer callbacks don't fire against a closed pool.
|
|
953
1058
|
for (const [key, rt] of Object.entries(realtimeServices)) {
|
|
954
1059
|
try {
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
await rtWithLifecycle.destroy();
|
|
1060
|
+
if (typeof rt.destroy === "function") {
|
|
1061
|
+
await rt.destroy();
|
|
958
1062
|
logger.info(`Realtime service "${key}" destroyed`);
|
|
959
|
-
} else if (typeof
|
|
960
|
-
await
|
|
1063
|
+
} else if (typeof rt.stopListening === "function") {
|
|
1064
|
+
await rt.stopListening();
|
|
961
1065
|
logger.info(`Realtime service "${key}" LISTEN client stopped`);
|
|
962
1066
|
}
|
|
963
1067
|
} catch (err) {
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
* in-memory cache to avoid redundant processing.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
//
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
10
10
|
let sharpFactory: ((input: Buffer | Uint8Array) => any) | undefined;
|
|
11
11
|
|
|
12
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
12
13
|
async function getSharp(): Promise<(input: Buffer | Uint8Array) => any> {
|
|
13
14
|
if (!sharpFactory) {
|
|
14
15
|
try {
|
|
@@ -540,174 +540,5 @@ displayName: "Updated" }),
|
|
|
540
540
|
});
|
|
541
541
|
});
|
|
542
542
|
|
|
543
|
-
// ── Role CRUD ───────────────────────────────────────────────────────
|
|
544
|
-
describe("Role Management", () => {
|
|
545
|
-
describe("GET /admin/roles", () => {
|
|
546
|
-
it("returns list of roles", async () => {
|
|
547
|
-
const app = createApp();
|
|
548
|
-
mockAuthRepo.listRoles.mockResolvedValueOnce([
|
|
549
|
-
mockRole("admin", true),
|
|
550
|
-
mockRole("editor"),
|
|
551
|
-
mockRole("viewer")
|
|
552
|
-
]);
|
|
553
|
-
|
|
554
|
-
const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
|
|
555
|
-
expect(res.status).toBe(200);
|
|
556
|
-
const body = await res.json() as any;
|
|
557
|
-
expect(body.roles).toHaveLength(3);
|
|
558
|
-
expect(body.roles[0].isAdmin).toBe(true);
|
|
559
|
-
});
|
|
560
|
-
});
|
|
561
|
-
|
|
562
|
-
describe("GET /admin/roles/:roleId", () => {
|
|
563
|
-
it("returns role by ID", async () => {
|
|
564
|
-
const app = createApp();
|
|
565
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("admin", true));
|
|
566
|
-
|
|
567
|
-
const res = await app.request("/admin/roles/admin", { headers: { ...adminAuth() } });
|
|
568
|
-
expect(res.status).toBe(200);
|
|
569
|
-
const body = await res.json() as any;
|
|
570
|
-
expect(body.role.id).toBe("admin");
|
|
571
|
-
expect(body.role.isAdmin).toBe(true);
|
|
572
|
-
});
|
|
573
|
-
|
|
574
|
-
it("returns 404 for non-existent role", async () => {
|
|
575
|
-
const app = createApp();
|
|
576
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
|
|
577
|
-
|
|
578
|
-
const res = await app.request("/admin/roles/missing", { headers: { ...adminAuth() } });
|
|
579
|
-
expect(res.status).toBe(404);
|
|
580
|
-
});
|
|
581
|
-
});
|
|
582
|
-
|
|
583
|
-
describe("POST /admin/roles", () => {
|
|
584
|
-
it("creates a new role", async () => {
|
|
585
|
-
const app = createApp();
|
|
586
|
-
|
|
587
|
-
const res = await app.request("/admin/roles", {
|
|
588
|
-
...json({ id: "custom",
|
|
589
|
-
name: "Custom Role" }),
|
|
590
|
-
headers: { ...json({}).headers,
|
|
591
|
-
...adminAuth() }
|
|
592
|
-
});
|
|
593
|
-
expect(res.status).toBe(201);
|
|
594
|
-
const body = await res.json() as any;
|
|
595
|
-
expect(body.role.id).toBe("custom");
|
|
596
|
-
});
|
|
597
543
|
|
|
598
|
-
it("returns 400 for missing id or name", async () => {
|
|
599
|
-
const app = createApp();
|
|
600
|
-
|
|
601
|
-
const res = await app.request("/admin/roles", {
|
|
602
|
-
...json({ id: "nope" }),
|
|
603
|
-
headers: { ...json({}).headers,
|
|
604
|
-
...adminAuth() }
|
|
605
|
-
});
|
|
606
|
-
expect(res.status).toBe(400);
|
|
607
|
-
});
|
|
608
|
-
|
|
609
|
-
it("returns 409 when role already exists", async () => {
|
|
610
|
-
const app = createApp();
|
|
611
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
|
|
612
|
-
|
|
613
|
-
const res = await app.request("/admin/roles", {
|
|
614
|
-
...json({ id: "custom",
|
|
615
|
-
name: "Dup" }),
|
|
616
|
-
headers: { ...json({}).headers,
|
|
617
|
-
...adminAuth() }
|
|
618
|
-
});
|
|
619
|
-
expect(res.status).toBe(409);
|
|
620
|
-
const body = await res.json() as any;
|
|
621
|
-
expect(body.error.code).toBe("ROLE_EXISTS");
|
|
622
|
-
});
|
|
623
|
-
});
|
|
624
|
-
|
|
625
|
-
describe("PUT /admin/roles/:roleId", () => {
|
|
626
|
-
it("updates an existing role", async () => {
|
|
627
|
-
const app = createApp();
|
|
628
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("editor"));
|
|
629
|
-
|
|
630
|
-
const res = await app.request("/admin/roles/editor", {
|
|
631
|
-
method: "PUT",
|
|
632
|
-
headers: { "Content-Type": "application/json",
|
|
633
|
-
...adminAuth() },
|
|
634
|
-
body: JSON.stringify({ name: "Super Editor" })
|
|
635
|
-
});
|
|
636
|
-
expect(res.status).toBe(200);
|
|
637
|
-
expect(mockAuthRepo.updateRole).toHaveBeenCalledWith("editor", expect.objectContaining({
|
|
638
|
-
name: "Super Editor"
|
|
639
|
-
}));
|
|
640
|
-
});
|
|
641
|
-
|
|
642
|
-
it("returns 404 for non-existent role", async () => {
|
|
643
|
-
const app = createApp();
|
|
644
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
|
|
645
|
-
|
|
646
|
-
const res = await app.request("/admin/roles/missing", {
|
|
647
|
-
method: "PUT",
|
|
648
|
-
headers: { "Content-Type": "application/json",
|
|
649
|
-
...adminAuth() },
|
|
650
|
-
body: JSON.stringify({ name: "Nope" })
|
|
651
|
-
});
|
|
652
|
-
expect(res.status).toBe(404);
|
|
653
|
-
});
|
|
654
|
-
});
|
|
655
|
-
|
|
656
|
-
describe("DELETE /admin/roles/:roleId", () => {
|
|
657
|
-
it("deletes a custom role", async () => {
|
|
658
|
-
const app = createApp();
|
|
659
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(mockRole("custom"));
|
|
660
|
-
|
|
661
|
-
const res = await app.request("/admin/roles/custom", {
|
|
662
|
-
method: "DELETE",
|
|
663
|
-
headers: { ...adminAuth() }
|
|
664
|
-
});
|
|
665
|
-
expect(res.status).toBe(200);
|
|
666
|
-
expect(mockAuthRepo.deleteRole).toHaveBeenCalledWith("custom");
|
|
667
|
-
});
|
|
668
|
-
|
|
669
|
-
it("prevents deletion of built-in admin role", async () => {
|
|
670
|
-
const app = createApp();
|
|
671
|
-
|
|
672
|
-
const res = await app.request("/admin/roles/admin", {
|
|
673
|
-
method: "DELETE",
|
|
674
|
-
headers: { ...adminAuth() }
|
|
675
|
-
});
|
|
676
|
-
expect(res.status).toBe(400);
|
|
677
|
-
const body = await res.json() as any;
|
|
678
|
-
expect(body.error.code).toBe("BUILTIN_ROLE");
|
|
679
|
-
});
|
|
680
|
-
|
|
681
|
-
it("prevents deletion of built-in editor role", async () => {
|
|
682
|
-
const app = createApp();
|
|
683
|
-
|
|
684
|
-
const res = await app.request("/admin/roles/editor", {
|
|
685
|
-
method: "DELETE",
|
|
686
|
-
headers: { ...adminAuth() }
|
|
687
|
-
});
|
|
688
|
-
expect(res.status).toBe(400);
|
|
689
|
-
});
|
|
690
|
-
|
|
691
|
-
it("prevents deletion of built-in viewer role", async () => {
|
|
692
|
-
const app = createApp();
|
|
693
|
-
|
|
694
|
-
const res = await app.request("/admin/roles/viewer", {
|
|
695
|
-
method: "DELETE",
|
|
696
|
-
headers: { ...adminAuth() }
|
|
697
|
-
});
|
|
698
|
-
expect(res.status).toBe(400);
|
|
699
|
-
});
|
|
700
|
-
|
|
701
|
-
it("returns 404 for non-existent role", async () => {
|
|
702
|
-
const app = createApp();
|
|
703
|
-
mockAuthRepo.getRoleById.mockResolvedValueOnce(null);
|
|
704
|
-
|
|
705
|
-
const res = await app.request("/admin/roles/ghost", {
|
|
706
|
-
method: "DELETE",
|
|
707
|
-
headers: { ...adminAuth() }
|
|
708
|
-
});
|
|
709
|
-
expect(res.status).toBe(404);
|
|
710
|
-
});
|
|
711
|
-
});
|
|
712
|
-
});
|
|
713
544
|
});
|
|
@@ -362,32 +362,7 @@ describe("BackendHooks — Admin Routes", () => {
|
|
|
362
362
|
});
|
|
363
363
|
});
|
|
364
364
|
|
|
365
|
-
// ── roles.afterRead ─────────────────────────────────────────────────
|
|
366
|
-
describe("roles.afterRead", () => {
|
|
367
|
-
it("filters out roles from GET /admin/roles", async () => {
|
|
368
|
-
const hooks: BackendHooks = {
|
|
369
|
-
roles: {
|
|
370
|
-
afterRead(role) {
|
|
371
|
-
// Hide internal roles
|
|
372
|
-
if (role.id === "internal") return null;
|
|
373
|
-
return role;
|
|
374
|
-
}
|
|
375
|
-
}
|
|
376
|
-
};
|
|
377
|
-
const app = createApp(hooks);
|
|
378
|
-
mockAuthRepo.listRoles.mockResolvedValueOnce([
|
|
379
|
-
mockRole("admin", true),
|
|
380
|
-
mockRole("internal"),
|
|
381
|
-
mockRole("editor")
|
|
382
|
-
]);
|
|
383
365
|
|
|
384
|
-
const res = await app.request("/admin/roles", { headers: { ...adminAuth() } });
|
|
385
|
-
expect(res.status).toBe(200);
|
|
386
|
-
const body = await res.json() as any;
|
|
387
|
-
expect(body.roles).toHaveLength(2);
|
|
388
|
-
expect(body.roles.map((r: any) => r.id)).toEqual(["admin", "editor"]);
|
|
389
|
-
});
|
|
390
|
-
});
|
|
391
366
|
|
|
392
367
|
// ── no hooks (passthrough) ──────────────────────────────────────────
|
|
393
368
|
describe("no hooks configured", () => {
|
|
@@ -143,7 +143,7 @@ describe("createCustomAuthAdapter", () => {
|
|
|
143
143
|
expect(adapter.serviceKey).toBe("sk_live_123");
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
it("passes through userManagement
|
|
146
|
+
it("passes through userManagement when provided", () => {
|
|
147
147
|
const userMgmt = {
|
|
148
148
|
getUser: jest.fn(),
|
|
149
149
|
listUsers: jest.fn(),
|
|
@@ -151,27 +151,19 @@ describe("createCustomAuthAdapter", () => {
|
|
|
151
151
|
updateUser: jest.fn(),
|
|
152
152
|
deleteUser: jest.fn(),
|
|
153
153
|
};
|
|
154
|
-
const roleMgmt = {
|
|
155
|
-
listRoles: jest.fn(),
|
|
156
|
-
getUserRoles: jest.fn(),
|
|
157
|
-
setUserRoles: jest.fn(),
|
|
158
|
-
};
|
|
159
154
|
|
|
160
155
|
const adapter = createCustomAuthAdapter({
|
|
161
156
|
verifyRequest: async () => null,
|
|
162
157
|
userManagement: userMgmt as unknown as CustomAuthAdapterOptions["userManagement"],
|
|
163
|
-
roleManagement: roleMgmt as unknown as CustomAuthAdapterOptions["roleManagement"],
|
|
164
158
|
});
|
|
165
159
|
|
|
166
160
|
expect(adapter.userManagement).toBe(userMgmt);
|
|
167
|
-
expect(adapter.roleManagement).toBe(roleMgmt);
|
|
168
161
|
});
|
|
169
162
|
|
|
170
|
-
it("omits userManagement
|
|
163
|
+
it("omits userManagement when not provided", () => {
|
|
171
164
|
const adapter = createCustomAuthAdapter({
|
|
172
165
|
verifyRequest: async () => null,
|
|
173
166
|
});
|
|
174
167
|
expect(adapter.userManagement).toBeUndefined();
|
|
175
|
-
expect(adapter.roleManagement).toBeUndefined();
|
|
176
168
|
});
|
|
177
169
|
});
|