db-model-router 1.0.7 → 1.0.9

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 (47) hide show
  1. package/README.md +25 -4
  2. package/db-manager/.dbmanager.sqlite-shm +0 -0
  3. package/db-manager/.dbmanager.sqlite-wal +0 -0
  4. package/demo/.env.example +1 -0
  5. package/demo/app.js +2 -2
  6. package/demo/commons/db.js +0 -11
  7. package/demo/middleware/tenantIsolation.js +2 -0
  8. package/demo/package-lock.json +1224 -62
  9. package/demo/package.json +6 -3
  10. package/demo/routes/addresses/index.js +5 -1
  11. package/demo/routes/auth/index.js +1 -1
  12. package/demo/routes/carts/cart_items/index.js +5 -1
  13. package/demo/routes/carts/index.js +9 -1
  14. package/demo/routes/categories/index.js +5 -1
  15. package/demo/routes/coupons/index.js +5 -1
  16. package/demo/routes/index.js +1 -15
  17. package/demo/routes/orders/index.js +13 -1
  18. package/demo/routes/orders/order_items/index.js +5 -1
  19. package/demo/routes/orders/payments/index.js +5 -1
  20. package/demo/routes/orders/shipments/index.js +5 -1
  21. package/demo/routes/products/index.js +13 -1
  22. package/demo/routes/products/product_images/index.js +5 -1
  23. package/demo/routes/products/product_reviews/index.js +5 -1
  24. package/demo/routes/products/product_variants/index.js +5 -1
  25. package/demo/routes/roles/index.js +1 -1
  26. package/demo/routes/tenants/index.js +1 -1
  27. package/demo/routes/users/index.js +1 -1
  28. package/demo/routes/wishlists/index.js +5 -1
  29. package/demo/seeds/saas-seed.js +1 -1
  30. package/docs/dbmr-schema-spec.md +393 -0
  31. package/package.json +4 -2
  32. package/skill/SKILL.md +47 -4
  33. package/src/cli/commands/generate.js +45 -15
  34. package/src/cli/diff-engine.js +17 -5
  35. package/src/cli/generate-migration.js +207 -19
  36. package/src/cli/generate-route.js +156 -58
  37. package/src/cli/generate-saas-structure.js +8 -1
  38. package/src/cli/init/dependencies.js +5 -1
  39. package/src/cli/init/generators.js +4 -81
  40. package/src/cli/init.js +1 -2
  41. package/src/cli/saas/generate-saas-middleware.js +2 -0
  42. package/src/cli/saas/generate-saas-routes.js +3 -13
  43. package/src/cli/saas/generate-saas-tests.js +473 -0
  44. package/src/commons/route.js +6 -6
  45. /package/demo/migrations/{20260509170349_create_migrations_table.sql → 20260510193736_create_migrations_table.sql} +0 -0
  46. /package/demo/migrations/{20260509170349_create_saas_tables.sql → 20260510193737_create_saas_tables.sql} +0 -0
  47. /package/demo/migrations/{20260509170349_create_tables.sql → 20260510193737_create_tables.sql} +0 -0
