@aprovan/chat-backend 0.1.0-dev.99f9769 → 0.1.0-dev.abe9883

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.
@@ -14,9 +14,10 @@ vi.mock("@aws-sdk/lib-dynamodb", () => ({
14
14
  from: vi.fn(() => ({ send: mockSend })),
15
15
  },
16
16
  QueryCommand: vi.fn((input) => input),
17
+ GetCommand: vi.fn((input) => input),
17
18
  }));
18
19
 
19
- const { workspaceMiddleware, resolveWorkspaceId } = await import(
20
+ const { workspaceMiddleware, resolveWorkspaceId, evictWorkspaceCache } = await import(
20
21
  "../../src/middleware/workspace"
21
22
  );
22
23
 
@@ -41,15 +42,70 @@ describe("workspaceMiddleware", () => {
41
42
  beforeEach(() => {
42
43
  vi.clearAllMocks();
43
44
  process.env["MEMBERSHIPS_TABLE_NAME"] = "gateway-prd-use1-memberships";
45
+ process.env["USERS_TABLE_NAME"] = "gateway-prd-use1-users";
44
46
  process.env["AWS_REGION"] = "us-east-1";
45
- // Clear the module-level cache between tests by resetting the module state.
46
- // resolveWorkspaceId memoizes per sub; tests use unique subs or clear cache.
47
+ // Clear module-level cache between tests
48
+ evictWorkspaceCache("user-sub-123");
49
+ evictWorkspaceCache("user-sub-456");
50
+ evictWorkspaceCache("user-no-ws");
51
+ evictWorkspaceCache("user-cached");
52
+ evictWorkspaceCache("user-users-table");
53
+ evictWorkspaceCache("user-fallback");
47
54
  });
48
55
 
49
- it("resolves workspaceId from DDB and sets it on context", async () => {
56
+ it("uses activeWorkspaceId from Users table when present", async () => {
57
+ // Users table returns activeWorkspaceId — no Memberships query needed
50
58
  mockSend.mockResolvedValueOnce({
51
- Items: [{ workspaceId: "ws-abc", userSub: "user-sub-456" }],
59
+ Item: { sub: "user-users-table", activeWorkspaceId: "ws-from-users" },
60
+ });
61
+
62
+ const app = new Hono<{ Variables: AppVariables }>();
63
+ const localClaims = { sub: "user-users-table" } as unknown as CognitoAccessTokenPayload;
64
+ app.use("/protected", async (c, next) => {
65
+ c.set("claims", localClaims);
66
+ await next();
67
+ });
68
+ app.use("/protected", workspaceMiddleware);
69
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
70
+
71
+ const res = await app.request("/protected");
72
+ expect(res.status).toBe(200);
73
+ const body = await res.json();
74
+ expect(body).toEqual({ workspaceId: "ws-from-users" });
75
+ // Only one DDB call (Users Get), no Memberships query
76
+ expect(mockSend).toHaveBeenCalledTimes(1);
77
+ });
78
+
79
+ it("falls back to Memberships when Users table has no activeWorkspaceId", async () => {
80
+ // Users Get returns no item; Memberships query returns ws-from-memberships
81
+ mockSend
82
+ .mockResolvedValueOnce({}) // Users GetCommand → no item
83
+ .mockResolvedValueOnce({
84
+ Items: [{ workspaceId: "ws-from-memberships", userSub: "user-fallback" }],
85
+ }); // Memberships QueryCommand
86
+
87
+ const app = new Hono<{ Variables: AppVariables }>();
88
+ const localClaims = { sub: "user-fallback" } as unknown as CognitoAccessTokenPayload;
89
+ app.use("/protected", async (c, next) => {
90
+ c.set("claims", localClaims);
91
+ await next();
52
92
  });
93
+ app.use("/protected", workspaceMiddleware);
94
+ app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
95
+
96
+ const res = await app.request("/protected");
97
+ expect(res.status).toBe(200);
98
+ const body = await res.json();
99
+ expect(body).toEqual({ workspaceId: "ws-from-memberships" });
100
+ expect(mockSend).toHaveBeenCalledTimes(2);
101
+ });
102
+
103
+ it("resolves workspaceId from DDB and sets it on context", async () => {
104
+ mockSend
105
+ .mockResolvedValueOnce({}) // Users GetCommand → no item
106
+ .mockResolvedValueOnce({
107
+ Items: [{ workspaceId: "ws-abc", userSub: "user-sub-456" }],
108
+ });
53
109
 
54
110
  const app = new Hono<{ Variables: AppVariables }>();
55
111
  const localClaims = { sub: "user-sub-456" } as unknown as CognitoAccessTokenPayload;
@@ -66,8 +122,10 @@ describe("workspaceMiddleware", () => {
66
122
  expect(body).toEqual({ workspaceId: "ws-abc" });
67
123
  });
68
124
 
69
- it("returns 403 when no membership exists", async () => {
70
- mockSend.mockResolvedValueOnce({ Items: [] });
125
+ it("returns 403 when no membership exists and Users table has no activeWorkspaceId", async () => {
126
+ mockSend
127
+ .mockResolvedValueOnce({}) // Users GetCommand → no item
128
+ .mockResolvedValueOnce({ Items: [] }); // Memberships QueryCommand → empty
71
129
 
72
130
  const app = new Hono<{ Variables: AppVariables }>();
73
131
  const localClaims = {
@@ -88,7 +146,7 @@ describe("workspaceMiddleware", () => {
88
146
 
89
147
  it("uses cache on subsequent calls for the same sub", async () => {
90
148
  mockSend.mockResolvedValueOnce({
91
- Items: [{ workspaceId: "ws-cached", userSub: "user-cached" }],
149
+ Item: { activeWorkspaceId: "ws-cached" },
92
150
  });
93
151
 
94
152
  // First call — hits DDB
@@ -98,4 +156,17 @@ describe("workspaceMiddleware", () => {
98
156
 
99
157
  expect(mockSend).toHaveBeenCalledTimes(1);
100
158
  });
159
+
160
+ it("evictWorkspaceCache clears the cache so next call re-fetches", async () => {
161
+ mockSend
162
+ .mockResolvedValueOnce({ Item: { activeWorkspaceId: "ws-v1" } })
163
+ .mockResolvedValueOnce({ Item: { activeWorkspaceId: "ws-v2" } });
164
+
165
+ await resolveWorkspaceId("user-cached");
166
+ evictWorkspaceCache("user-cached");
167
+ const result = await resolveWorkspaceId("user-cached");
168
+
169
+ expect(mockSend).toHaveBeenCalledTimes(2);
170
+ expect(result).toBe("ws-v2");
171
+ });
101
172
  });
@@ -0,0 +1,436 @@
1
+ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
2
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
3
+
4
+ vi.mock("aws-jwt-verify", () => {
5
+ const verify = vi.fn();
6
+ return {
7
+ CognitoJwtVerifier: {
8
+ create: vi.fn(() => ({ verify })),
9
+ },
10
+ };
11
+ });
12
+
13
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
14
+ DynamoDBClient: vi.fn(() => ({})),
15
+ ConditionalCheckFailedException: class ConditionalCheckFailedException extends Error {
16
+ name = "ConditionalCheckFailedException";
17
+ },
18
+ }));
19
+
20
+ const mockDdbSend = vi.fn();
21
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
22
+ DynamoDBDocumentClient: {
23
+ from: vi.fn(() => ({ send: mockDdbSend })),
24
+ },
25
+ UpdateCommand: vi.fn((input) => input),
26
+ GetCommand: vi.fn((input) => input),
27
+ QueryCommand: vi.fn((input) => input),
28
+ DeleteCommand: vi.fn((input) => input),
29
+ }));
30
+
31
+ const mockS3Send = vi.fn();
32
+ vi.mock("@aws-sdk/client-s3", () => ({
33
+ S3Client: vi.fn(() => ({ send: mockS3Send })),
34
+ GetObjectCommand: vi.fn((input) => input),
35
+ PutObjectCommand: vi.fn((input) => input),
36
+ DeleteObjectCommand: vi.fn((input) => input),
37
+ }));
38
+
39
+ beforeAll(() => {
40
+ process.env["COGNITO_USER_POOL_ID"] = "us-east-1_test";
41
+ process.env["COGNITO_CLIENT_ID"] = "test-client-id";
42
+ process.env["AWS_REGION"] = "us-east-1";
43
+ process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
44
+ process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
45
+ process.env["USERS_TABLE_NAME"] = "test-users";
46
+ process.env["USER_SESSIONS_TABLE_NAME"] = "test-sessions";
47
+ });
48
+
49
+ const { createChatApp } = await import("../../src/app");
50
+
51
+ const USER_SUB = "vfs-test-unique-user-sub";
52
+ const WORKSPACE_ID = "ws-test-vfs-123";
53
+ const BEARER = "Bearer valid.token.here";
54
+
55
+ async function setupAuth() {
56
+ const { CognitoJwtVerifier } = await import("aws-jwt-verify");
57
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
58
+ (CognitoJwtVerifier.create({} as any) as any).verify.mockResolvedValue({
59
+ sub: USER_SUB,
60
+ } as unknown as CognitoAccessTokenPayload);
61
+ }
62
+
63
+ /**
64
+ * Install a mockImplementation that dispatches on table name so the workspace
65
+ * middleware's cache behaviour (hit vs miss) never affects VFS query mocks.
66
+ * Workspace calls go to "test-users"; VFS calls go to the optional "test-vfs".
67
+ */
68
+ function setupDdbRouter(vfsItems?: Array<Record<string, unknown>>) {
69
+ mockDdbSend.mockImplementation(
70
+ (command: { TableName?: string; Key?: unknown }) => {
71
+ if (command.TableName === "test-users") {
72
+ // Workspace resolution — GetCommand
73
+ return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
74
+ }
75
+ if (command.TableName === "test-memberships") {
76
+ // Fallback membership query (should not be reached)
77
+ return Promise.resolve({ Items: [{ workspaceId: WORKSPACE_ID }] });
78
+ }
79
+ if (command.TableName === "test-vfs") {
80
+ return Promise.resolve({ Items: vfsItems ?? [] });
81
+ }
82
+ return Promise.resolve({});
83
+ },
84
+ );
85
+ }
86
+
87
+ describe("GET /vfs?since=", () => {
88
+ beforeEach(() => {
89
+ vi.clearAllMocks();
90
+ delete process.env["VFS_TABLE_NAME"];
91
+ });
92
+
93
+ it("returns 401 without Authorization header", async () => {
94
+ const app = createChatApp();
95
+ const res = await app.request("/vfs?since=2026-01-01T00:00:00Z");
96
+ expect(res.status).toBe(401);
97
+ });
98
+
99
+ it("returns 400 when since is missing", async () => {
100
+ await setupAuth();
101
+ setupDdbRouter();
102
+
103
+ const app = createChatApp();
104
+ const res = await app.request("/vfs", {
105
+ headers: { Authorization: BEARER },
106
+ });
107
+ expect(res.status).toBe(400);
108
+ const body = await res.json() as { error: string };
109
+ expect(body.error).toMatch(/since/);
110
+ });
111
+
112
+ it("returns 400 when since is not a valid date", async () => {
113
+ await setupAuth();
114
+ setupDdbRouter();
115
+
116
+ const app = createChatApp();
117
+ const res = await app.request("/vfs?since=not-a-date", {
118
+ headers: { Authorization: BEARER },
119
+ });
120
+ expect(res.status).toBe(400);
121
+ const body = await res.json() as { error: string };
122
+ expect(body.error).toMatch(/RFC3339/);
123
+ });
124
+
125
+ it("returns [] when VFS_TABLE_NAME is not configured", async () => {
126
+ await setupAuth();
127
+ setupDdbRouter();
128
+
129
+ const app = createChatApp();
130
+ const res = await app.request("/vfs?since=2026-01-01T00:00:00Z", {
131
+ headers: { Authorization: BEARER },
132
+ });
133
+ expect(res.status).toBe(200);
134
+ const body = await res.json();
135
+ expect(body).toEqual([]);
136
+ });
137
+
138
+ it("queries DDB and returns changed files when table is configured", async () => {
139
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
140
+ await setupAuth();
141
+
142
+ const since = "2026-07-01T00:00:00.000Z";
143
+ const fakeItems = [
144
+ { SK: "file#src/index.ts", mtime: "2026-07-11T10:00:00.000Z", version: 3, size: 512 },
145
+ { SK: "file#src/app.tsx", mtime: "2026-07-11T10:05:00.000Z", version: 1, size: 1024 },
146
+ ];
147
+ setupDdbRouter(fakeItems);
148
+
149
+ const app = createChatApp();
150
+ const res = await app.request(`/vfs?since=${encodeURIComponent(since)}`, {
151
+ headers: { Authorization: BEARER },
152
+ });
153
+
154
+ expect(res.status).toBe(200);
155
+ const body = await res.json() as Array<{ path: string; mtime: string; version: number; size: number }>;
156
+ expect(body).toHaveLength(2);
157
+ expect(body[0]).toMatchObject({ path: "src/index.ts", version: 3, size: 512 });
158
+ expect(body[1]).toMatchObject({ path: "src/app.tsx", version: 1, size: 1024 });
159
+
160
+ // Verify the VFS query used the right PK and since filter.
161
+ const vfsCall = mockDdbSend.mock.calls
162
+ .map((c) => c[0] as { TableName?: string; ExpressionAttributeValues?: Record<string, unknown> })
163
+ .find((c) => c.TableName === "test-vfs");
164
+ expect(vfsCall).toBeDefined();
165
+ expect(vfsCall!.ExpressionAttributeValues?.[":pk"]).toBe(`workspace#${WORKSPACE_ID}`);
166
+ expect(vfsCall!.ExpressionAttributeValues?.[":since"]).toBe(since);
167
+ });
168
+
169
+ it("strips file# prefix from SK in the response path", async () => {
170
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
171
+ await setupAuth();
172
+ setupDdbRouter([{ SK: "file#components/Button.tsx", mtime: "2026-07-11T00:00:00.000Z" }]);
173
+
174
+ const app = createChatApp();
175
+ const res = await app.request("/vfs?since=2026-01-01T00:00:00Z", {
176
+ headers: { Authorization: BEARER },
177
+ });
178
+
179
+ const body = await res.json() as Array<{ path: string }>;
180
+ expect(body[0]?.path).toBe("components/Button.tsx");
181
+ });
182
+ });
183
+
184
+ describe("GET /vfs/config", () => {
185
+ beforeEach(async () => {
186
+ vi.clearAllMocks();
187
+ await setupAuth();
188
+ setupDdbRouter();
189
+ });
190
+
191
+ it("returns { usePaths: true }", async () => {
192
+ const app = createChatApp();
193
+ const res = await app.request("/vfs/config", {
194
+ headers: { Authorization: BEARER },
195
+ });
196
+ expect(res.status).toBe(200);
197
+ const body = await res.json() as { usePaths: boolean };
198
+ expect(body.usePaths).toBe(true);
199
+ });
200
+ });
201
+
202
+ describe("HEAD /vfs/:path", () => {
203
+ beforeEach(async () => {
204
+ vi.clearAllMocks();
205
+ await setupAuth();
206
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
207
+ });
208
+
209
+ it("returns 404 when VFS_TABLE_NAME is not set", async () => {
210
+ delete process.env["VFS_TABLE_NAME"];
211
+ setupDdbRouter();
212
+ const app = createChatApp();
213
+ const res = await app.request("/vfs/src/index.ts", { method: "HEAD", headers: { Authorization: BEARER } });
214
+ expect(res.status).toBe(404);
215
+ });
216
+
217
+ it("returns 200 when item exists in DDB", async () => {
218
+ mockDdbSend.mockImplementation((cmd: { TableName?: string; Key?: unknown }) => {
219
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
220
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Item: { SK: "file#src/index.ts" } });
221
+ return Promise.resolve({});
222
+ });
223
+
224
+ const app = createChatApp();
225
+ const res = await app.request("/vfs/src/index.ts", { method: "HEAD", headers: { Authorization: BEARER } });
226
+ expect(res.status).toBe(200);
227
+ });
228
+
229
+ it("returns 404 when item not found in DDB", async () => {
230
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
231
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
232
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Item: undefined });
233
+ return Promise.resolve({});
234
+ });
235
+
236
+ const app = createChatApp();
237
+ const res = await app.request("/vfs/missing.ts", { method: "HEAD", headers: { Authorization: BEARER } });
238
+ expect(res.status).toBe(404);
239
+ });
240
+ });
241
+
242
+ describe("GET /vfs/:path?stat=true", () => {
243
+ beforeEach(async () => {
244
+ vi.clearAllMocks();
245
+ await setupAuth();
246
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
247
+ });
248
+
249
+ it("returns file metadata from DDB", async () => {
250
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
251
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
252
+ if (cmd.TableName === "test-vfs") return Promise.resolve({
253
+ Item: { size: 1024, mtime: "2026-07-11T00:00:00.000Z", version: 5 },
254
+ });
255
+ return Promise.resolve({});
256
+ });
257
+
258
+ const app = createChatApp();
259
+ const res = await app.request("/vfs/src/index.ts?stat=true", { headers: { Authorization: BEARER } });
260
+ expect(res.status).toBe(200);
261
+ const body = await res.json() as { size: number; mtime: string; isFile: boolean; isDirectory: boolean };
262
+ expect(body.size).toBe(1024);
263
+ expect(body.isFile).toBe(true);
264
+ expect(body.isDirectory).toBe(false);
265
+ });
266
+
267
+ it("returns 404 when file not in DDB", async () => {
268
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
269
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
270
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Item: undefined });
271
+ return Promise.resolve({});
272
+ });
273
+
274
+ const app = createChatApp();
275
+ const res = await app.request("/vfs/nope.ts?stat=true", { headers: { Authorization: BEARER } });
276
+ expect(res.status).toBe(404);
277
+ });
278
+ });
279
+
280
+ describe("GET /vfs/:path?readdir=true", () => {
281
+ beforeEach(async () => {
282
+ vi.clearAllMocks();
283
+ await setupAuth();
284
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
285
+ });
286
+
287
+ it("collapses DDB items to one-level directory entries", async () => {
288
+ // Mock returns only items that DDB's begins_with(SK, "file#src/") would select.
289
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
290
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
291
+ if (cmd.TableName === "test-vfs") return Promise.resolve({
292
+ Items: [
293
+ { SK: "file#src/index.ts" },
294
+ { SK: "file#src/app.tsx" },
295
+ { SK: "file#src/lib/utils.ts" },
296
+ ],
297
+ });
298
+ return Promise.resolve({});
299
+ });
300
+
301
+ const app = createChatApp();
302
+ const res = await app.request("/vfs/src?readdir=true", { headers: { Authorization: BEARER } });
303
+ expect(res.status).toBe(200);
304
+ const body = await res.json() as Array<{ name: string; isDirectory: boolean }>;
305
+ const names = body.map((e) => e.name).sort();
306
+ expect(names).toEqual(["app.tsx", "index.ts", "lib"]);
307
+ expect(body.find((e) => e.name === "lib")?.isDirectory).toBe(true);
308
+ expect(body.find((e) => e.name === "index.ts")?.isDirectory).toBe(false);
309
+ });
310
+ });
311
+
312
+ describe("PUT /vfs/:path", () => {
313
+ beforeEach(async () => {
314
+ vi.clearAllMocks();
315
+ await setupAuth();
316
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
317
+ process.env["VFS_BUCKET_NAME"] = "test-vfs-bucket";
318
+ });
319
+
320
+ it("returns 503 when VFS not configured", async () => {
321
+ delete process.env["VFS_TABLE_NAME"];
322
+ delete process.env["VFS_BUCKET_NAME"];
323
+ setupDdbRouter();
324
+
325
+ const app = createChatApp();
326
+ const res = await app.request("/vfs/src/index.ts", {
327
+ method: "PUT",
328
+ headers: { Authorization: BEARER, "Content-Type": "text/plain" },
329
+ body: "const x = 1;",
330
+ });
331
+ expect(res.status).toBe(503);
332
+ });
333
+
334
+ it("writes to S3 and DDB, returns ok+version", async () => {
335
+ mockS3Send.mockResolvedValue({});
336
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
337
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
338
+ if (cmd.TableName === "test-vfs") return Promise.resolve({ Attributes: { version: 1 } });
339
+ return Promise.resolve({});
340
+ });
341
+
342
+ const app = createChatApp();
343
+ const res = await app.request("/vfs/src/index.ts", {
344
+ method: "PUT",
345
+ headers: { Authorization: BEARER, "Content-Type": "text/plain" },
346
+ body: "const x = 1;",
347
+ });
348
+ expect(res.status).toBe(200);
349
+ const body = await res.json() as { ok: boolean; version: number };
350
+ expect(body.ok).toBe(true);
351
+ expect(body.version).toBe(1);
352
+ expect(mockS3Send).toHaveBeenCalledTimes(1);
353
+ });
354
+
355
+ it("returns 409 on concurrent write conflict", async () => {
356
+ const { ConditionalCheckFailedException } = await import("@aws-sdk/client-dynamodb");
357
+ mockS3Send.mockResolvedValue({});
358
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
359
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
360
+ if (cmd.TableName === "test-vfs") {
361
+ // First call (UpdateCommand) throws; second call (GetCommand for conflict metadata) succeeds
362
+ if (mockDdbSend.mock.calls.filter((c) => (c[0] as { TableName?: string }).TableName === "test-vfs").length === 1) {
363
+ throw new ConditionalCheckFailedException("conflict");
364
+ }
365
+ return Promise.resolve({ Item: { version: 3 } });
366
+ }
367
+ return Promise.resolve({});
368
+ });
369
+
370
+ const app = createChatApp();
371
+ const res = await app.request("/vfs/src/index.ts", {
372
+ method: "PUT",
373
+ headers: {
374
+ Authorization: BEARER,
375
+ "Content-Type": "text/plain",
376
+ "X-Vfs-Expected-Version": "2",
377
+ },
378
+ body: "conflict",
379
+ });
380
+ expect(res.status).toBe(409);
381
+ const body = await res.json() as { ok: boolean; conflict: { serverVersion: number } };
382
+ expect(body.ok).toBe(false);
383
+ expect(body.conflict.serverVersion).toBe(3);
384
+ });
385
+ });
386
+
387
+ describe("DELETE /vfs/:path", () => {
388
+ beforeEach(async () => {
389
+ vi.clearAllMocks();
390
+ await setupAuth();
391
+ process.env["VFS_TABLE_NAME"] = "test-vfs";
392
+ process.env["VFS_BUCKET_NAME"] = "test-vfs-bucket";
393
+ });
394
+
395
+ it("deletes from DDB and S3, returns 204", async () => {
396
+ mockS3Send.mockResolvedValue({});
397
+ mockDdbSend.mockImplementation((cmd: { TableName?: string }) => {
398
+ if (cmd.TableName === "test-users") return Promise.resolve({ Item: { activeWorkspaceId: WORKSPACE_ID } });
399
+ return Promise.resolve({});
400
+ });
401
+
402
+ const app = createChatApp();
403
+ const res = await app.request("/vfs/src/old.ts", {
404
+ method: "DELETE",
405
+ headers: { Authorization: BEARER },
406
+ });
407
+ expect(res.status).toBe(204);
408
+ expect(mockS3Send).toHaveBeenCalledTimes(1);
409
+ });
410
+ });
411
+
412
+ describe("POST /vfs/:path?mkdir=true", () => {
413
+ beforeEach(async () => {
414
+ vi.clearAllMocks();
415
+ await setupAuth();
416
+ setupDdbRouter();
417
+ });
418
+
419
+ it("returns 204 (no-op)", async () => {
420
+ const app = createChatApp();
421
+ const res = await app.request("/vfs/src/components?mkdir=true", {
422
+ method: "POST",
423
+ headers: { Authorization: BEARER },
424
+ });
425
+ expect(res.status).toBe(204);
426
+ });
427
+
428
+ it("returns 400 for unknown POST operations", async () => {
429
+ const app = createChatApp();
430
+ const res = await app.request("/vfs/src/something", {
431
+ method: "POST",
432
+ headers: { Authorization: BEARER },
433
+ });
434
+ expect(res.status).toBe(400);
435
+ });
436
+ });
@@ -0,0 +1,107 @@
1
+ import { describe, it, expect, vi, beforeEach, beforeAll } from "vitest";
2
+ import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
3
+
4
+ vi.mock("aws-jwt-verify", () => {
5
+ const verify = vi.fn();
6
+ return {
7
+ CognitoJwtVerifier: {
8
+ create: vi.fn(() => ({ verify })),
9
+ },
10
+ };
11
+ });
12
+
13
+ vi.mock("@aws-sdk/client-dynamodb", () => ({
14
+ DynamoDBClient: vi.fn(() => ({})),
15
+ }));
16
+
17
+ const mockDdbSend = vi.fn();
18
+ vi.mock("@aws-sdk/lib-dynamodb", () => ({
19
+ DynamoDBDocumentClient: {
20
+ from: vi.fn(() => ({ send: mockDdbSend })),
21
+ },
22
+ UpdateCommand: vi.fn((input) => input),
23
+ GetCommand: vi.fn((input) => input),
24
+ QueryCommand: vi.fn((input) => input),
25
+ }));
26
+
27
+ beforeAll(() => {
28
+ process.env["COGNITO_USER_POOL_ID"] = "us-east-1_test";
29
+ process.env["COGNITO_CLIENT_ID"] = "test-client-id";
30
+ process.env["AWS_REGION"] = "us-east-1";
31
+ process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
32
+ process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
33
+ process.env["USERS_TABLE_NAME"] = "test-users";
34
+ });
35
+
36
+ const { createChatApp } = await import("../../src/app");
37
+
38
+ const USER_SUB = "test-user-sub";
39
+ const BEARER = "Bearer valid.token.here";
40
+
41
+ async function makeVerify(sub: string) {
42
+ const { CognitoJwtVerifier } = await import("aws-jwt-verify");
43
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
44
+ (CognitoJwtVerifier.create({} as any) as any).verify.mockResolvedValue({
45
+ sub,
46
+ } as unknown as CognitoAccessTokenPayload);
47
+ }
48
+
49
+ describe("POST /api/workspace", () => {
50
+ beforeEach(() => {
51
+ vi.clearAllMocks();
52
+ });
53
+
54
+ it("returns 401 without Authorization header", async () => {
55
+ const app = createChatApp();
56
+ const res = await app.request("/api/workspace", { method: "POST" });
57
+ expect(res.status).toBe(401);
58
+ });
59
+
60
+ it("switches workspace and evicts cache", async () => {
61
+ await makeVerify(USER_SUB);
62
+ mockDdbSend.mockResolvedValueOnce({}); // UpdateCommand succeeds
63
+
64
+ const app = createChatApp();
65
+ const res = await app.request("/api/workspace", {
66
+ method: "POST",
67
+ headers: {
68
+ Authorization: BEARER,
69
+ "Content-Type": "application/json",
70
+ },
71
+ body: JSON.stringify({ workspaceId: "ws-new" }),
72
+ });
73
+
74
+ expect(res.status).toBe(200);
75
+ const body = await res.json() as { activeWorkspaceId: string };
76
+ expect(body.activeWorkspaceId).toBe("ws-new");
77
+
78
+ // UpdateCommand should have been called with USERS_TABLE_NAME
79
+ expect(mockDdbSend).toHaveBeenCalledTimes(1);
80
+ const call = mockDdbSend.mock.calls[0]![0] as {
81
+ TableName?: string;
82
+ Key?: Record<string, unknown>;
83
+ ExpressionAttributeValues?: Record<string, unknown>;
84
+ };
85
+ expect(call.TableName).toBe("test-users");
86
+ expect(call.Key?.["sub"]).toBe(USER_SUB);
87
+ expect(call.ExpressionAttributeValues?.[":ws"]).toBe("ws-new");
88
+ });
89
+
90
+ it("returns 400 when workspaceId is missing", async () => {
91
+ await makeVerify(USER_SUB);
92
+
93
+ const app = createChatApp();
94
+ const res = await app.request("/api/workspace", {
95
+ method: "POST",
96
+ headers: {
97
+ Authorization: BEARER,
98
+ "Content-Type": "application/json",
99
+ },
100
+ body: JSON.stringify({}),
101
+ });
102
+
103
+ expect(res.status).toBe(400);
104
+ const body = await res.json() as { error: string };
105
+ expect(body.error).toMatch(/workspaceId/);
106
+ });
107
+ });