db-model-router 1.0.6 → 1.0.8
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 +150 -11
- package/TODO.md +0 -15
- package/db-manager/.dbmanager.sqlite +0 -0
- package/db-manager/README.md +223 -0
- package/db-manager/adapter-proxy.js +361 -0
- package/db-manager/demo/cockroachdb.env +6 -0
- package/db-manager/demo/demo.sqlite +0 -0
- package/db-manager/demo/dynamodb.env +7 -0
- package/db-manager/demo/mongodb.env +4 -0
- package/db-manager/demo/mssql.env +6 -0
- package/db-manager/demo/mysql.env +6 -0
- package/db-manager/demo/oracle.env +6 -0
- package/db-manager/demo/postgres.env +6 -0
- package/db-manager/demo/redis.env +4 -0
- package/db-manager/demo/seeds/cockroachdb.sql +32 -0
- package/db-manager/demo/seeds/mssql.sql +32 -0
- package/db-manager/demo/seeds/mysql.sql +32 -0
- package/db-manager/demo/seeds/oracle.sql +43 -0
- package/db-manager/demo/seeds/postgres.sql +32 -0
- package/db-manager/demo/seeds/sqlite3.sql +32 -0
- package/db-manager/demo/sqlite3.env +2 -0
- package/db-manager/metadata-db.js +170 -0
- package/db-manager/public/.gitkeep +1 -0
- package/db-manager/public/css/style.css +1413 -0
- package/db-manager/public/js/app.js +1370 -0
- package/db-manager/routes/api.js +388 -0
- package/db-manager/routes/views.js +61 -0
- package/db-manager/server.js +39 -0
- package/db-manager/utils/build-filter-config.js +18 -0
- package/db-manager/utils/csv-export.js +59 -0
- package/db-manager/utils/export-filename.js +39 -0
- package/db-manager/utils/filter-tables.js +20 -0
- package/db-manager/utils/parse-filters.js +93 -0
- package/db-manager/utils/sort-state.js +35 -0
- package/db-manager/views/.gitkeep +1 -0
- package/db-manager/views/dashboard.ejs +53 -0
- package/db-manager/views/history.ejs +52 -0
- package/db-manager/views/index.ejs +35 -0
- package/db-manager/views/layout.ejs +31 -0
- package/db-manager/views/partials/data-panel.ejs +74 -0
- package/db-manager/views/partials/header.ejs +36 -0
- package/db-manager/views/partials/sidebar.ejs +30 -0
- package/db-manager/views/query.ejs +58 -0
- package/dbmr.schema.json +22 -44
- package/demo/.dockerignore +7 -0
- package/demo/.env.example +15 -0
- package/demo/Dockerfile +20 -0
- package/demo/app.js +39 -0
- package/demo/commons/add_migration.js +43 -0
- package/demo/commons/db.js +17 -0
- package/demo/commons/migrate.js +68 -0
- package/demo/commons/modules.js +18 -0
- package/demo/commons/password.js +36 -0
- package/demo/commons/security.js +30 -0
- package/demo/commons/session.js +13 -0
- package/demo/commons/webhook.js +81 -0
- package/demo/dbmr.schema.json +338 -0
- package/demo/middleware/authenticate.js +14 -0
- package/demo/middleware/hasPermission.js +30 -0
- package/demo/middleware/logger.js +67 -0
- package/demo/middleware/tenantIsolation.js +19 -0
- package/demo/migrations/20260510092158_create_migrations_table.sql +6 -0
- package/demo/migrations/20260510092159_create_saas_tables.sql +69 -0
- package/demo/migrations/20260510092159_create_tables.sql +193 -0
- package/demo/models/addresses.js +24 -0
- package/demo/models/cart_items.js +20 -0
- package/demo/models/carts.js +18 -0
- package/demo/models/categories.js +22 -0
- package/demo/models/coupons.js +25 -0
- package/demo/models/index.js +43 -0
- package/demo/models/order_items.js +23 -0
- package/demo/models/orders.js +27 -0
- package/demo/models/payments.js +23 -0
- package/demo/models/product_images.js +20 -0
- package/demo/models/product_reviews.js +22 -0
- package/demo/models/product_variants.js +22 -0
- package/demo/models/products.js +32 -0
- package/demo/models/role_permissions.js +17 -0
- package/demo/models/roles.js +17 -0
- package/demo/models/shipments.js +21 -0
- package/demo/models/tenants.js +18 -0
- package/demo/models/users.js +23 -0
- package/demo/models/webhook_logs.js +22 -0
- package/demo/models/webhooks.js +19 -0
- package/demo/models/wishlists.js +17 -0
- package/demo/openapi.json +7000 -0
- package/demo/package-lock.json +2827 -0
- package/demo/package.json +42 -0
- package/demo/routes/addresses/index.js +10 -0
- package/demo/routes/auth/index.js +55 -0
- package/demo/routes/carts/cart_items/index.js +11 -0
- package/demo/routes/carts/index.js +14 -0
- package/demo/routes/categories/index.js +10 -0
- package/demo/routes/coupons/index.js +10 -0
- package/demo/routes/docs.js +18 -0
- package/demo/routes/health.js +35 -0
- package/demo/routes/index.js +54 -0
- package/demo/routes/orders/index.js +18 -0
- package/demo/routes/orders/order_items/index.js +11 -0
- package/demo/routes/orders/payments/index.js +11 -0
- package/demo/routes/orders/shipments/index.js +11 -0
- package/demo/routes/products/index.js +18 -0
- package/demo/routes/products/product_images/index.js +11 -0
- package/demo/routes/products/product_reviews/index.js +11 -0
- package/demo/routes/products/product_variants/index.js +11 -0
- package/demo/routes/roles/index.js +75 -0
- package/demo/routes/roles/permissions/index.js +47 -0
- package/demo/routes/tenants/index.js +45 -0
- package/demo/routes/users/index.js +45 -0
- package/demo/routes/wishlists/index.js +10 -0
- package/demo/seeds/saas-seed.js +329 -0
- package/docker-compose.yml +61 -0
- package/package.json +120 -113
- package/scripts/demo-create.js +1 -1
- package/skill/SKILL.md +119 -3
- package/src/cli/commands/db-manager.js +134 -0
- package/src/cli/commands/generate.js +106 -60
- package/src/cli/commands/help.js +0 -1
- package/src/cli/generate-route.js +66 -27
- package/src/cli/generate-saas-structure.js +129 -0
- package/src/cli/init/dependencies.js +1 -1
- package/src/cli/init/generators.js +6 -77
- package/src/cli/init.js +9 -2
- package/src/cli/main.js +8 -1
- package/src/cli/saas/generate-saas-middleware.js +110 -0
- package/src/cli/saas/generate-saas-migrations.js +480 -0
- package/src/cli/saas/generate-saas-models.js +211 -0
- package/src/cli/saas/generate-saas-openapi.js +419 -0
- package/src/cli/saas/generate-saas-routes.js +435 -0
- package/src/cli/saas/generate-saas-seeds.js +243 -0
- package/src/cli/saas/generate-saas-tests.js +473 -0
- package/src/cli/saas/generate-saas-utils.js +176 -0
- package/src/commons/kafka.js +139 -0
- package/src/commons/model.js +29 -9
- package/src/commons/route.js +6 -6
- package/src/index.js +2 -0
- package/src/mssql/db.js +41 -3
- package/src/mysql/db.js +3 -0
- package/src/postgres/db.js +6 -0
- package/src/cli/generate-db-manager.js +0 -1573
|
@@ -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 };
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* SaaS utility generators.
|
|
5
|
+
*
|
|
6
|
+
* Each function returns the file content string for a commons/ utility module.
|
|
7
|
+
* Generated code uses ES6 module syntax (import/export).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Generate the content for `commons/password.js`.
|
|
12
|
+
*
|
|
13
|
+
* @returns {string} File content for commons/password.js
|
|
14
|
+
*/
|
|
15
|
+
function generatePasswordUtil() {
|
|
16
|
+
return `import crypto from "crypto";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Hash a password using scrypt with a random salt.
|
|
20
|
+
* Returns a string in the format "salt:derivedKey" (both hex-encoded).
|
|
21
|
+
*
|
|
22
|
+
* @param {string} password - The plaintext password to hash
|
|
23
|
+
* @returns {Promise<string>} The hashed password string
|
|
24
|
+
*/
|
|
25
|
+
export function hashPassword(password) {
|
|
26
|
+
const salt = crypto.randomBytes(16).toString("hex");
|
|
27
|
+
return new Promise((resolve, reject) => {
|
|
28
|
+
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
|
29
|
+
if (err) reject(err);
|
|
30
|
+
resolve(salt + ":" + derivedKey.toString("hex"));
|
|
31
|
+
});
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Verify a password against a previously hashed value.
|
|
37
|
+
* Uses timing-safe comparison to prevent timing attacks.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} password - The plaintext password to verify
|
|
40
|
+
* @param {string} hash - The stored hash in "salt:derivedKey" format
|
|
41
|
+
* @returns {Promise<boolean>} True if the password matches, false otherwise
|
|
42
|
+
*/
|
|
43
|
+
export function verifyPassword(password, hash) {
|
|
44
|
+
const [salt, key] = hash.split(":");
|
|
45
|
+
return new Promise((resolve, reject) => {
|
|
46
|
+
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
|
|
47
|
+
if (err) reject(err);
|
|
48
|
+
resolve(crypto.timingSafeEqual(Buffer.from(key, "hex"), derivedKey));
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Generate the content for `commons/modules.js`.
|
|
57
|
+
*
|
|
58
|
+
* @returns {string} File content for commons/modules.js
|
|
59
|
+
*/
|
|
60
|
+
function generateModulesUtil() {
|
|
61
|
+
return `/**
|
|
62
|
+
* Registry of all SaaS module names.
|
|
63
|
+
* This is the single source of truth for valid module identifiers
|
|
64
|
+
* used by the permission system.
|
|
65
|
+
*
|
|
66
|
+
* @type {string[]}
|
|
67
|
+
*/
|
|
68
|
+
export const modules = ["users", "tenants", "roles", "permissions", "webhooks"];
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Check whether a given name is a registered module.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} name - The module name to validate
|
|
74
|
+
* @returns {boolean} True if the name exists in the modules registry
|
|
75
|
+
*/
|
|
76
|
+
export function isValidModule(name) {
|
|
77
|
+
return modules.includes(name);
|
|
78
|
+
}
|
|
79
|
+
`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Generate the content for `commons/webhook.js`.
|
|
84
|
+
*
|
|
85
|
+
* @returns {string} File content for commons/webhook.js
|
|
86
|
+
*/
|
|
87
|
+
function generateWebhookUtil() {
|
|
88
|
+
return `import crypto from "crypto";
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Retry delay schedule in seconds.
|
|
92
|
+
* Attempt 0: immediate, 1: 1 min, 2: 5 min, 3: 1 hour, 4: 1 day.
|
|
93
|
+
*
|
|
94
|
+
* @type {number[]}
|
|
95
|
+
*/
|
|
96
|
+
export const RETRY_DELAYS = [0, 60, 300, 3600, 86400];
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Sign a webhook payload using HMAC-SHA256.
|
|
100
|
+
*
|
|
101
|
+
* @param {object} payload - The payload object to sign
|
|
102
|
+
* @param {string} secret - The tenant's webhook secret
|
|
103
|
+
* @returns {string} Hex-encoded HMAC-SHA256 signature
|
|
104
|
+
*/
|
|
105
|
+
export function signPayload(payload, secret) {
|
|
106
|
+
const body = JSON.stringify(payload);
|
|
107
|
+
return crypto.createHmac("sha256", secret).update(body).digest("hex");
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Look up the configured webhook for a tenant.
|
|
112
|
+
* TODO: Replace this stub with actual database lookup.
|
|
113
|
+
*/
|
|
114
|
+
export async function lookupWebhook(tenantId) {
|
|
115
|
+
return null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Log a webhook delivery event.
|
|
120
|
+
* TODO: Replace this stub with actual database insert into webhook_logs.
|
|
121
|
+
*/
|
|
122
|
+
export async function logWebhookEvent(webhookId, tenantId, eventType, payload, status, responseBody, responseStatusCode) {
|
|
123
|
+
// Stub: replace with actual DB insert
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Delay execution for the specified number of milliseconds.
|
|
128
|
+
*/
|
|
129
|
+
export function delay(ms) {
|
|
130
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Send a webhook notification to the configured endpoint for a tenant.
|
|
135
|
+
* Retries delivery up to 5 times with exponential backoff.
|
|
136
|
+
*/
|
|
137
|
+
export async function sendWebhook(tenantId, event, context) {
|
|
138
|
+
const webhook = await lookupWebhook(tenantId);
|
|
139
|
+
if (!webhook) return;
|
|
140
|
+
|
|
141
|
+
const payload = { context, event, timestamp: new Date().toISOString() };
|
|
142
|
+
payload.signature = signPayload(payload, webhook.secret);
|
|
143
|
+
|
|
144
|
+
for (let attempt = 0; attempt < RETRY_DELAYS.length; attempt++) {
|
|
145
|
+
if (attempt > 0) {
|
|
146
|
+
await delay(RETRY_DELAYS[attempt] * 1000);
|
|
147
|
+
console.log(\`Webhook retry attempt \${attempt}, delay: \${RETRY_DELAYS[attempt]}s\`);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
const response = await fetch(webhook.url, {
|
|
151
|
+
method: "POST",
|
|
152
|
+
headers: { "Content-Type": "application/json", "X-Webhook-Key": webhook.key },
|
|
153
|
+
body: JSON.stringify(payload),
|
|
154
|
+
});
|
|
155
|
+
await logWebhookEvent(
|
|
156
|
+
webhook.id, tenantId, event.type, payload,
|
|
157
|
+
response.ok ? "success" : "failed",
|
|
158
|
+
await response.text(), response.status
|
|
159
|
+
);
|
|
160
|
+
if (response.ok) return;
|
|
161
|
+
} catch (err) {
|
|
162
|
+
await logWebhookEvent(
|
|
163
|
+
webhook.id, tenantId, event.type, payload,
|
|
164
|
+
"error", err.message, null
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
module.exports = {
|
|
173
|
+
generatePasswordUtil,
|
|
174
|
+
generateModulesUtil,
|
|
175
|
+
generateWebhookUtil,
|
|
176
|
+
};
|