@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
|
@@ -14,7 +14,9 @@ function createMockDataDriverDelegate(key: string): DataDriver {
|
|
|
14
14
|
initialised: true,
|
|
15
15
|
fetchCollection: jest.fn().mockResolvedValue([]),
|
|
16
16
|
fetchEntity: jest.fn().mockResolvedValue(undefined),
|
|
17
|
-
saveEntity: jest.fn().mockResolvedValue({ id: "test-id",
|
|
17
|
+
saveEntity: jest.fn().mockResolvedValue({ id: "test-id",
|
|
18
|
+
path: "test",
|
|
19
|
+
values: {} }),
|
|
18
20
|
deleteEntity: jest.fn().mockResolvedValue(undefined),
|
|
19
21
|
checkUniqueField: jest.fn().mockResolvedValue(true)
|
|
20
22
|
};
|
|
@@ -86,7 +88,7 @@ describe("DriverRegistry", () => {
|
|
|
86
88
|
const registry = new DefaultDriverRegistry();
|
|
87
89
|
|
|
88
90
|
expect(() => registry.getDefault()).toThrow(
|
|
89
|
-
|
|
91
|
+
"[DriverRegistry] No default driver registered."
|
|
90
92
|
);
|
|
91
93
|
});
|
|
92
94
|
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { ApiError, errorHandler } from "../src/api/errors";
|
|
3
|
+
import { HonoEnv } from "../src/api/types";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* End-to-end tests that verify ApiError thrown inside Hono sub-routers
|
|
7
|
+
* produces the correct HTTP status code and JSON body WHEN the sub-router
|
|
8
|
+
* is mounted on a parent app — exactly simulating the init.ts mounting
|
|
9
|
+
* pattern.
|
|
10
|
+
*
|
|
11
|
+
* This is critical because Hono's `onError` does NOT propagate from parent
|
|
12
|
+
* to child routers. Each sub-router must have its own `router.onError(errorHandler)`.
|
|
13
|
+
*/
|
|
14
|
+
describe("Error Propagation through Sub-Routers", () => {
|
|
15
|
+
|
|
16
|
+
// ── Without onError on sub-router (broken behavior) ─────────────
|
|
17
|
+
|
|
18
|
+
describe("Sub-router WITHOUT onError (demonstrates the bug)", () => {
|
|
19
|
+
function createBrokenApp() {
|
|
20
|
+
// This simulates the REAL production scenario: the consumer's Hono app
|
|
21
|
+
// (e.g. SustenTalent's index.ts) does NOT set onError on the parent app.
|
|
22
|
+
// Without onError on the sub-router either, Hono's DEFAULT error handler
|
|
23
|
+
// catches ApiErrors and returns a generic 500 with a plain text body.
|
|
24
|
+
const parentApp = new Hono<HonoEnv>();
|
|
25
|
+
// NOTE: no parentApp.onError(errorHandler) — this is the real scenario
|
|
26
|
+
|
|
27
|
+
const subRouter = new Hono<HonoEnv>();
|
|
28
|
+
// NOTE: no subRouter.onError(errorHandler) — this is the bug
|
|
29
|
+
subRouter.get("/login", () => {
|
|
30
|
+
throw ApiError.unauthorized("Invalid credentials", "INVALID_CREDENTIALS");
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
parentApp.route("/api/auth", subRouter);
|
|
34
|
+
return parentApp;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
it("ApiError.unauthorized returns 500 plain text without ANY onError handler", async () => {
|
|
38
|
+
const app = createBrokenApp();
|
|
39
|
+
const res = await app.request("/api/auth/login");
|
|
40
|
+
// Without any onError handler, Hono's default handler returns 500
|
|
41
|
+
expect(res.status).toBe(500);
|
|
42
|
+
// And the body is NOT our structured JSON — it's plain text
|
|
43
|
+
const text = await res.text();
|
|
44
|
+
expect(text).toBe("Internal Server Error");
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// ── With onError on sub-router (fixed behavior) ─────────────────
|
|
49
|
+
|
|
50
|
+
describe("Sub-router WITH onError (correct behavior)", () => {
|
|
51
|
+
function createFixedApp() {
|
|
52
|
+
const parentApp = new Hono<HonoEnv>();
|
|
53
|
+
parentApp.onError(errorHandler);
|
|
54
|
+
|
|
55
|
+
const subRouter = new Hono<HonoEnv>();
|
|
56
|
+
subRouter.onError(errorHandler); // ← THE FIX
|
|
57
|
+
|
|
58
|
+
subRouter.get("/login", () => {
|
|
59
|
+
throw ApiError.unauthorized("Invalid credentials", "INVALID_CREDENTIALS");
|
|
60
|
+
});
|
|
61
|
+
subRouter.get("/register", () => {
|
|
62
|
+
throw ApiError.conflict("Email already exists", "EMAIL_EXISTS");
|
|
63
|
+
});
|
|
64
|
+
subRouter.get("/refresh", () => {
|
|
65
|
+
throw ApiError.unauthorized("Refresh token expired", "TOKEN_EXPIRED");
|
|
66
|
+
});
|
|
67
|
+
subRouter.get("/forbidden", () => {
|
|
68
|
+
throw ApiError.forbidden("Admin only");
|
|
69
|
+
});
|
|
70
|
+
subRouter.get("/not-found", () => {
|
|
71
|
+
throw ApiError.notFound("User not found");
|
|
72
|
+
});
|
|
73
|
+
subRouter.get("/bad-request", () => {
|
|
74
|
+
throw ApiError.badRequest("Missing email", "INVALID_INPUT", { field: "email" });
|
|
75
|
+
});
|
|
76
|
+
subRouter.get("/service-unavailable", () => {
|
|
77
|
+
throw ApiError.serviceUnavailable("Google login not configured", "NOT_CONFIGURED");
|
|
78
|
+
});
|
|
79
|
+
subRouter.get("/generic-error", () => {
|
|
80
|
+
throw new Error("Unexpected failure");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
parentApp.route("/api/auth", subRouter);
|
|
84
|
+
return parentApp;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
it("returns 401 for unauthorized errors", async () => {
|
|
88
|
+
const app = createFixedApp();
|
|
89
|
+
const res = await app.request("/api/auth/login");
|
|
90
|
+
expect(res.status).toBe(401);
|
|
91
|
+
const body = await res.json() as { error: { message: string; code: string } };
|
|
92
|
+
expect(body.error.message).toBe("Invalid credentials");
|
|
93
|
+
expect(body.error.code).toBe("INVALID_CREDENTIALS");
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it("returns 409 for conflict errors", async () => {
|
|
97
|
+
const app = createFixedApp();
|
|
98
|
+
const res = await app.request("/api/auth/register");
|
|
99
|
+
expect(res.status).toBe(409);
|
|
100
|
+
const body = await res.json() as { error: { message: string; code: string } };
|
|
101
|
+
expect(body.error.message).toBe("Email already exists");
|
|
102
|
+
expect(body.error.code).toBe("EMAIL_EXISTS");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns 401 for expired refresh token", async () => {
|
|
106
|
+
const app = createFixedApp();
|
|
107
|
+
const res = await app.request("/api/auth/refresh");
|
|
108
|
+
expect(res.status).toBe(401);
|
|
109
|
+
const body = await res.json() as { error: { message: string; code: string } };
|
|
110
|
+
expect(body.error.code).toBe("TOKEN_EXPIRED");
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("returns 403 for forbidden errors", async () => {
|
|
114
|
+
const app = createFixedApp();
|
|
115
|
+
const res = await app.request("/api/auth/forbidden");
|
|
116
|
+
expect(res.status).toBe(403);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it("returns 404 for not found errors", async () => {
|
|
120
|
+
const app = createFixedApp();
|
|
121
|
+
const res = await app.request("/api/auth/not-found");
|
|
122
|
+
expect(res.status).toBe(404);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
it("returns 400 with details for bad request errors", async () => {
|
|
126
|
+
const app = createFixedApp();
|
|
127
|
+
const res = await app.request("/api/auth/bad-request");
|
|
128
|
+
expect(res.status).toBe(400);
|
|
129
|
+
const body = await res.json() as { error: { message: string; code: string; details: unknown } };
|
|
130
|
+
expect(body.error.code).toBe("INVALID_INPUT");
|
|
131
|
+
expect(body.error.details).toEqual({ field: "email" });
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("returns 503 for service unavailable errors", async () => {
|
|
135
|
+
const app = createFixedApp();
|
|
136
|
+
const res = await app.request("/api/auth/service-unavailable");
|
|
137
|
+
expect(res.status).toBe(503);
|
|
138
|
+
const body = await res.json() as { error: { message: string; code: string } };
|
|
139
|
+
expect(body.error.code).toBe("NOT_CONFIGURED");
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("returns 500 with sanitized message for generic errors", async () => {
|
|
143
|
+
const app = createFixedApp();
|
|
144
|
+
const res = await app.request("/api/auth/generic-error");
|
|
145
|
+
expect(res.status).toBe(500);
|
|
146
|
+
const body = await res.json() as { error: { message: string; code: string } };
|
|
147
|
+
expect(body.error.code).toBe("INTERNAL_ERROR");
|
|
148
|
+
// Should NOT leak the raw error message
|
|
149
|
+
expect(body.error.message).toBe("Internal Server Error");
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("returns consistent { error: { message, code } } shape for all error types", async () => {
|
|
153
|
+
const app = createFixedApp();
|
|
154
|
+
const paths = ["/api/auth/login", "/api/auth/register", "/api/auth/forbidden", "/api/auth/not-found", "/api/auth/bad-request"];
|
|
155
|
+
|
|
156
|
+
for (const path of paths) {
|
|
157
|
+
const res = await app.request(path);
|
|
158
|
+
const body = await res.json() as Record<string, unknown>;
|
|
159
|
+
expect(body).toHaveProperty("error");
|
|
160
|
+
expect(body.error).toHaveProperty("message");
|
|
161
|
+
expect(body.error).toHaveProperty("code");
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// ── Multi-level nesting ─────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
describe("Deeply nested sub-routers", () => {
|
|
169
|
+
it("error handler works through two levels of nesting", async () => {
|
|
170
|
+
const root = new Hono<HonoEnv>();
|
|
171
|
+
root.onError(errorHandler);
|
|
172
|
+
|
|
173
|
+
const level1 = new Hono<HonoEnv>();
|
|
174
|
+
level1.onError(errorHandler);
|
|
175
|
+
|
|
176
|
+
const level2 = new Hono<HonoEnv>();
|
|
177
|
+
level2.onError(errorHandler);
|
|
178
|
+
|
|
179
|
+
level2.get("/deep", () => {
|
|
180
|
+
throw ApiError.unauthorized("Deep error");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
level1.route("/nested", level2);
|
|
184
|
+
root.route("/api", level1);
|
|
185
|
+
|
|
186
|
+
const res = await root.request("/api/nested/deep");
|
|
187
|
+
expect(res.status).toBe(401);
|
|
188
|
+
const body = await res.json() as { error: { message: string } };
|
|
189
|
+
expect(body.error.message).toBe("Deep error");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
// ── Data router simulation ──────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
describe("Data router error propagation (simulates init.ts pattern)", () => {
|
|
196
|
+
function createDataApp() {
|
|
197
|
+
const app = new Hono<HonoEnv>();
|
|
198
|
+
app.onError(errorHandler);
|
|
199
|
+
|
|
200
|
+
const dataRouter = new Hono<HonoEnv>();
|
|
201
|
+
dataRouter.onError(errorHandler);
|
|
202
|
+
|
|
203
|
+
dataRouter.get("/posts/:id", () => {
|
|
204
|
+
throw ApiError.notFound("Entity not found");
|
|
205
|
+
});
|
|
206
|
+
dataRouter.post("/posts", () => {
|
|
207
|
+
throw ApiError.badRequest("Validation failed", "INVALID_INPUT");
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
app.route("/api/data", dataRouter);
|
|
211
|
+
return app;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
it("returns 404 for entity not found", async () => {
|
|
215
|
+
const app = createDataApp();
|
|
216
|
+
const res = await app.request("/api/data/posts/999");
|
|
217
|
+
expect(res.status).toBe(404);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("returns 400 for validation errors", async () => {
|
|
221
|
+
const app = createDataApp();
|
|
222
|
+
const res = await app.request("/api/data/posts", { method: "POST" });
|
|
223
|
+
expect(res.status).toBe(400);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
});
|
package/test/errors.test.ts
CHANGED
|
@@ -6,18 +6,19 @@ function createMockContext(method = "GET", path = "/test") {
|
|
|
6
6
|
let capturedBody: any;
|
|
7
7
|
|
|
8
8
|
const c = {
|
|
9
|
-
req: { method,
|
|
9
|
+
req: { method,
|
|
10
|
+
path },
|
|
10
11
|
json: (body: any, status?: number) => {
|
|
11
12
|
capturedBody = body;
|
|
12
13
|
capturedStatus = status ?? 200;
|
|
13
14
|
return new Response(JSON.stringify(body), { status: capturedStatus });
|
|
14
|
-
}
|
|
15
|
+
}
|
|
15
16
|
} as any;
|
|
16
17
|
|
|
17
18
|
return {
|
|
18
19
|
c,
|
|
19
20
|
getStatus: () => capturedStatus,
|
|
20
|
-
getBody: () => capturedBody
|
|
21
|
+
getBody: () => capturedBody
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -100,7 +101,8 @@ describe("errorHandler", () => {
|
|
|
100
101
|
|
|
101
102
|
expect(getStatus()).toBe(404);
|
|
102
103
|
expect(getBody()).toEqual({
|
|
103
|
-
error: { message: "User not found",
|
|
104
|
+
error: { message: "User not found",
|
|
105
|
+
code: "NOT_FOUND" }
|
|
104
106
|
});
|
|
105
107
|
});
|
|
106
108
|
|
|
@@ -125,7 +127,8 @@ describe("errorHandler", () => {
|
|
|
125
127
|
|
|
126
128
|
expect(getStatus()).toBe(404);
|
|
127
129
|
expect(getBody()).toEqual({
|
|
128
|
-
error: { message: "Not found",
|
|
130
|
+
error: { message: "Not found",
|
|
131
|
+
code: "NOT_FOUND" }
|
|
129
132
|
});
|
|
130
133
|
});
|
|
131
134
|
|
|
@@ -136,13 +139,15 @@ describe("errorHandler", () => {
|
|
|
136
139
|
|
|
137
140
|
expect(getStatus()).toBe(500);
|
|
138
141
|
expect(getBody()).toEqual({
|
|
139
|
-
error: { message: "Internal Server Error",
|
|
142
|
+
error: { message: "Internal Server Error",
|
|
143
|
+
code: "INTERNAL_ERROR" }
|
|
140
144
|
});
|
|
141
145
|
});
|
|
142
146
|
|
|
143
147
|
it("should use statusCode from error if present", () => {
|
|
144
148
|
const { c, getStatus } = createMockContext();
|
|
145
|
-
const err = Object.assign(new Error("Rate limited"), { statusCode: 429,
|
|
149
|
+
const err = Object.assign(new Error("Rate limited"), { statusCode: 429,
|
|
150
|
+
code: "RATE_LIMITED" });
|
|
146
151
|
errorHandler(err, c);
|
|
147
152
|
|
|
148
153
|
expect(getStatus()).toBe(429);
|
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
generateRefreshToken,
|
|
6
6
|
hashRefreshToken,
|
|
7
7
|
getAccessTokenExpiryMs,
|
|
8
|
-
getRefreshTokenExpiry
|
|
8
|
+
getRefreshTokenExpiry
|
|
9
9
|
} from "../src/auth/jwt";
|
|
10
10
|
|
|
11
11
|
const STRONG_SECRET = "this-is-a-strong-secret-for-jwt-testing-at-least-32-chars-long";
|
|
@@ -13,7 +13,9 @@ const STRONG_SECRET = "this-is-a-strong-secret-for-jwt-testing-at-least-32-chars
|
|
|
13
13
|
describe("JWT Security Hardening", () => {
|
|
14
14
|
|
|
15
15
|
beforeEach(() => {
|
|
16
|
-
configureJwt({ secret: STRONG_SECRET,
|
|
16
|
+
configureJwt({ secret: STRONG_SECRET,
|
|
17
|
+
accessExpiresIn: "1h",
|
|
18
|
+
refreshExpiresIn: "30d" });
|
|
17
19
|
});
|
|
18
20
|
|
|
19
21
|
// ── Secret validation ───────────────────────────────────
|
|
@@ -37,7 +39,7 @@ describe("JWT Security Hardening", () => {
|
|
|
37
39
|
|
|
38
40
|
it("accepts strong, random secrets", () => {
|
|
39
41
|
expect(() => configureJwt({
|
|
40
|
-
secret: "aG7x!kL2$mP9#qR5+tU8*wZ0^bD3&fH6"
|
|
42
|
+
secret: "aG7x!kL2$mP9#qR5+tU8*wZ0^bD3&fH6"
|
|
41
43
|
})).not.toThrow();
|
|
42
44
|
});
|
|
43
45
|
});
|
|
@@ -64,7 +66,9 @@ describe("JWT Security Hardening", () => {
|
|
|
64
66
|
|
|
65
67
|
it("throws when secret is not configured", () => {
|
|
66
68
|
// Force empty secret
|
|
67
|
-
|
|
69
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
70
|
+
Object.defineProperty(require("../src/auth/jwt"), "jwtConfig", { value: { secret: "" },
|
|
71
|
+
writable: true });
|
|
68
72
|
// This won't work since jwtConfig is module-scoped, but generateAccessToken has its own check
|
|
69
73
|
// We'll test via configureJwt + clearing
|
|
70
74
|
});
|
|
@@ -142,27 +146,32 @@ describe("JWT Security Hardening", () => {
|
|
|
142
146
|
// ── Expiry calculations ─────────────────────────────────
|
|
143
147
|
describe("expiry calculations", () => {
|
|
144
148
|
it("calculates 1h as 3600000ms", () => {
|
|
145
|
-
configureJwt({ secret: STRONG_SECRET,
|
|
149
|
+
configureJwt({ secret: STRONG_SECRET,
|
|
150
|
+
accessExpiresIn: "1h" });
|
|
146
151
|
expect(getAccessTokenExpiryMs()).toBe(3600000);
|
|
147
152
|
});
|
|
148
153
|
|
|
149
154
|
it("calculates 30m as 1800000ms", () => {
|
|
150
|
-
configureJwt({ secret: STRONG_SECRET,
|
|
155
|
+
configureJwt({ secret: STRONG_SECRET,
|
|
156
|
+
accessExpiresIn: "30m" });
|
|
151
157
|
expect(getAccessTokenExpiryMs()).toBe(1800000);
|
|
152
158
|
});
|
|
153
159
|
|
|
154
160
|
it("calculates 7d correctly", () => {
|
|
155
|
-
configureJwt({ secret: STRONG_SECRET,
|
|
161
|
+
configureJwt({ secret: STRONG_SECRET,
|
|
162
|
+
accessExpiresIn: "7d" });
|
|
156
163
|
expect(getAccessTokenExpiryMs()).toBe(7 * 24 * 60 * 60 * 1000);
|
|
157
164
|
});
|
|
158
165
|
|
|
159
166
|
it("defaults to 1h for unparseable duration", () => {
|
|
160
|
-
configureJwt({ secret: STRONG_SECRET,
|
|
167
|
+
configureJwt({ secret: STRONG_SECRET,
|
|
168
|
+
accessExpiresIn: "invalid" });
|
|
161
169
|
expect(getAccessTokenExpiryMs()).toBe(3600000);
|
|
162
170
|
});
|
|
163
171
|
|
|
164
172
|
it("refresh expiry is in the future", () => {
|
|
165
|
-
configureJwt({ secret: STRONG_SECRET,
|
|
173
|
+
configureJwt({ secret: STRONG_SECRET,
|
|
174
|
+
refreshExpiresIn: "30d" });
|
|
166
175
|
const expiry = getRefreshTokenExpiry();
|
|
167
176
|
expect(expiry.getTime()).toBeGreaterThan(Date.now());
|
|
168
177
|
// Should be approximately 30 days in the future
|
package/test/jwt.test.ts
CHANGED
|
@@ -29,7 +29,8 @@ describe("JWT Utilities", () => {
|
|
|
29
29
|
});
|
|
30
30
|
|
|
31
31
|
it("should allow partial configuration updates", () => {
|
|
32
|
-
configureJwt({ secret: testSecret,
|
|
32
|
+
configureJwt({ secret: testSecret,
|
|
33
|
+
accessExpiresIn: "2h" });
|
|
33
34
|
// Token generation should still work
|
|
34
35
|
const token = generateAccessToken("user-1", ["admin"]);
|
|
35
36
|
expect(token).toBeTruthy();
|
|
@@ -138,27 +139,32 @@ describe("JWT Utilities", () => {
|
|
|
138
139
|
|
|
139
140
|
describe("getAccessTokenExpiryMs", () => {
|
|
140
141
|
it("should return correct milliseconds for hours", () => {
|
|
141
|
-
configureJwt({ secret: testSecret,
|
|
142
|
+
configureJwt({ secret: testSecret,
|
|
143
|
+
accessExpiresIn: "2h" });
|
|
142
144
|
expect(getAccessTokenExpiryMs()).toBe(2 * 60 * 60 * 1000);
|
|
143
145
|
});
|
|
144
146
|
|
|
145
147
|
it("should return correct milliseconds for days", () => {
|
|
146
|
-
configureJwt({ secret: testSecret,
|
|
148
|
+
configureJwt({ secret: testSecret,
|
|
149
|
+
accessExpiresIn: "7d" });
|
|
147
150
|
expect(getAccessTokenExpiryMs()).toBe(7 * 24 * 60 * 60 * 1000);
|
|
148
151
|
});
|
|
149
152
|
|
|
150
153
|
it("should return correct milliseconds for minutes", () => {
|
|
151
|
-
configureJwt({ secret: testSecret,
|
|
154
|
+
configureJwt({ secret: testSecret,
|
|
155
|
+
accessExpiresIn: "30m" });
|
|
152
156
|
expect(getAccessTokenExpiryMs()).toBe(30 * 60 * 1000);
|
|
153
157
|
});
|
|
154
158
|
|
|
155
159
|
it("should return correct milliseconds for seconds", () => {
|
|
156
|
-
configureJwt({ secret: testSecret,
|
|
160
|
+
configureJwt({ secret: testSecret,
|
|
161
|
+
accessExpiresIn: "300s" });
|
|
157
162
|
expect(getAccessTokenExpiryMs()).toBe(300 * 1000);
|
|
158
163
|
});
|
|
159
164
|
|
|
160
165
|
it("should default to 1 hour for invalid format", () => {
|
|
161
|
-
configureJwt({ secret: testSecret,
|
|
166
|
+
configureJwt({ secret: testSecret,
|
|
167
|
+
accessExpiresIn: "invalid" });
|
|
162
168
|
expect(getAccessTokenExpiryMs()).toBe(60 * 60 * 1000);
|
|
163
169
|
});
|
|
164
170
|
});
|
|
@@ -171,7 +177,8 @@ describe("JWT Utilities", () => {
|
|
|
171
177
|
});
|
|
172
178
|
|
|
173
179
|
it("should match the configured expiry duration", () => {
|
|
174
|
-
configureJwt({ secret: testSecret,
|
|
180
|
+
configureJwt({ secret: testSecret,
|
|
181
|
+
accessExpiresIn: "1h" });
|
|
175
182
|
const now = Date.now();
|
|
176
183
|
const expiry = getAccessTokenExpiry();
|
|
177
184
|
// Should be approximately 1 hour from now (with small tolerance)
|
|
@@ -197,7 +204,8 @@ describe("JWT Utilities", () => {
|
|
|
197
204
|
});
|
|
198
205
|
|
|
199
206
|
it("should respect custom refresh expiry configuration", () => {
|
|
200
|
-
configureJwt({ secret: testSecret,
|
|
207
|
+
configureJwt({ secret: testSecret,
|
|
208
|
+
refreshExpiresIn: "7d" });
|
|
201
209
|
const expiry = getRefreshTokenExpiry();
|
|
202
210
|
const expected = Date.now() + (7 * 24 * 60 * 60 * 1000);
|
|
203
211
|
expect(expiry.getTime()).toBeGreaterThanOrEqual(expected - 1000);
|
|
@@ -205,7 +213,8 @@ describe("JWT Utilities", () => {
|
|
|
205
213
|
});
|
|
206
214
|
|
|
207
215
|
it("should handle hour-based refresh expiry", () => {
|
|
208
|
-
configureJwt({ secret: testSecret,
|
|
216
|
+
configureJwt({ secret: testSecret,
|
|
217
|
+
refreshExpiresIn: "24h" });
|
|
209
218
|
const expiry = getRefreshTokenExpiry();
|
|
210
219
|
const expected = Date.now() + (24 * 60 * 60 * 1000);
|
|
211
220
|
expect(expiry.getTime()).toBeGreaterThanOrEqual(expected - 1000);
|
|
@@ -213,7 +222,8 @@ describe("JWT Utilities", () => {
|
|
|
213
222
|
});
|
|
214
223
|
|
|
215
224
|
it("should handle minute-based refresh expiry", () => {
|
|
216
|
-
configureJwt({ secret: testSecret,
|
|
225
|
+
configureJwt({ secret: testSecret,
|
|
226
|
+
refreshExpiresIn: "90m" });
|
|
217
227
|
const expiry = getRefreshTokenExpiry();
|
|
218
228
|
const expected = Date.now() + (90 * 60 * 1000);
|
|
219
229
|
expect(expiry.getTime()).toBeGreaterThanOrEqual(expected - 1000);
|
|
@@ -221,7 +231,8 @@ describe("JWT Utilities", () => {
|
|
|
221
231
|
});
|
|
222
232
|
|
|
223
233
|
it("should handle second-based refresh expiry", () => {
|
|
224
|
-
configureJwt({ secret: testSecret,
|
|
234
|
+
configureJwt({ secret: testSecret,
|
|
235
|
+
refreshExpiresIn: "3600s" });
|
|
225
236
|
const expiry = getRefreshTokenExpiry();
|
|
226
237
|
const expected = Date.now() + (3600 * 1000);
|
|
227
238
|
expect(expiry.getTime()).toBeGreaterThanOrEqual(expected - 1000);
|
|
@@ -229,7 +240,8 @@ describe("JWT Utilities", () => {
|
|
|
229
240
|
});
|
|
230
241
|
|
|
231
242
|
it("should default to 30 days for invalid refresh format", () => {
|
|
232
|
-
configureJwt({ secret: testSecret,
|
|
243
|
+
configureJwt({ secret: testSecret,
|
|
244
|
+
refreshExpiresIn: "invalid" });
|
|
233
245
|
const expiry = getRefreshTokenExpiry();
|
|
234
246
|
const expected = Date.now() + (30 * 24 * 60 * 60 * 1000);
|
|
235
247
|
expect(expiry.getTime()).toBeGreaterThanOrEqual(expected - 1000);
|
|
@@ -270,7 +282,8 @@ describe("JWT Utilities", () => {
|
|
|
270
282
|
describe("expired token handling", () => {
|
|
271
283
|
it("should return null for an expired token", () => {
|
|
272
284
|
// Configure with 1 second expiry
|
|
273
|
-
configureJwt({ secret: testSecret,
|
|
285
|
+
configureJwt({ secret: testSecret,
|
|
286
|
+
accessExpiresIn: "1s" });
|
|
274
287
|
const token = generateAccessToken("user-1", ["admin"]);
|
|
275
288
|
|
|
276
289
|
// Immediately verify should work
|
package/test/middleware.test.ts
CHANGED
|
@@ -22,7 +22,7 @@ function createMockContext(opts: {
|
|
|
22
22
|
query: (name: string) => {
|
|
23
23
|
if (name === "token") return opts.queryToken;
|
|
24
24
|
return undefined;
|
|
25
|
-
}
|
|
25
|
+
}
|
|
26
26
|
},
|
|
27
27
|
json: (body: any, status?: number) => {
|
|
28
28
|
capturedBody = body;
|
|
@@ -30,14 +30,14 @@ function createMockContext(opts: {
|
|
|
30
30
|
return new Response(JSON.stringify(body), { status: capturedStatus });
|
|
31
31
|
},
|
|
32
32
|
set: (key: string, value: any) => variables.set(key, value),
|
|
33
|
-
get: (key: string) => variables.get(key)
|
|
33
|
+
get: (key: string) => variables.get(key)
|
|
34
34
|
} as any;
|
|
35
35
|
|
|
36
36
|
return {
|
|
37
37
|
c,
|
|
38
38
|
getStatus: () => capturedStatus,
|
|
39
39
|
getBody: () => capturedBody,
|
|
40
|
-
getUser: () => variables.get("user")
|
|
40
|
+
getUser: () => variables.get("user")
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -217,7 +217,8 @@ describe("Auth Middleware", () => {
|
|
|
217
217
|
});
|
|
218
218
|
|
|
219
219
|
it("should return 403 if user has empty roles array", async () => {
|
|
220
|
-
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
220
|
+
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
221
|
+
roles: [] } });
|
|
221
222
|
|
|
222
223
|
await requireAdmin(c, nextFn);
|
|
223
224
|
|
|
@@ -226,7 +227,8 @@ describe("Auth Middleware", () => {
|
|
|
226
227
|
});
|
|
227
228
|
|
|
228
229
|
it("should return 403 if user has standard roles (forbidden)", async () => {
|
|
229
|
-
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
230
|
+
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
231
|
+
roles: ["editor", "viewer"] } });
|
|
230
232
|
|
|
231
233
|
await requireAdmin(c, nextFn);
|
|
232
234
|
|
|
@@ -235,7 +237,8 @@ describe("Auth Middleware", () => {
|
|
|
235
237
|
});
|
|
236
238
|
|
|
237
239
|
it("should allow access if user has 'admin' role", async () => {
|
|
238
|
-
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
240
|
+
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
241
|
+
roles: ["editor", "admin"] } });
|
|
239
242
|
|
|
240
243
|
await requireAdmin(c, nextFn);
|
|
241
244
|
|
|
@@ -244,7 +247,8 @@ describe("Auth Middleware", () => {
|
|
|
244
247
|
});
|
|
245
248
|
|
|
246
249
|
it("should allow access if user has 'schema-admin' role", async () => {
|
|
247
|
-
const { c } = createMockContext({ user: { userId: "user-123",
|
|
250
|
+
const { c } = createMockContext({ user: { userId: "user-123",
|
|
251
|
+
roles: ["schema-admin"] } });
|
|
248
252
|
|
|
249
253
|
await requireAdmin(c, nextFn);
|
|
250
254
|
|
|
@@ -252,7 +256,8 @@ describe("Auth Middleware", () => {
|
|
|
252
256
|
});
|
|
253
257
|
|
|
254
258
|
it("should block access for malformed spoofed string roles", async () => {
|
|
255
|
-
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
259
|
+
const { c, getStatus } = createMockContext({ user: { userId: "user-123",
|
|
260
|
+
roles: ["schema-adminstration", "admins", "admin "] } });
|
|
256
261
|
|
|
257
262
|
await requireAdmin(c, nextFn);
|
|
258
263
|
|
|
@@ -39,7 +39,8 @@ describe("parseQueryOptions — pagination", () => {
|
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
it("calculates offset from page number", () => {
|
|
42
|
-
const result = parseQueryOptions({ page: "3",
|
|
42
|
+
const result = parseQueryOptions({ page: "3",
|
|
43
|
+
limit: "10" });
|
|
43
44
|
expect(result.offset).toBe(20); // (3-1) * 10
|
|
44
45
|
});
|
|
45
46
|
|
|
@@ -134,7 +135,7 @@ describe("parseQueryOptions — PostgREST filters", () => {
|
|
|
134
135
|
limit: "10",
|
|
135
136
|
offset: "0",
|
|
136
137
|
orderBy: "name:asc",
|
|
137
|
-
status: "eq.active"
|
|
138
|
+
status: "eq.active"
|
|
138
139
|
});
|
|
139
140
|
// Only status should be in where
|
|
140
141
|
expect(result.where?.status).toEqual(["==", "active"]);
|
|
@@ -161,14 +162,14 @@ describe("parseQueryOptions — PostgREST filters", () => {
|
|
|
161
162
|
describe("parseQueryOptions — legacy JSON where", () => {
|
|
162
163
|
it("parses JSON where string", () => {
|
|
163
164
|
const result = parseQueryOptions({
|
|
164
|
-
where: JSON.stringify({ status: ["==", "published"] })
|
|
165
|
+
where: JSON.stringify({ status: ["==", "published"] })
|
|
165
166
|
});
|
|
166
167
|
expect(result.where?.status).toEqual(["==", "published"]);
|
|
167
168
|
});
|
|
168
169
|
|
|
169
170
|
it("accepts object where directly", () => {
|
|
170
171
|
const result = parseQueryOptions({
|
|
171
|
-
where: { status: ["==", "draft"] }
|
|
172
|
+
where: { status: ["==", "draft"] }
|
|
172
173
|
});
|
|
173
174
|
expect(result.where?.status).toEqual(["==", "draft"]);
|
|
174
175
|
});
|
|
@@ -191,19 +192,23 @@ describe("parseQueryOptions — legacy JSON where", () => {
|
|
|
191
192
|
// ─────────────────────────────────────────────────────────────
|
|
192
193
|
describe("parseQueryOptions — sorting", () => {
|
|
193
194
|
it("parses JSON orderBy", () => {
|
|
194
|
-
const orderBy = JSON.stringify([{ field: "name",
|
|
195
|
+
const orderBy = JSON.stringify([{ field: "name",
|
|
196
|
+
direction: "asc" }]);
|
|
195
197
|
const result = parseQueryOptions({ orderBy });
|
|
196
|
-
expect(result.orderBy).toEqual([{ field: "name",
|
|
198
|
+
expect(result.orderBy).toEqual([{ field: "name",
|
|
199
|
+
direction: "asc" }]);
|
|
197
200
|
});
|
|
198
201
|
|
|
199
202
|
it("parses simple field:direction format", () => {
|
|
200
203
|
const result = parseQueryOptions({ orderBy: "created_at:desc" });
|
|
201
|
-
expect(result.orderBy).toEqual([{ field: "created_at",
|
|
204
|
+
expect(result.orderBy).toEqual([{ field: "created_at",
|
|
205
|
+
direction: "desc" }]);
|
|
202
206
|
});
|
|
203
207
|
|
|
204
208
|
it("defaults direction to asc", () => {
|
|
205
209
|
const result = parseQueryOptions({ orderBy: "name" });
|
|
206
|
-
expect(result.orderBy).toEqual([{ field: "name",
|
|
210
|
+
expect(result.orderBy).toEqual([{ field: "name",
|
|
211
|
+
direction: "asc" }]);
|
|
207
212
|
});
|
|
208
213
|
|
|
209
214
|
it("handles no orderBy", () => {
|