@rebasepro/server-postgresql 0.1.0 → 0.2.1
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/LICENSE +22 -6
- package/dist/common/src/util/entities.d.ts +2 -2
- package/dist/common/src/util/relations.d.ts +1 -1
- package/dist/index.es.js +1250 -1665
- package/dist/index.es.js.map +1 -1
- package/dist/index.umd.js +1196 -1611
- package/dist/index.umd.js.map +1 -1
- package/dist/server-postgresql/src/PostgresAdapter.d.ts +6 -0
- package/dist/server-postgresql/src/PostgresBackendDriver.d.ts +2 -1
- package/dist/server-postgresql/src/PostgresBootstrapper.d.ts +0 -5
- package/dist/server-postgresql/src/auth/ensure-tables.d.ts +2 -1
- package/dist/server-postgresql/src/auth/services.d.ts +37 -15
- package/dist/server-postgresql/src/index.d.ts +1 -0
- package/dist/server-postgresql/src/schema/auth-schema.d.ts +43 -856
- package/dist/server-postgresql/src/schema/default-collections.d.ts +2 -0
- package/dist/server-postgresql/src/schema/doctor.d.ts +10 -1
- package/dist/server-postgresql/src/schema/introspect-db-logic.d.ts +1 -0
- package/dist/server-postgresql/src/services/entity-helpers.d.ts +1 -1
- package/dist/server-postgresql/src/services/realtimeService.d.ts +12 -0
- package/dist/server-postgresql/src/websocket.d.ts +2 -1
- package/dist/types/src/controllers/auth.d.ts +9 -8
- package/dist/types/src/controllers/client.d.ts +3 -0
- package/dist/types/src/types/auth_adapter.d.ts +356 -0
- package/dist/types/src/types/collections.d.ts +67 -2
- package/dist/types/src/types/database_adapter.d.ts +94 -0
- package/dist/types/src/types/entity_actions.d.ts +7 -1
- package/dist/types/src/types/entity_callbacks.d.ts +1 -1
- package/dist/types/src/types/entity_views.d.ts +36 -1
- package/dist/types/src/types/index.d.ts +2 -0
- package/dist/types/src/types/plugins.d.ts +1 -1
- package/dist/types/src/types/properties.d.ts +24 -5
- package/dist/types/src/types/property_config.d.ts +6 -2
- package/dist/types/src/types/relations.d.ts +1 -1
- package/dist/types/src/types/translations.d.ts +8 -0
- package/dist/types/src/users/user.d.ts +5 -0
- package/package.json +21 -15
- package/src/PostgresAdapter.ts +59 -0
- package/src/PostgresBackendDriver.ts +57 -8
- package/src/PostgresBootstrapper.ts +35 -15
- package/src/auth/ensure-tables.ts +82 -189
- package/src/auth/services.ts +421 -170
- package/src/cli.ts +44 -13
- package/src/data-transformer.ts +78 -8
- package/src/history/HistoryService.ts +25 -2
- package/src/index.ts +1 -0
- package/src/schema/auth-schema.ts +130 -98
- package/src/schema/default-collections.ts +68 -0
- package/src/schema/doctor-cli.ts +5 -1
- package/src/schema/doctor.ts +85 -8
- package/src/schema/generate-drizzle-schema-logic.ts +74 -27
- package/src/schema/generate-drizzle-schema.ts +13 -3
- package/src/schema/introspect-db-inference.ts +5 -5
- package/src/schema/introspect-db-logic.ts +9 -2
- package/src/schema/introspect-db.ts +14 -3
- package/src/services/EntityFetchService.ts +5 -5
- package/src/services/RelationService.ts +2 -2
- package/src/services/entity-helpers.ts +1 -1
- package/src/services/realtimeService.ts +145 -136
- package/src/utils/drizzle-conditions.ts +16 -2
- package/src/websocket.ts +113 -37
- package/test/auth-services.test.ts +163 -74
- package/test/data-transformer-hardening.test.ts +57 -0
- package/test/data-transformer.test.ts +43 -0
- package/test/generate-drizzle-schema.test.ts +7 -5
- package/test/introspect-db-utils.test.ts +4 -1
- package/test/postgresDataDriver.test.ts +17 -0
- package/test/realtimeService.test.ts +7 -7
- package/test/websocket.test.ts +139 -0
- package/examples/sdk-demo/node_modules/esbuild/LICENSE.md +0 -21
- package/examples/sdk-demo/node_modules/esbuild/README.md +0 -3
- package/examples/sdk-demo/node_modules/esbuild/bin/esbuild +0 -223
- package/examples/sdk-demo/node_modules/esbuild/install.js +0 -289
- package/examples/sdk-demo/node_modules/esbuild/lib/main.d.ts +0 -716
- package/examples/sdk-demo/node_modules/esbuild/lib/main.js +0 -2242
- package/examples/sdk-demo/node_modules/esbuild/package.json +0 -49
package/src/websocket.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { RealtimeService } from "./services/realtimeService";
|
|
2
2
|
import { PostgresBackendDriver } from "./PostgresBackendDriver";
|
|
3
|
-
import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin } from "@rebasepro/types";
|
|
3
|
+
import { DataDriver, DeleteEntityProps, FetchCollectionProps, FetchEntityProps, SaveEntityProps, TableMetadata, BranchInfo, isSQLAdmin, isSchemaAdmin, AuthAdapter } from "@rebasepro/types";
|
|
4
4
|
import { WebSocketServer, WebSocket } from "ws";
|
|
5
5
|
import { Server } from "http";
|
|
6
6
|
import { inspect } from "util";
|
|
@@ -9,9 +9,18 @@ import { extractUserFromToken, AccessTokenPayload } from "@rebasepro/server-core
|
|
|
9
9
|
// @ts-ignore
|
|
10
10
|
import { AuthConfig } from "@rebasepro/server-core";
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Normalized user identity for WebSocket sessions.
|
|
14
|
+
*/
|
|
15
|
+
interface WsUserIdentity {
|
|
16
|
+
userId: string;
|
|
17
|
+
roles: string[];
|
|
18
|
+
isAdmin: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
12
21
|
interface ClientSession {
|
|
13
22
|
ws: WebSocket;
|
|
14
|
-
user?:
|
|
23
|
+
user?: WsUserIdentity;
|
|
15
24
|
authenticated: boolean;
|
|
16
25
|
/** Sliding window message counter for rate limiting */
|
|
17
26
|
messageCount: number;
|
|
@@ -38,11 +47,31 @@ const ADMIN_ONLY_TYPES = new Set([
|
|
|
38
47
|
"LIST_BRANCHES"
|
|
39
48
|
]);
|
|
40
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Recursively extract the deepest error message from an error's cause chain (e.g., Drizzle wrapping a PG error).
|
|
52
|
+
*/
|
|
53
|
+
function extractErrorMessage(error: unknown): string {
|
|
54
|
+
if (!error) return "Unknown error";
|
|
55
|
+
if (typeof error === "object") {
|
|
56
|
+
const err = error as Record<string, unknown> & { cause?: unknown; message?: string };
|
|
57
|
+
if (err.cause) {
|
|
58
|
+
return extractErrorMessage(err.cause);
|
|
59
|
+
}
|
|
60
|
+
if (typeof err.message === "string") {
|
|
61
|
+
return err.message;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return String(error);
|
|
65
|
+
}
|
|
66
|
+
|
|
41
67
|
/**
|
|
42
68
|
* Check if the current session belongs to an admin user.
|
|
43
69
|
*/
|
|
44
70
|
function isAdminSession(session: ClientSession | undefined): boolean {
|
|
45
|
-
if (!session?.user
|
|
71
|
+
if (!session?.user) return false;
|
|
72
|
+
// Fast path: new adapter-aware sessions set isAdmin directly
|
|
73
|
+
if (session.user.isAdmin) return true;
|
|
74
|
+
if (!session.user.roles) return false;
|
|
46
75
|
return session.user.roles.some((r: unknown) => {
|
|
47
76
|
if (typeof r === "string") return r === "admin";
|
|
48
77
|
if (r && typeof r === "object" && "isAdmin" in r) return (r as { isAdmin: boolean }).isAdmin;
|
|
@@ -55,7 +84,8 @@ export function createPostgresWebSocket(
|
|
|
55
84
|
server: Server,
|
|
56
85
|
realtimeService: RealtimeService,
|
|
57
86
|
driver: PostgresBackendDriver,
|
|
58
|
-
authConfig?: AuthConfig
|
|
87
|
+
authConfig?: AuthConfig,
|
|
88
|
+
authAdapter?: AuthAdapter
|
|
59
89
|
) {
|
|
60
90
|
const isProduction = process.env.NODE_ENV === "production";
|
|
61
91
|
/** Debug logger that is suppressed in production to prevent PII/data leaks */
|
|
@@ -74,7 +104,11 @@ export function createPostgresWebSocket(
|
|
|
74
104
|
console.error("❌ [WebSocket Server] Error:", err);
|
|
75
105
|
});
|
|
76
106
|
|
|
77
|
-
|
|
107
|
+
// Auth is required when either: an adapter is present (secure by default),
|
|
108
|
+
// OR the config has a jwtSecret and requireAuth !== false.
|
|
109
|
+
const requireAuth = authAdapter
|
|
110
|
+
? true
|
|
111
|
+
: (authConfig?.requireAuth !== false && !!authConfig?.jwtSecret);
|
|
78
112
|
|
|
79
113
|
wss.on("connection", (ws) => {
|
|
80
114
|
const clientId = `client_${Date.now()}_${Math.random().toString(36).substring(2, 9)}`;
|
|
@@ -123,21 +157,53 @@ code } }
|
|
|
123
157
|
return;
|
|
124
158
|
}
|
|
125
159
|
|
|
126
|
-
|
|
127
|
-
|
|
160
|
+
// Use the auth adapter when available (custom auth, Clerk, etc.)
|
|
161
|
+
// Fall back to JWT extraction otherwise.
|
|
162
|
+
let verifiedUser: WsUserIdentity | null = null;
|
|
163
|
+
|
|
164
|
+
if (authAdapter) {
|
|
165
|
+
try {
|
|
166
|
+
const adapterUser = authAdapter.verifyToken
|
|
167
|
+
? await authAdapter.verifyToken(token)
|
|
168
|
+
: await authAdapter.verifyRequest(new Request("http://localhost/_ws_auth", {
|
|
169
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
170
|
+
}));
|
|
171
|
+
|
|
172
|
+
if (adapterUser) {
|
|
173
|
+
verifiedUser = {
|
|
174
|
+
userId: adapterUser.uid,
|
|
175
|
+
roles: adapterUser.roles,
|
|
176
|
+
isAdmin: adapterUser.isAdmin,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
} catch {
|
|
180
|
+
// Adapter threw — treat as invalid token
|
|
181
|
+
}
|
|
182
|
+
} else {
|
|
183
|
+
// Standard JWT path
|
|
184
|
+
const jwtPayload = extractUserFromToken(token);
|
|
185
|
+
if (jwtPayload) {
|
|
186
|
+
verifiedUser = {
|
|
187
|
+
userId: jwtPayload.userId,
|
|
188
|
+
roles: jwtPayload.roles ?? [],
|
|
189
|
+
isAdmin: (jwtPayload.roles ?? []).some((r: string) => r === "admin"),
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (verifiedUser) {
|
|
128
195
|
const session = clientSessions.get(clientId);
|
|
129
196
|
if (session) {
|
|
130
|
-
session.user =
|
|
197
|
+
session.user = verifiedUser;
|
|
131
198
|
session.authenticated = true;
|
|
132
199
|
}
|
|
133
200
|
wsDebug(`[WS] replying AUTH_SUCCESS for requestId ${requestId}`);
|
|
134
201
|
ws.send(JSON.stringify({
|
|
135
202
|
type: "AUTH_SUCCESS",
|
|
136
203
|
requestId,
|
|
137
|
-
payload: { userId:
|
|
138
|
-
roles: user.roles }
|
|
204
|
+
payload: { userId: verifiedUser.userId, roles: verifiedUser.roles }
|
|
139
205
|
}));
|
|
140
|
-
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${
|
|
206
|
+
wsDebug(`🔐 [WebSocket Server] Client ${clientId} authenticated as ${verifiedUser.userId}`);
|
|
141
207
|
} else {
|
|
142
208
|
wsDebug(`[WS] replying AUTH_ERROR for requestId ${requestId} (invalid token)`);
|
|
143
209
|
sendError("AUTH_ERROR", "INVALID_TOKEN", "Invalid or expired token");
|
|
@@ -183,17 +249,21 @@ roles: user.roles }
|
|
|
183
249
|
// Helper to get correctly scoped delegate for the current request
|
|
184
250
|
const getScopedDelegate = async (): Promise<DataDriver> => {
|
|
185
251
|
const session = clientSessions.get(clientId);
|
|
186
|
-
if (
|
|
252
|
+
if ("withAuth" in driver && typeof (driver as unknown as Record<string, unknown>).withAuth === "function") {
|
|
187
253
|
try {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
254
|
+
const userForAuth = session?.user
|
|
255
|
+
? {
|
|
256
|
+
uid: session.user.userId,
|
|
257
|
+
roles: session.user.roles ?? []
|
|
258
|
+
}
|
|
259
|
+
: {
|
|
260
|
+
uid: "anon",
|
|
261
|
+
roles: ["anon"]
|
|
262
|
+
};
|
|
193
263
|
return await (driver as unknown as { withAuth: (user: Record<string, unknown>) => Promise<DataDriver> }).withAuth(userForAuth);
|
|
194
264
|
} catch (e) {
|
|
195
|
-
console.error("Failed to create
|
|
196
|
-
|
|
265
|
+
console.error("Failed to create RLS scoped delegate for WS request", e);
|
|
266
|
+
throw new Error("Internal authentication error");
|
|
197
267
|
}
|
|
198
268
|
}
|
|
199
269
|
return driver;
|
|
@@ -306,22 +376,29 @@ colors: true }));
|
|
|
306
376
|
|
|
307
377
|
case "EXECUTE_SQL": {
|
|
308
378
|
const { sql, options } = payload;
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
379
|
+
try {
|
|
380
|
+
const delegate = await getScopedDelegate();
|
|
381
|
+
const admin = delegate.admin;
|
|
382
|
+
if (!isSQLAdmin(admin)) {
|
|
383
|
+
sendError("ERROR", "NOT_SUPPORTED", "SQL execution is not available for this driver.");
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
const result = await admin.executeSql(sql, options);
|
|
387
|
+
if (process.env.NODE_ENV !== "production") {
|
|
388
|
+
wsDebug(`⚡ [WebSocket Server] SQL executed. Returned ${Array.isArray(result) ? result.length : "non-array"} rows.`);
|
|
389
|
+
}
|
|
390
|
+
const response = {
|
|
391
|
+
type: "EXECUTE_SQL_SUCCESS",
|
|
392
|
+
payload: { result },
|
|
393
|
+
requestId
|
|
394
|
+
};
|
|
395
|
+
ws.send(JSON.stringify(response));
|
|
396
|
+
} catch (sqlError: unknown) {
|
|
397
|
+
// This is a query execution error (e.g., syntax error, permission denied).
|
|
398
|
+
// We return it cleanly to the client without logging a server stack trace.
|
|
399
|
+
const errMsg = extractErrorMessage(sqlError);
|
|
400
|
+
sendError("ERROR", "SQL_ERROR", errMsg);
|
|
318
401
|
}
|
|
319
|
-
const response = {
|
|
320
|
-
type: "EXECUTE_SQL_SUCCESS",
|
|
321
|
-
payload: { result },
|
|
322
|
-
requestId
|
|
323
|
-
};
|
|
324
|
-
ws.send(JSON.stringify(response));
|
|
325
402
|
}
|
|
326
403
|
break;
|
|
327
404
|
|
|
@@ -478,9 +555,8 @@ colors: true }));
|
|
|
478
555
|
// Attach auth context from the WS session so RLS-aware refetches work
|
|
479
556
|
const session = clientSessions.get(clientId);
|
|
480
557
|
const authContext = session?.user
|
|
481
|
-
? { userId: session.user.userId,
|
|
482
|
-
|
|
483
|
-
: undefined;
|
|
558
|
+
? { userId: session.user.userId, roles: session.user.roles ?? [] }
|
|
559
|
+
: { userId: "anon", roles: ["anon"] };
|
|
484
560
|
// Let RealtimeService handle these messages
|
|
485
561
|
await realtimeService.handleClientMessage(clientId, {
|
|
486
562
|
type,
|
|
@@ -1,19 +1,45 @@
|
|
|
1
1
|
import { NodePgDatabase } from "drizzle-orm/node-postgres";
|
|
2
2
|
import { UserService, RoleService, RefreshTokenService, PasswordResetTokenService, Role } from "../src/auth/services";
|
|
3
|
-
import { users, refreshTokens, passwordResetTokens
|
|
3
|
+
import { users, refreshTokens, passwordResetTokens } from "../src/schema/auth-schema";
|
|
4
|
+
import { UserData } from "@rebasepro/server-core";
|
|
4
5
|
|
|
5
6
|
// Mock the drizzle-orm functions
|
|
6
|
-
jest.mock("drizzle-orm", () =>
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}))
|
|
7
|
+
jest.mock("drizzle-orm", () => {
|
|
8
|
+
const actual = jest.requireActual("drizzle-orm");
|
|
9
|
+
return {
|
|
10
|
+
...actual,
|
|
11
|
+
eq: jest.fn((field, value) => ({ field, value, type: "eq" })),
|
|
12
|
+
sql: Object.assign(
|
|
13
|
+
jest.fn((strings: TemplateStringsArray, ...values: unknown[]) => ({
|
|
14
|
+
strings,
|
|
15
|
+
values,
|
|
16
|
+
type: "sql"
|
|
17
|
+
})),
|
|
18
|
+
{
|
|
19
|
+
raw: jest.fn((val: string) => ({ val, type: "sql-raw" })),
|
|
20
|
+
join: jest.fn((parts: unknown[], separator: unknown) => ({ parts, separator, type: "sql-join" }))
|
|
21
|
+
}
|
|
22
|
+
),
|
|
23
|
+
relations: jest.fn(() => ({}))
|
|
24
|
+
};
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
function mockUserData(overrides: Partial<UserData>): UserData {
|
|
28
|
+
return {
|
|
29
|
+
id: "user-123",
|
|
30
|
+
email: "test@example.com",
|
|
31
|
+
passwordHash: null,
|
|
32
|
+
displayName: null,
|
|
33
|
+
photoUrl: null,
|
|
34
|
+
emailVerified: false,
|
|
35
|
+
emailVerificationToken: null,
|
|
36
|
+
emailVerificationSentAt: null,
|
|
37
|
+
createdAt: expect.any(Date),
|
|
38
|
+
updatedAt: expect.any(Date),
|
|
39
|
+
metadata: {},
|
|
40
|
+
...overrides
|
|
41
|
+
};
|
|
42
|
+
}
|
|
17
43
|
|
|
18
44
|
describe("Auth Services", () => {
|
|
19
45
|
let db: jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
|
|
@@ -37,9 +63,7 @@ describe("Auth Services", () => {
|
|
|
37
63
|
});
|
|
38
64
|
|
|
39
65
|
mockSelectWhere = jest.fn().mockResolvedValue([]);
|
|
40
|
-
mockSelectFrom = jest.fn()
|
|
41
|
-
where: mockSelectWhere
|
|
42
|
-
});
|
|
66
|
+
mockSelectFrom = jest.fn();
|
|
43
67
|
|
|
44
68
|
mockUpdateReturning = jest.fn().mockResolvedValue([]);
|
|
45
69
|
mockUpdateWhere = jest.fn().mockReturnValue({ returning: mockUpdateReturning });
|
|
@@ -49,13 +73,50 @@ describe("Auth Services", () => {
|
|
|
49
73
|
|
|
50
74
|
mockExecute = jest.fn().mockResolvedValue({ rows: [] });
|
|
51
75
|
|
|
76
|
+
// Set up chainable mock for db.select()
|
|
77
|
+
const mockChain: any = {};
|
|
78
|
+
mockChain.from = jest.fn().mockImplementation((...args) => {
|
|
79
|
+
const result = mockSelectFrom(...args);
|
|
80
|
+
if (result && typeof result.then === "function") {
|
|
81
|
+
return result; // If listUsers mocks selectFrom to return a promise, return it directly
|
|
82
|
+
}
|
|
83
|
+
return mockChain;
|
|
84
|
+
});
|
|
85
|
+
mockChain.innerJoin = jest.fn().mockReturnValue(mockChain);
|
|
86
|
+
mockChain.where = jest.fn().mockImplementation((...args) => {
|
|
87
|
+
mockChain.wherePromise = mockSelectWhere(...args);
|
|
88
|
+
return mockChain;
|
|
89
|
+
});
|
|
90
|
+
mockChain.limit = jest.fn().mockReturnValue(mockChain);
|
|
91
|
+
mockChain.offset = jest.fn().mockReturnValue(mockChain);
|
|
92
|
+
mockChain.orderBy = jest.fn().mockReturnValue(mockChain);
|
|
93
|
+
mockChain.then = jest.fn().mockImplementation(async (onFulfilled) => {
|
|
94
|
+
let val;
|
|
95
|
+
if (mockChain.wherePromise) {
|
|
96
|
+
val = await mockChain.wherePromise;
|
|
97
|
+
mockChain.wherePromise = null;
|
|
98
|
+
} else if (mockSelectWhere.mock.calls.length > 0) {
|
|
99
|
+
const result = mockSelectWhere.mock.results[mockSelectWhere.mock.results.length - 1];
|
|
100
|
+
val = result.type === "return" ? result.value : undefined;
|
|
101
|
+
if (val && typeof val.then === "function") {
|
|
102
|
+
val = await val;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
val = [];
|
|
106
|
+
}
|
|
107
|
+
return onFulfilled(val || []);
|
|
108
|
+
});
|
|
109
|
+
|
|
52
110
|
db = {
|
|
53
111
|
insert: jest.fn().mockReturnValue({ values: mockInsertValues }),
|
|
54
|
-
select: jest.fn().mockReturnValue(
|
|
112
|
+
select: jest.fn().mockReturnValue(mockChain),
|
|
55
113
|
update: jest.fn().mockReturnValue({ set: mockUpdateSet }),
|
|
56
114
|
delete: jest.fn().mockReturnValue({ where: mockDeleteWhere }),
|
|
57
115
|
execute: mockExecute
|
|
58
116
|
} as unknown as jest.Mocked<NodePgDatabase<Record<string, unknown>>>;
|
|
117
|
+
|
|
118
|
+
// Set default return value for mockSelectFrom to return mockChain (chainable)
|
|
119
|
+
mockSelectFrom.mockReturnValue(mockChain);
|
|
59
120
|
});
|
|
60
121
|
|
|
61
122
|
describe("UserService", () => {
|
|
@@ -71,30 +132,34 @@ describe("Auth Services", () => {
|
|
|
71
132
|
email: "test@example.com",
|
|
72
133
|
displayName: "Test User"
|
|
73
134
|
};
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
135
|
+
const dbReturnedUser = {
|
|
136
|
+
id: "user-123",
|
|
137
|
+
...newUser,
|
|
138
|
+
createdAt: new Date(),
|
|
139
|
+
updatedAt: new Date()
|
|
140
|
+
};
|
|
141
|
+
mockInsertReturning.mockResolvedValueOnce([dbReturnedUser]);
|
|
79
142
|
|
|
80
143
|
const result = await userService.createUser(newUser);
|
|
81
144
|
|
|
82
145
|
expect(db.insert).toHaveBeenCalledWith(users);
|
|
83
|
-
expect(mockInsertValues).toHaveBeenCalledWith(
|
|
84
|
-
|
|
146
|
+
expect(mockInsertValues).toHaveBeenCalledWith({
|
|
147
|
+
...newUser,
|
|
148
|
+
metadata: {}
|
|
149
|
+
});
|
|
150
|
+
expect(result).toEqual(mockUserData({ displayName: "Test User" }));
|
|
85
151
|
});
|
|
86
152
|
});
|
|
87
153
|
|
|
88
154
|
describe("getUserById", () => {
|
|
89
155
|
it("should return user when found", async () => {
|
|
90
|
-
const mockUser = { id: "user-123",
|
|
91
|
-
email: "test@example.com" };
|
|
156
|
+
const mockUser = { id: "user-123", email: "test@example.com" };
|
|
92
157
|
mockSelectWhere.mockResolvedValueOnce([mockUser]);
|
|
93
158
|
|
|
94
159
|
const result = await userService.getUserById("user-123");
|
|
95
160
|
|
|
96
161
|
expect(db.select).toHaveBeenCalled();
|
|
97
|
-
expect(result).toEqual(
|
|
162
|
+
expect(result).toEqual(mockUserData({}));
|
|
98
163
|
});
|
|
99
164
|
|
|
100
165
|
it("should return null when user not found", async () => {
|
|
@@ -108,13 +173,12 @@ email: "test@example.com" };
|
|
|
108
173
|
|
|
109
174
|
describe("getUserByEmail", () => {
|
|
110
175
|
it("should return user when found by email", async () => {
|
|
111
|
-
const mockUser = { id: "user-123",
|
|
112
|
-
email: "test@example.com" };
|
|
176
|
+
const mockUser = { id: "user-123", email: "test@example.com" };
|
|
113
177
|
mockSelectWhere.mockResolvedValueOnce([mockUser]);
|
|
114
178
|
|
|
115
179
|
const result = await userService.getUserByEmail("test@example.com");
|
|
116
180
|
|
|
117
|
-
expect(result).toEqual(
|
|
181
|
+
expect(result).toEqual(mockUserData({}));
|
|
118
182
|
});
|
|
119
183
|
|
|
120
184
|
it("should lowercase email for lookup", async () => {
|
|
@@ -128,14 +192,14 @@ email: "test@example.com" };
|
|
|
128
192
|
});
|
|
129
193
|
|
|
130
194
|
describe("getUserByIdentity", () => {
|
|
131
|
-
it("should
|
|
132
|
-
const mockUser = { id: "user-123" };
|
|
133
|
-
|
|
134
|
-
mockExecute.mockResolvedValueOnce({ rows: [mockUser] });
|
|
195
|
+
it("should fetch user by identity", async () => {
|
|
196
|
+
const mockUser = { id: "user-123", email: "test@example.com" };
|
|
197
|
+
mockSelectWhere.mockResolvedValueOnce([{ user: mockUser }]);
|
|
135
198
|
|
|
136
199
|
const result = await userService.getUserByIdentity("google", "google-abc");
|
|
137
200
|
|
|
138
|
-
expect(
|
|
201
|
+
expect(db.select).toHaveBeenCalled();
|
|
202
|
+
expect(result).toEqual(expect.objectContaining({ id: "user-123", email: "test@example.com" }));
|
|
139
203
|
});
|
|
140
204
|
});
|
|
141
205
|
|
|
@@ -158,9 +222,11 @@ email: "test@example.com" };
|
|
|
158
222
|
|
|
159
223
|
describe("updateUser", () => {
|
|
160
224
|
it("should update user and return updated record", async () => {
|
|
161
|
-
const updatedUser = {
|
|
162
|
-
|
|
163
|
-
|
|
225
|
+
const updatedUser = {
|
|
226
|
+
id: "user-123",
|
|
227
|
+
email: "test@example.com",
|
|
228
|
+
displayName: "Updated Name"
|
|
229
|
+
};
|
|
164
230
|
mockUpdateReturning.mockResolvedValueOnce([updatedUser]);
|
|
165
231
|
|
|
166
232
|
const result = await userService.updateUser("user-123", { displayName: "Updated Name" });
|
|
@@ -170,7 +236,7 @@ displayName: "Updated Name" };
|
|
|
170
236
|
displayName: "Updated Name",
|
|
171
237
|
updatedAt: expect.any(Date)
|
|
172
238
|
}));
|
|
173
|
-
expect(result).toEqual(
|
|
239
|
+
expect(result).toEqual(mockUserData({ displayName: "Updated Name" }));
|
|
174
240
|
});
|
|
175
241
|
|
|
176
242
|
it("should return null when user not found", async () => {
|
|
@@ -194,17 +260,18 @@ displayName: "Updated Name" };
|
|
|
194
260
|
describe("listUsers", () => {
|
|
195
261
|
it("should return all users", async () => {
|
|
196
262
|
const mockUsers = [
|
|
197
|
-
{ id: "user-1",
|
|
198
|
-
email: "
|
|
199
|
-
{ id: "user-2",
|
|
200
|
-
email: "user2@example.com" }
|
|
263
|
+
{ id: "user-1", email: "user1@example.com" },
|
|
264
|
+
{ id: "user-2", email: "user2@example.com" }
|
|
201
265
|
];
|
|
202
266
|
mockSelectFrom.mockReturnValueOnce(Promise.resolve(mockUsers));
|
|
203
267
|
|
|
204
268
|
const result = await userService.listUsers();
|
|
205
269
|
|
|
206
270
|
expect(db.select).toHaveBeenCalled();
|
|
207
|
-
expect(result).toEqual(
|
|
271
|
+
expect(result).toEqual([
|
|
272
|
+
mockUserData({ id: "user-1", email: "user1@example.com" }),
|
|
273
|
+
mockUserData({ id: "user-2", email: "user2@example.com" })
|
|
274
|
+
]);
|
|
208
275
|
});
|
|
209
276
|
});
|
|
210
277
|
|
|
@@ -264,13 +331,12 @@ email: "user2@example.com" }
|
|
|
264
331
|
|
|
265
332
|
describe("getUserByVerificationToken", () => {
|
|
266
333
|
it("should find user by verification token", async () => {
|
|
267
|
-
const mockUser = { id: "user-123",
|
|
268
|
-
email: "test@example.com" };
|
|
334
|
+
const mockUser = { id: "user-123", email: "test@example.com" };
|
|
269
335
|
mockSelectWhere.mockResolvedValueOnce([mockUser]);
|
|
270
336
|
|
|
271
337
|
const result = await userService.getUserByVerificationToken("token-abc");
|
|
272
338
|
|
|
273
|
-
expect(result).toEqual(
|
|
339
|
+
expect(result).toEqual(mockUserData({}));
|
|
274
340
|
});
|
|
275
341
|
});
|
|
276
342
|
|
|
@@ -279,17 +345,17 @@ email: "test@example.com" };
|
|
|
279
345
|
mockExecute.mockResolvedValueOnce({
|
|
280
346
|
rows: [
|
|
281
347
|
{ id: "admin",
|
|
282
|
-
name: "Admin",
|
|
283
|
-
is_admin: true,
|
|
284
|
-
default_permissions: null,
|
|
285
|
-
collection_permissions: null,
|
|
286
|
-
config: null },
|
|
348
|
+
name: "Admin",
|
|
349
|
+
is_admin: true,
|
|
350
|
+
default_permissions: null,
|
|
351
|
+
collection_permissions: null,
|
|
352
|
+
config: null },
|
|
287
353
|
{ id: "editor",
|
|
288
|
-
name: "Editor",
|
|
289
|
-
is_admin: false,
|
|
290
|
-
default_permissions: { edit: true },
|
|
291
|
-
collection_permissions: null,
|
|
292
|
-
config: null }
|
|
354
|
+
name: "Editor",
|
|
355
|
+
is_admin: false,
|
|
356
|
+
default_permissions: { edit: true },
|
|
357
|
+
collection_permissions: null,
|
|
358
|
+
config: null }
|
|
293
359
|
]
|
|
294
360
|
});
|
|
295
361
|
|
|
@@ -312,11 +378,11 @@ config: null }
|
|
|
312
378
|
mockExecute.mockResolvedValueOnce({
|
|
313
379
|
rows: [
|
|
314
380
|
{ id: "admin",
|
|
315
|
-
name: "Admin",
|
|
316
|
-
is_admin: true,
|
|
317
|
-
default_permissions: null,
|
|
318
|
-
collection_permissions: null,
|
|
319
|
-
config: null }
|
|
381
|
+
name: "Admin",
|
|
382
|
+
is_admin: true,
|
|
383
|
+
default_permissions: null,
|
|
384
|
+
collection_permissions: null,
|
|
385
|
+
config: null }
|
|
320
386
|
]
|
|
321
387
|
});
|
|
322
388
|
|
|
@@ -353,28 +419,27 @@ config: null }
|
|
|
353
419
|
|
|
354
420
|
describe("getUserWithRoles", () => {
|
|
355
421
|
it("should return user with roles", async () => {
|
|
356
|
-
const mockUser = { id: "user-123",
|
|
357
|
-
email: "test@example.com" };
|
|
422
|
+
const mockUser = { id: "user-123", email: "test@example.com" };
|
|
358
423
|
mockSelectWhere.mockResolvedValueOnce([mockUser]);
|
|
359
424
|
mockExecute.mockResolvedValueOnce({
|
|
360
425
|
rows: [{ id: "admin",
|
|
361
|
-
name: "Admin",
|
|
362
|
-
is_admin: true,
|
|
363
|
-
default_permissions: null,
|
|
364
|
-
collection_permissions: null,
|
|
365
|
-
config: null }]
|
|
426
|
+
name: "Admin",
|
|
427
|
+
is_admin: true,
|
|
428
|
+
default_permissions: null,
|
|
429
|
+
collection_permissions: null,
|
|
430
|
+
config: null }]
|
|
366
431
|
});
|
|
367
432
|
|
|
368
433
|
const result = await userService.getUserWithRoles("user-123");
|
|
369
434
|
|
|
370
435
|
expect(result).toEqual({
|
|
371
|
-
user:
|
|
436
|
+
user: mockUserData({}),
|
|
372
437
|
roles: [{ id: "admin",
|
|
373
|
-
name: "Admin",
|
|
374
|
-
isAdmin: true,
|
|
375
|
-
defaultPermissions: null,
|
|
376
|
-
collectionPermissions: null,
|
|
377
|
-
config: null }]
|
|
438
|
+
name: "Admin",
|
|
439
|
+
isAdmin: true,
|
|
440
|
+
defaultPermissions: null,
|
|
441
|
+
collectionPermissions: null,
|
|
442
|
+
config: null }]
|
|
378
443
|
});
|
|
379
444
|
});
|
|
380
445
|
|
|
@@ -386,6 +451,30 @@ config: null }]
|
|
|
386
451
|
expect(result).toBeNull();
|
|
387
452
|
});
|
|
388
453
|
});
|
|
454
|
+
|
|
455
|
+
describe("listUsersPaginated", () => {
|
|
456
|
+
it("should return paginated and filtered users list", async () => {
|
|
457
|
+
mockExecute
|
|
458
|
+
.mockResolvedValueOnce({ rows: [{ total: 1 }] })
|
|
459
|
+
.mockResolvedValueOnce({ rows: [{ id: "user-123", email: "test@example.com" }] });
|
|
460
|
+
|
|
461
|
+
const result = await userService.listUsersPaginated({
|
|
462
|
+
limit: 10,
|
|
463
|
+
offset: 0,
|
|
464
|
+
search: "test",
|
|
465
|
+
orderBy: "email",
|
|
466
|
+
orderDir: "asc"
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
expect(mockExecute).toHaveBeenCalledTimes(2);
|
|
470
|
+
expect(result).toEqual({
|
|
471
|
+
users: [mockUserData({ id: "user-123", email: "test@example.com" })],
|
|
472
|
+
total: 1,
|
|
473
|
+
limit: 10,
|
|
474
|
+
offset: 0
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
});
|
|
389
478
|
});
|
|
390
479
|
|
|
391
480
|
describe("RoleService", () => {
|
|
@@ -415,3 +415,60 @@ describe("structural dunder guard", () => {
|
|
|
415
415
|
expect(dunderKeys).toEqual([]);
|
|
416
416
|
});
|
|
417
417
|
});
|
|
418
|
+
|
|
419
|
+
// ─────────────────────────────────────────────────────────────
|
|
420
|
+
// parsePropertyFromServer — vector parsing
|
|
421
|
+
// ─────────────────────────────────────────────────────────────
|
|
422
|
+
describe("parsePropertyFromServer vector parsing", () => {
|
|
423
|
+
const targetCollection = makeCollection("items", {
|
|
424
|
+
embedding: {
|
|
425
|
+
type: "vector",
|
|
426
|
+
dimensions: 3,
|
|
427
|
+
name: "Embedding"
|
|
428
|
+
} as unknown as Property
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
it("parses string representation '[0.1,-0.2,3.14]' from postgres into wrapped vector", () => {
|
|
432
|
+
const property = targetCollection.properties.embedding as Property;
|
|
433
|
+
const result = parsePropertyFromServer("[0.1,-0.2,3.14]", property, targetCollection, "embedding");
|
|
434
|
+
expect(result).toEqual({
|
|
435
|
+
__type: "Vector",
|
|
436
|
+
value: [0.1, -0.2, 3.14]
|
|
437
|
+
});
|
|
438
|
+
});
|
|
439
|
+
|
|
440
|
+
it("parses numeric array [0.1,-0.2,3.14] into wrapped vector", () => {
|
|
441
|
+
const property = targetCollection.properties.embedding as Property;
|
|
442
|
+
const result = parsePropertyFromServer([0.1, -0.2, 3.14], property, targetCollection, "embedding");
|
|
443
|
+
expect(result).toEqual({
|
|
444
|
+
__type: "Vector",
|
|
445
|
+
value: [0.1, -0.2, 3.14]
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
// ─────────────────────────────────────────────────────────────
|
|
451
|
+
// parsePropertyFromServer — binary parsing
|
|
452
|
+
// ─────────────────────────────────────────────────────────────
|
|
453
|
+
describe("parsePropertyFromServer binary parsing", () => {
|
|
454
|
+
const targetCollection = makeCollection("items", {
|
|
455
|
+
data: {
|
|
456
|
+
type: "binary",
|
|
457
|
+
name: "Data"
|
|
458
|
+
} as unknown as Property
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("parses database Buffer into a base64 data URL string", () => {
|
|
462
|
+
const property = targetCollection.properties.data as Property;
|
|
463
|
+
const buffer = Buffer.from("hello");
|
|
464
|
+
const result = parsePropertyFromServer(buffer, property, targetCollection, "data");
|
|
465
|
+
expect(result).toBe("data:application/octet-stream;base64,aGVsbG8=");
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
it("parses Buffer object (JSON serialization) into a base64 data URL string", () => {
|
|
469
|
+
const property = targetCollection.properties.data as Property;
|
|
470
|
+
const bufferObj = { type: "Buffer", data: Array.from(Buffer.from("hello")) };
|
|
471
|
+
const result = parsePropertyFromServer(bufferObj, property, targetCollection, "data");
|
|
472
|
+
expect(result).toBe("data:application/octet-stream;base64,aGVsbG8=");
|
|
473
|
+
});
|
|
474
|
+
});
|