@@ -172,6 +172,7 @@ function buildEnvContent(answers, mode, secrets) {
172
172
  lines.push("API_BASE_PATH=/api");
173
173
  lines.push("");
174
174
  lines.push("# Database");
175
+ lines.push(`DB_TYPE=${answers.database}`);
175
176
 
176
177
  const vars = DB_ENV_MAP[answers.database] || [];
177
178
  for (const v of vars) {
@@ -358,83 +359,6 @@ app.use(session({
358
359
  }));`;
359
360
  }
360
361
 
361
- /**
362
- * Generate the app.js file content.
363
- * @param {import('./types').InitAnswers} answers
364
- * @returns {string}
365
- */
366
- function generateAppJs(answers) {
367
- const frameworkPkg =
368
- answers.framework === "ultimate-express" ? "ultimate-express" : "express";
369
-
370
- // Imports
371
- let imports = `import express from "${frameworkPkg}";
372
- import { init, db } from "db-model-router";
373
- import session from "express-session";`;
374
-
375
- if (answers.session === "redis") {
376
- imports += `\nimport RedisStore from "connect-redis";
377
- import { Redis } from "ioredis";`;
378
- }
379
- if (answers.rateLimiting) {
380
- imports += `\nimport rateLimit from "express-rate-limit";`;
381
- }
382
- if (answers.helmet) {
383
- imports += `\nimport helmet from "helmet";`;
384
- }
385
- imports += `\nimport logger from "./middleware/logger.js";`;
386
-
387
- // Rate limiting block
388
- const rateLimitBlock = answers.rateLimiting
389
- ? `app.use(rateLimit({
390
- windowMs: 15 * 60 * 1000,
391
- max: 100,
392
- standardHeaders: true,
393
- legacyHeaders: false,
394
- }));`
395
- : "";
396
-
397
- const helmetBlock = answers.helmet ? `app.use(helmet());` : "";
398
-
399
- return `${imports}
400
- import "dotenv/config";
401
-
402
- // Initialize database adapter
403
- init("${answers.database}");
404
- ${dbConnectBlock(answers.database)}
405
-
406
- const app = express();
407
- const PORT = process.env.PORT || 3000;
408
-
409
- // Middleware
410
- app.use(express.json());
411
- app.use(express.urlencoded({ extended: true }));
412
- ${helmetBlock ? helmetBlock + "\n" : ""}${rateLimitBlock ? rateLimitBlock + "\n" : ""}${sessionBlock(answers)}
413
- app.use(logger);
414
-
415
- // Routes
416
- import routes from "#routes/index.js";
417
- app.use(process.env.API_BASE_PATH || "/api", routes);
418
-
419
- // Health check
420
- app.get("/health", (req, res) => {
421
- res.json({ status: "ok", timestamp: new Date().toISOString() });
422
- });
423
-
424
- // Error handler
425
- app.use((err, req, res, next) => {
426
- console.error(err.stack);
427
- res.status(500).json({ type: "danger", message: "Internal Server Error" });
428
- });
429
-
430
- app.listen(PORT, () => {
431
- console.log(\`Server running on port \${PORT}\`);
432
- });
433
-
434
- export default app;
435
- `;
436
- }
437
-
438
362
  // ---------------------------------------------------------------------------
439
363
  // Logger middleware generator
440
364
  // ---------------------------------------------------------------------------
@@ -1720,7 +1644,7 @@ export default db;
1720
1644
  * @param {string} [outputDir] - relative output directory for source files (e.g. "backend")
1721
1645
  * @returns {string}
1722
1646
  */
1723
- function generateAppJsV2(answers, outputDir) {
1647
+ function generateAppJs(answers, outputDir) {
1724
1648
  const frameworkPkg =
1725
1649
  answers.framework === "ultimate-express" ? "ultimate-express" : "express";
1726
1650
 
@@ -1735,7 +1659,7 @@ import "${commonsPrefix}/db.js";
1735
1659
  import configureSession from "${commonsPrefix}/session.js";
1736
1660
  import applySecurity from "${commonsPrefix}/security.js";
1737
1661
  import logger from "${middlewarePrefix}/logger.js";
1738
- import route from "${routePrefix}/index.js";
1662
+ import routes from "${routePrefix}/index.js";
1739
1663
  import { fileURLToPath } from 'node:url';
1740
1664
  import path from "path";
1741
1665
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
@@ -1756,7 +1680,7 @@ app.use(configureSession());
1756
1680
  app.use(logger);
1757
1681
 
1758
1682
  // Routes
1759
- app.use(route);
1683
+ app.use(process.env.API_BASE_PATH || "/api", routes);
1760
1684
 
1761
1685
  // Error handler
1762
1686
  app.use((err, req, res, next) => {
@@ -1777,7 +1701,6 @@ module.exports = {
1777
1701
  isSql,
1778
1702
  randomPassword,
1779
1703
  generateAppJs,
1780
- generateAppJsV2,
1781
1704
  generateEnvFile,
1782
1705
  generateEnvExample,
1783
1706
  generateLoggerMiddleware,
package/src/cli/init.js CHANGED
@@ -7,7 +7,6 @@ const { execSync } = require("child_process");
7
7
 
8
8
  const {
9
9
  generateAppJs,
10
- generateAppJsV2,
11
10
  generateEnvFile,
12
11
  generateEnvExample,
13
12
  generateLoggerMiddleware,
@@ -109,7 +108,7 @@ function generateFiles(answers, outputDir) {
109
108
 
110
109
  // Root-level files (always in cwd, not in outputDir)
111
110
  // app.js uses the v2 generator that links commons/route modules
112
- if (safeWriteFile("app.js", generateAppJsV2(answers, outputDir || "")))
111
+ if (safeWriteFile("app.js", generateAppJs(answers, outputDir || "")))
113
112
  files.push("app.js");
114
113
  if (safeWriteFile(".env", generateEnvFile(answers, secrets)))
115
114
  files.push(".env");
@@ -49,6 +49,8 @@ function generateTenantIsolationMiddleware() {
49
49
  function tenantIsolation(req, res, next) {
50
50
  const hasGlobal = req.session.permission.some((p) => p.scope === "global");
51
51
  if (!hasGlobal) {
52
+ if (!req.query) req.query = {};
53
+ if (!req.body) req.body = {};
52
54
  req.query.tenant_id = req.session.user.tenant_id;
53
55
  req.body.tenant_id = req.session.user.tenant_id;
54
56
  }
@@ -370,19 +370,16 @@ function generateRoutesIndex(tableNames, relationships, options) {
370
370
  code += `import saasPermissionsRoute from "#routes/roles/permissions/index.js";\n\n`;
371
371
 
372
372
  // --- dbmr schema-generated route imports (folder-based, skip SaaS-owned tables) ---
373
+ // Child routes are mounted inside their parent's index.js, not here
373
374
  const dbmrTables = tableNames.filter(
374
375
  (t) => !saasRouteModules.has(t) && !nestedChildren.has(t),
375
376
  );
376
- if (dbmrTables.length > 0 || relationships.length > 0) {
377
+ if (dbmrTables.length > 0) {
377
378
  code += `// Schema-generated routes\n`;
378
379
  }
379
380
  for (const table of dbmrTables) {
380
381
  code += `import ${safeVarName(table)}Route from "#routes/${table}/index.js";\n`;
381
382
  }
382
- for (const rel of relationships) {
383
- if (saasRouteModules.has(rel.child)) continue;
384
- code += `import ${safeVarName(rel.child)}ChildRoute from "#routes/${rel.parent}/${rel.child}/index.js";\n`;
385
- }
386
383
 
387
384
  if (options.includeDocs) {
388
385
  code += `import docsRoute from "#routes/docs.js";\n`;
@@ -403,14 +400,7 @@ function generateRoutesIndex(tableNames, relationships, options) {
403
400
  code += `router.use("/docs", docsRoute);\n`;
404
401
  }
405
402
 
406
- // --- Mount dbmr child routes before parent routes ---
407
- for (const rel of relationships) {
408
- if (saasRouteModules.has(rel.child)) continue;
409
- const childVar = safeVarName(rel.child);
410
- code += `router.use("/${rel.parent}/:${rel.foreignKey}/${rel.child}", ${childVar}ChildRoute);\n`;
411
- }
412
-
413
- // --- Mount dbmr top-level routes ---
403
+ // --- Mount dbmr top-level routes (children are inside parent's index.js) ---
414
404
  if (dbmrTables.length > 0) {
415
405
  code += `\n// Schema-generated routes\n`;
416
406
  }
@@ -0,0 +1,473 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * SaaS test generators.
5
+ *
6
+ * Generates test files for SaaS routes: auth (login/logout), users, tenants, roles, permissions.
7
+ * Tests use supertest with a mock session to bypass authentication middleware.
8
+ * Generated code uses ES6 module syntax.
9
+ */
10
+
11
+ /**
12
+ * Generate all SaaS test files.
13
+ *
14
+ * @returns {Array<{ relPath: string, content: string }>}
15
+ */
16
+ function generateSaasTests() {
17
+ return [
18
+ { relPath: "test/auth.test.js", content: generateAuthTest() },
19
+ { relPath: "test/users.test.js", content: generateUsersTest() },
20
+ { relPath: "test/tenants.test.js", content: generateTenantsTest() },
21
+ { relPath: "test/roles.test.js", content: generateRolesTest() },
22
+ { relPath: "test/permissions.test.js", content: generatePermissionsTest() },
23
+ ];
24
+ }
25
+
26
+ function generateAuthTest() {
27
+ return `import assert from "assert";
28
+ import express from "express";
29
+ import request from "supertest";
30
+
31
+ // Import the auth route
32
+ import authRoute from "#routes/auth/index.js";
33
+
34
+ function createApp() {
35
+ const app = express();
36
+ app.use(express.json());
37
+ // Mock session object on each request
38
+ app.use((req, res, next) => {
39
+ req.session = {};
40
+ next();
41
+ });
42
+ app.use("/auth", authRoute);
43
+ return app;
44
+ }
45
+
46
+ describe("Auth Routes", function () {
47
+ let app;
48
+
49
+ before(function () {
50
+ app = createApp();
51
+ });
52
+
53
+ describe("POST /auth/login", function () {
54
+ it("should return 401 when email is missing", async function () {
55
+ const res = await request(app)
56
+ .post("/auth/login")
57
+ .send({ password: "test123" });
58
+ assert.strictEqual(res.status, 401);
59
+ assert.ok(res.body.message);
60
+ });
61
+
62
+ it("should return 401 when password is missing", async function () {
63
+ const res = await request(app)
64
+ .post("/auth/login")
65
+ .send({ email: "admin@system.local" });
66
+ assert.strictEqual(res.status, 401);
67
+ assert.ok(res.body.message);
68
+ });
69
+
70
+ it("should return 401 with empty body", async function () {
71
+ const res = await request(app)
72
+ .post("/auth/login")
73
+ .send({});
74
+ assert.strictEqual(res.status, 401);
75
+ });
76
+
77
+ it("should return 401 for non-existent user", async function () {
78
+ const res = await request(app)
79
+ .post("/auth/login")
80
+ .send({ email: "nobody@example.com", password: "wrong" });
81
+ assert.ok([401, 500].includes(res.status));
82
+ });
83
+ });
84
+
85
+ describe("POST /auth/logout", function () {
86
+ it("should return 401 when not authenticated", async function () {
87
+ const res = await request(app)
88
+ .post("/auth/logout");
89
+ assert.strictEqual(res.status, 401);
90
+ });
91
+ });
92
+ });
93
+ `;
94
+ }
95
+
96
+ function generateUsersTest() {
97
+ return `import assert from "assert";
98
+ import express from "express";
99
+ import request from "supertest";
100
+
101
+ import usersRoute from "#routes/users/index.js";
102
+
103
+ /**
104
+ * Create a test app with a pre-populated session (bypasses real auth).
105
+ * Injects session data via middleware before the route.
106
+ */
107
+ function createApp(sessionData) {
108
+ const app = express();
109
+ app.use(express.json());
110
+ // Mock session injection (no real session store needed for testing)
111
+ app.use((req, res, next) => {
112
+ req.session = {
113
+ user: sessionData.user,
114
+ role: sessionData.role,
115
+ permission: sessionData.permission,
116
+ };
117
+ next();
118
+ });
119
+ app.use("/users", usersRoute);
120
+ return app;
121
+ }
122
+
123
+ const mockSession = {
124
+ user: { user_id: 1, email: "admin@system.local", name: "Admin", tenant_id: null },
125
+ role: { role_id: 1, name: "Super Admin", tenant_id: null },
126
+ permission: [
127
+ { module: "users", action: "global", scope: "global" },
128
+ ],
129
+ };
130
+
131
+ describe("Users Routes (SaaS)", function () {
132
+ let app;
133
+
134
+ before(function () {
135
+ app = createApp(mockSession);
136
+ });
137
+
138
+ describe("GET /users/", function () {
139
+ it("should list users", async function () {
140
+ const res = await request(app).get("/users/");
141
+ assert.ok([200, 500].includes(res.status));
142
+ });
143
+ });
144
+
145
+ describe("POST /users/", function () {
146
+ it("should create a user", async function () {
147
+ const res = await request(app)
148
+ .post("/users/")
149
+ .send({
150
+ email: "test@example.com",
151
+ name: "Test User",
152
+ password_hash: "hashed",
153
+ unique_attribute: "test-unique",
154
+ role_id: 1,
155
+ });
156
+ assert.ok([200, 201, 400, 500].includes(res.status));
157
+ });
158
+ });
159
+
160
+ describe("PUT /users/:id", function () {
161
+ it("should update a user", async function () {
162
+ const res = await request(app)
163
+ .put("/users/1")
164
+ .send({ name: "Updated" });
165
+ assert.ok([200, 400, 404, 500].includes(res.status));
166
+ });
167
+ });
168
+
169
+ describe("DELETE /users/:id", function () {
170
+ it("should delete a user", async function () {
171
+ const res = await request(app).delete("/users/1");
172
+ assert.ok([200, 204, 404, 500].includes(res.status));
173
+ });
174
+ });
175
+
176
+ describe("Permission enforcement", function () {
177
+ it("should return 403 without proper permissions", async function () {
178
+ const noPermApp = createApp({
179
+ user: { user_id: 2, email: "user@test.com", name: "User", tenant_id: 1 },
180
+ role: { role_id: 2, name: "Viewer", tenant_id: 1 },
181
+ permission: [{ module: "tenants", action: "read", scope: "tenant" }],
182
+ });
183
+ const res = await request(noPermApp).get("/users/");
184
+ assert.strictEqual(res.status, 403);
185
+ });
186
+ });
187
+ });
188
+ `;
189
+ }
190
+
191
+ function generateTenantsTest() {
192
+ return `import assert from "assert";
193
+ import express from "express";
194
+ import request from "supertest";
195
+
196
+ import tenantsRoute from "#routes/tenants/index.js";
197
+
198
+ function createApp(sessionData) {
199
+ const app = express();
200
+ app.use(express.json());
201
+ app.use((req, res, next) => {
202
+ req.session = {
203
+ user: sessionData.user,
204
+ role: sessionData.role,
205
+ permission: sessionData.permission,
206
+ };
207
+ next();
208
+ });
209
+ app.use("/tenants", tenantsRoute);
210
+ return app;
211
+ }
212
+
213
+ const mockSession = {
214
+ user: { user_id: 1, email: "admin@system.local", name: "Admin", tenant_id: null },
215
+ role: { role_id: 1, name: "Super Admin", tenant_id: null },
216
+ permission: [
217
+ { module: "tenants", action: "global", scope: "global" },
218
+ ],
219
+ };
220
+
221
+ describe("Tenants Routes (SaaS)", function () {
222
+ let app;
223
+
224
+ before(function () {
225
+ app = createApp(mockSession);
226
+ });
227
+
228
+ describe("GET /tenants/", function () {
229
+ it("should list tenants", async function () {
230
+ const res = await request(app).get("/tenants/");
231
+ assert.ok([200, 500].includes(res.status));
232
+ });
233
+ });
234
+
235
+ describe("POST /tenants/", function () {
236
+ it("should create a tenant", async function () {
237
+ const res = await request(app)
238
+ .post("/tenants/")
239
+ .send({ name: "Acme Corp", slug: "acme-corp" });
240
+ assert.ok([200, 201, 400, 500].includes(res.status));
241
+ });
242
+ });
243
+
244
+ describe("PUT /tenants/:id", function () {
245
+ it("should update a tenant", async function () {
246
+ const res = await request(app)
247
+ .put("/tenants/1")
248
+ .send({ name: "Acme Updated" });
249
+ assert.ok([200, 400, 404, 500].includes(res.status));
250
+ });
251
+ });
252
+
253
+ describe("DELETE /tenants/:id", function () {
254
+ it("should delete a tenant", async function () {
255
+ const res = await request(app).delete("/tenants/1");
256
+ assert.ok([200, 204, 404, 500].includes(res.status));
257
+ });
258
+ });
259
+
260
+ describe("Permission enforcement", function () {
261
+ it("should return 403 without tenants permission", async function () {
262
+ const noPermApp = createApp({
263
+ user: { user_id: 2, email: "user@test.com", name: "User", tenant_id: 1 },
264
+ role: { role_id: 2, name: "Viewer", tenant_id: 1 },
265
+ permission: [{ module: "users", action: "read", scope: "tenant" }],
266
+ });
267
+ const res = await request(noPermApp).get("/tenants/");
268
+ assert.strictEqual(res.status, 403);
269
+ });
270
+ });
271
+ });
272
+ `;
273
+ }
274
+
275
+ function generateRolesTest() {
276
+ return `import assert from "assert";
277
+ import express from "express";
278
+ import request from "supertest";
279
+
280
+ import rolesRoute from "#routes/roles/index.js";
281
+
282
+ function createApp(sessionData) {
283
+ const app = express();
284
+ app.use(express.json());
285
+ app.use((req, res, next) => {
286
+ req.session = {
287
+ user: sessionData.user,
288
+ role: sessionData.role,
289
+ permission: sessionData.permission,
290
+ };
291
+ next();
292
+ });
293
+ app.use("/roles", rolesRoute);
294
+ return app;
295
+ }
296
+
297
+ const globalSession = {
298
+ user: { user_id: 1, email: "admin@system.local", name: "Admin", tenant_id: null },
299
+ role: { role_id: 1, name: "Super Admin", tenant_id: null },
300
+ permission: [
301
+ { module: "roles", action: "global", scope: "global" },
302
+ ],
303
+ };
304
+
305
+ const tenantSession = {
306
+ user: { user_id: 2, email: "tenant@test.com", name: "Tenant Admin", tenant_id: 1 },
307
+ role: { role_id: 2, name: "Tenant Admin", tenant_id: 1 },
308
+ permission: [
309
+ { module: "roles", action: "read", scope: "tenant" },
310
+ { module: "roles", action: "write", scope: "tenant" },
311
+ { module: "roles", action: "update", scope: "tenant" },
312
+ { module: "roles", action: "delete", scope: "tenant" },
313
+ ],
314
+ };
315
+
316
+ describe("Roles Routes (SaaS)", function () {
317
+ describe("with global permissions", function () {
318
+ let app;
319
+
320
+ before(function () {
321
+ app = createApp(globalSession);
322
+ });
323
+
324
+ describe("GET /roles/", function () {
325
+ it("should list roles", async function () {
326
+ const res = await request(app).get("/roles/");
327
+ assert.ok([200, 500].includes(res.status));
328
+ });
329
+ });
330
+
331
+ describe("POST /roles/", function () {
332
+ it("should create a role", async function () {
333
+ const res = await request(app)
334
+ .post("/roles/")
335
+ .send({ name: "Editor", tenant_id: 1 });
336
+ assert.ok([200, 201, 400, 500].includes(res.status));
337
+ });
338
+
339
+ it("should allow creating role with global permissions for global user", async function () {
340
+ const res = await request(app)
341
+ .post("/roles/")
342
+ .send({
343
+ name: "Global Role",
344
+ tenant_id: null,
345
+ permissions: [{ module: "users", action: "read", scope: "global" }],
346
+ });
347
+ assert.ok([200, 201, 400, 500].includes(res.status));
348
+ // Should NOT be 403 for global user
349
+ assert.notStrictEqual(res.status, 403);
350
+ });
351
+ });
352
+ });
353
+
354
+ describe("with tenant permissions", function () {
355
+ let app;
356
+
357
+ before(function () {
358
+ app = createApp(tenantSession);
359
+ });
360
+
361
+ describe("POST /roles/", function () {
362
+ it("should return 403 when creating role with global permissions", async function () {
363
+ const res = await request(app)
364
+ .post("/roles/")
365
+ .send({
366
+ name: "Escalated Role",
367
+ permissions: [{ module: "users", action: "read", scope: "global" }],
368
+ });
369
+ assert.strictEqual(res.status, 403);
370
+ });
371
+ });
372
+ });
373
+
374
+ describe("Permission enforcement", function () {
375
+ it("should return 403 without roles permission", async function () {
376
+ const noPermApp = createApp({
377
+ user: { user_id: 3, email: "noperm@test.com", name: "No Perm", tenant_id: 1 },
378
+ role: { role_id: 3, name: "None", tenant_id: 1 },
379
+ permission: [{ module: "tenants", action: "read", scope: "tenant" }],
380
+ });
381
+ const res = await request(noPermApp).get("/roles/");
382
+ assert.strictEqual(res.status, 403);
383
+ });
384
+ });
385
+ });
386
+ `;
387
+ }
388
+
389
+ function generatePermissionsTest() {
390
+ return `import assert from "assert";
391
+ import express from "express";
392
+ import request from "supertest";
393
+
394
+ import permissionsRoute from "#routes/roles/permissions/index.js";
395
+
396
+ function createApp(sessionData) {
397
+ const app = express();
398
+ app.use(express.json());
399
+ app.use((req, res, next) => {
400
+ req.session = {
401
+ user: sessionData.user,
402
+ role: sessionData.role,
403
+ permission: sessionData.permission,
404
+ };
405
+ next();
406
+ });
407
+ app.use("/roles/:role_id/permissions", permissionsRoute);
408
+ return app;
409
+ }
410
+
411
+ const mockSession = {
412
+ user: { user_id: 1, email: "admin@system.local", name: "Admin", tenant_id: null },
413
+ role: { role_id: 1, name: "Super Admin", tenant_id: null },
414
+ permission: [
415
+ { module: "permissions", action: "global", scope: "global" },
416
+ ],
417
+ };
418
+
419
+ describe("Permissions Routes (SaaS)", function () {
420
+ let app;
421
+
422
+ before(function () {
423
+ app = createApp(mockSession);
424
+ });
425
+
426
+ describe("GET /roles/:role_id/permissions/", function () {
427
+ it("should list permissions for a role", async function () {
428
+ const res = await request(app).get("/roles/1/permissions/");
429
+ assert.ok([200, 500].includes(res.status));
430
+ });
431
+ });
432
+
433
+ describe("POST /roles/:role_id/permissions/", function () {
434
+ it("should create a permission entry", async function () {
435
+ const res = await request(app)
436
+ .post("/roles/1/permissions/")
437
+ .send({ permission: { module: "users", action: "read", scope: "tenant" } });
438
+ assert.ok([200, 201, 400, 500].includes(res.status));
439
+ });
440
+ });
441
+
442
+ describe("PUT /roles/:role_id/permissions/:permission_id", function () {
443
+ it("should update a permission entry", async function () {
444
+ const res = await request(app)
445
+ .put("/roles/1/permissions/1")
446
+ .send({ permission: { module: "users", action: "write", scope: "tenant" } });
447
+ assert.ok([200, 400, 404, 500].includes(res.status));
448
+ });
449
+ });
450
+
451
+ describe("DELETE /roles/:role_id/permissions/:permission_id", function () {
452
+ it("should delete a permission entry", async function () {
453
+ const res = await request(app).delete("/roles/1/permissions/1");
454
+ assert.ok([200, 204, 404, 500].includes(res.status));
455
+ });
456
+ });
457
+
458
+ describe("Permission enforcement", function () {
459
+ it("should return 403 without permissions module access", async function () {
460
+ const noPermApp = createApp({
461
+ user: { user_id: 2, email: "user@test.com", name: "User", tenant_id: 1 },
462
+ role: { role_id: 2, name: "Viewer", tenant_id: 1 },
463
+ permission: [{ module: "users", action: "read", scope: "tenant" }],
464
+ });
465
+ const res = await request(noPermApp).get("/roles/1/permissions/");
466
+ assert.strictEqual(res.status, 403);
467
+ });
468
+ });
469
+ });
470
+ `;
471
+ }
472
+
473
+ module.exports = { generateSaasTests };