@aprovan/chat-backend 0.1.0-dev.4d82df8 → 0.1.0-dev.879ed82
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/.turbo/turbo-build.log +7 -7
- package/dist/index.d.ts +0 -3
- package/dist/index.js +116 -259
- package/dist/index.js.map +1 -1
- package/dist/lambda.js +116 -259
- package/dist/lambda.js.map +1 -1
- package/package.json +1 -1
- package/src/app.ts +3 -5
- package/src/env.ts +0 -1
- package/src/middleware/workspace.ts +19 -56
- package/src/routes/chat.ts +2 -10
- package/test/app.test.ts +0 -3
- package/test/middleware/workspace.test.ts +45 -66
- package/test/routes/chat.test.ts +0 -49
- package/src/routes/workspaces.ts +0 -89
- package/src/session.ts +0 -80
- package/test/routes/workspaces.test.ts +0 -164
- package/test/session.test.ts +0 -56
|
@@ -3,6 +3,7 @@ import { Hono } from "hono";
|
|
|
3
3
|
import type { AppVariables } from "../../src/types";
|
|
4
4
|
import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
|
|
5
5
|
|
|
6
|
+
// Mock the DDB client before importing the middleware
|
|
6
7
|
vi.mock("@aws-sdk/client-dynamodb", () => ({
|
|
7
8
|
DynamoDBClient: vi.fn(() => ({})),
|
|
8
9
|
}));
|
|
@@ -13,110 +14,88 @@ vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
|
13
14
|
from: vi.fn(() => ({ send: mockSend })),
|
|
14
15
|
},
|
|
15
16
|
QueryCommand: vi.fn((input) => input),
|
|
16
|
-
GetCommand: vi.fn((input) => input),
|
|
17
|
-
PutCommand: vi.fn((input) => input),
|
|
18
17
|
}));
|
|
19
18
|
|
|
20
|
-
const {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
resetMembershipCache,
|
|
24
|
-
} = await import("../../src/middleware/workspace");
|
|
25
|
-
const { resetSessionCache } = await import("../../src/session");
|
|
19
|
+
const { workspaceMiddleware, resolveWorkspaceId } = await import(
|
|
20
|
+
"../../src/middleware/workspace"
|
|
21
|
+
);
|
|
26
22
|
|
|
27
|
-
const
|
|
28
|
-
|
|
23
|
+
const fakeClaims = {
|
|
24
|
+
sub: "user-sub-123",
|
|
25
|
+
} as unknown as CognitoAccessTokenPayload;
|
|
29
26
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
function buildApp(userSub: string) {
|
|
33
|
-
const fakeClaims = { sub: userSub } as unknown as CognitoAccessTokenPayload;
|
|
27
|
+
function buildApp() {
|
|
34
28
|
const app = new Hono<{ Variables: AppVariables }>();
|
|
35
29
|
app.use("/protected", async (c, next) => {
|
|
36
30
|
c.set("claims", fakeClaims);
|
|
37
31
|
await next();
|
|
38
32
|
});
|
|
39
33
|
app.use("/protected", workspaceMiddleware);
|
|
40
|
-
app.get("/protected", (c) =>
|
|
34
|
+
app.get("/protected", (c) =>
|
|
35
|
+
c.json({ workspaceId: c.get("workspaceId") }),
|
|
36
|
+
);
|
|
41
37
|
return app;
|
|
42
38
|
}
|
|
43
39
|
|
|
44
40
|
describe("workspaceMiddleware", () => {
|
|
45
41
|
beforeEach(() => {
|
|
46
42
|
vi.clearAllMocks();
|
|
47
|
-
|
|
48
|
-
resetSessionCache();
|
|
49
|
-
process.env["MEMBERSHIPS_TABLE_NAME"] = MEMBERSHIPS_TABLE;
|
|
50
|
-
process.env["USER_SESSIONS_TABLE_NAME"] = SESSIONS_TABLE;
|
|
43
|
+
process.env["MEMBERSHIPS_TABLE_NAME"] = "gateway-prd-use1-memberships";
|
|
51
44
|
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.
|
|
52
47
|
});
|
|
53
48
|
|
|
54
|
-
it("
|
|
55
|
-
mockSend.
|
|
56
|
-
|
|
57
|
-
return Promise.resolve({ Item: { activeWorkspaceId: "ws-b" } });
|
|
58
|
-
}
|
|
59
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }] });
|
|
49
|
+
it("resolves workspaceId from DDB and sets it on context", async () => {
|
|
50
|
+
mockSend.mockResolvedValueOnce({
|
|
51
|
+
Items: [{ workspaceId: "ws-abc", userSub: "user-sub-456" }],
|
|
60
52
|
});
|
|
61
53
|
|
|
62
|
-
const app =
|
|
63
|
-
const
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
it("falls back to first membership when no session preference exists", async () => {
|
|
70
|
-
mockSend.mockImplementation((cmd: MockCommand) => {
|
|
71
|
-
if (cmd.TableName === SESSIONS_TABLE) {
|
|
72
|
-
return Promise.resolve({ Item: undefined });
|
|
73
|
-
}
|
|
74
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-first" }] });
|
|
54
|
+
const app = new Hono<{ Variables: AppVariables }>();
|
|
55
|
+
const localClaims = { sub: "user-sub-456" } as unknown as CognitoAccessTokenPayload;
|
|
56
|
+
app.use("/protected", async (c, next) => {
|
|
57
|
+
c.set("claims", localClaims);
|
|
58
|
+
await next();
|
|
75
59
|
});
|
|
60
|
+
app.use("/protected", workspaceMiddleware);
|
|
61
|
+
app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
|
|
76
62
|
|
|
77
|
-
const app = buildApp("user-no-session");
|
|
78
63
|
const res = await app.request("/protected");
|
|
79
64
|
expect(res.status).toBe(200);
|
|
80
65
|
const body = await res.json();
|
|
81
|
-
expect(body
|
|
66
|
+
expect(body).toEqual({ workspaceId: "ws-abc" });
|
|
82
67
|
});
|
|
83
68
|
|
|
84
|
-
it("
|
|
85
|
-
mockSend.
|
|
86
|
-
if (cmd.TableName === SESSIONS_TABLE) {
|
|
87
|
-
return Promise.resolve({ Item: { activeWorkspaceId: "ws-stale" } });
|
|
88
|
-
}
|
|
89
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }] });
|
|
90
|
-
});
|
|
91
|
-
|
|
92
|
-
const app = buildApp("user-stale");
|
|
93
|
-
const res = await app.request("/protected");
|
|
94
|
-
expect(res.status).toBe(200);
|
|
95
|
-
const body = await res.json();
|
|
96
|
-
expect(body.workspaceId).toBe("ws-a");
|
|
97
|
-
});
|
|
69
|
+
it("returns 403 when no membership exists", async () => {
|
|
70
|
+
mockSend.mockResolvedValueOnce({ Items: [] });
|
|
98
71
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
72
|
+
const app = new Hono<{ Variables: AppVariables }>();
|
|
73
|
+
const localClaims = {
|
|
74
|
+
sub: "user-no-ws",
|
|
75
|
+
} as unknown as CognitoAccessTokenPayload;
|
|
76
|
+
app.use("/protected", async (c, next) => {
|
|
77
|
+
c.set("claims", localClaims);
|
|
78
|
+
await next();
|
|
105
79
|
});
|
|
80
|
+
app.use("/protected", workspaceMiddleware);
|
|
81
|
+
app.get("/protected", (c) => c.json({ workspaceId: c.get("workspaceId") }));
|
|
106
82
|
|
|
107
|
-
const app = buildApp("user-no-ws");
|
|
108
83
|
const res = await app.request("/protected");
|
|
109
84
|
expect(res.status).toBe(403);
|
|
110
85
|
const body = await res.json();
|
|
111
86
|
expect(body).toMatchObject({ error: "No workspace membership" });
|
|
112
87
|
});
|
|
113
88
|
|
|
114
|
-
it("uses
|
|
115
|
-
mockSend.
|
|
89
|
+
it("uses cache on subsequent calls for the same sub", async () => {
|
|
90
|
+
mockSend.mockResolvedValueOnce({
|
|
91
|
+
Items: [{ workspaceId: "ws-cached", userSub: "user-cached" }],
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
// First call — hits DDB
|
|
95
|
+
await resolveWorkspaceId("user-cached");
|
|
96
|
+
// Second call — should use cache
|
|
97
|
+
await resolveWorkspaceId("user-cached");
|
|
116
98
|
|
|
117
|
-
await listWorkspaceMemberships("user-cached-q");
|
|
118
|
-
await listWorkspaceMemberships("user-cached-q");
|
|
119
|
-
// Only one DDB send for memberships (cache hit on second call)
|
|
120
99
|
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
121
100
|
});
|
|
122
101
|
});
|
package/test/routes/chat.test.ts
CHANGED
|
@@ -307,55 +307,6 @@ describe("POST /chat", () => {
|
|
|
307
307
|
expect(mockDoStream).toHaveBeenCalledTimes(1);
|
|
308
308
|
});
|
|
309
309
|
|
|
310
|
-
it("returns 403 when requested model is not in workspace.limits.maxModels", async () => {
|
|
311
|
-
const app = buildApp();
|
|
312
|
-
const res = await app.request("/chat", {
|
|
313
|
-
method: "POST",
|
|
314
|
-
headers: validHeaders,
|
|
315
|
-
body: JSON.stringify({
|
|
316
|
-
id: "chat-1",
|
|
317
|
-
messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
|
|
318
|
-
trigger: "submit-message",
|
|
319
|
-
model: "anthropic/claude-opus-4",
|
|
320
|
-
}),
|
|
321
|
-
});
|
|
322
|
-
expect(res.status).toBe(403);
|
|
323
|
-
const body = await res.json();
|
|
324
|
-
expect(body).toMatchObject({ error: expect.stringContaining("Model not allowed") });
|
|
325
|
-
expect(mockDoStream).not.toHaveBeenCalled();
|
|
326
|
-
});
|
|
327
|
-
|
|
328
|
-
it("uses the requested model when it is in workspace.limits.maxModels", async () => {
|
|
329
|
-
mockDoStream.mockResolvedValueOnce({
|
|
330
|
-
stream: makeSuccessStream(),
|
|
331
|
-
rawResponse: { headers: {} },
|
|
332
|
-
});
|
|
333
|
-
|
|
334
|
-
const proWorkspace: WorkspaceItem = {
|
|
335
|
-
...fakeWorkspace,
|
|
336
|
-
plan: "pro",
|
|
337
|
-
limits: {
|
|
338
|
-
...fakeWorkspace.limits,
|
|
339
|
-
maxModels: ["openrouter/auto", "anthropic/claude-opus-4"],
|
|
340
|
-
},
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
const app = buildApp(proWorkspace);
|
|
344
|
-
const res = await app.request("/chat", {
|
|
345
|
-
method: "POST",
|
|
346
|
-
headers: validHeaders,
|
|
347
|
-
body: JSON.stringify({
|
|
348
|
-
id: "chat-1",
|
|
349
|
-
messages: [{ role: "user", parts: [{ type: "text", text: "Hello" }], id: "msg-1" }],
|
|
350
|
-
trigger: "submit-message",
|
|
351
|
-
model: "anthropic/claude-opus-4",
|
|
352
|
-
}),
|
|
353
|
-
});
|
|
354
|
-
|
|
355
|
-
expect(res.status).toBe(200);
|
|
356
|
-
expect(mockProviderFactory).toHaveBeenCalledWith("anthropic/claude-opus-4");
|
|
357
|
-
});
|
|
358
|
-
|
|
359
310
|
it("selects model from workspace.limits.maxModels[0]", async () => {
|
|
360
311
|
mockDoStream.mockResolvedValueOnce({
|
|
361
312
|
stream: makeSuccessStream(),
|
package/src/routes/workspaces.ts
DELETED
|
@@ -1,89 +0,0 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import { BatchGetCommand, DynamoDBDocumentClient } from "@aws-sdk/lib-dynamodb";
|
|
3
|
-
import { zValidator } from "@hono/zod-validator";
|
|
4
|
-
import { Hono } from "hono";
|
|
5
|
-
import { z } from "zod";
|
|
6
|
-
import { listWorkspaceMemberships } from "../middleware/workspace.js";
|
|
7
|
-
import { getSessionWorkspaceId, setSessionWorkspaceId } from "../session.js";
|
|
8
|
-
import type { AppVariables, WorkspaceItem } from "../types.js";
|
|
9
|
-
|
|
10
|
-
let ddbClient: DynamoDBDocumentClient | null = null;
|
|
11
|
-
|
|
12
|
-
function getDdb() {
|
|
13
|
-
if (!ddbClient) {
|
|
14
|
-
ddbClient = DynamoDBDocumentClient.from(
|
|
15
|
-
new DynamoDBClient({ region: process.env["AWS_REGION"] }),
|
|
16
|
-
);
|
|
17
|
-
}
|
|
18
|
-
return ddbClient;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
async function batchGetWorkspaces(
|
|
22
|
-
workspaceIds: string[],
|
|
23
|
-
): Promise<WorkspaceItem[]> {
|
|
24
|
-
if (workspaceIds.length === 0) return [];
|
|
25
|
-
const tableName = process.env["WORKSPACE_TABLE_NAME"]!;
|
|
26
|
-
const result = await getDdb().send(
|
|
27
|
-
new BatchGetCommand({
|
|
28
|
-
RequestItems: {
|
|
29
|
-
[tableName]: { Keys: workspaceIds.map((id) => ({ workspaceId: id })) },
|
|
30
|
-
},
|
|
31
|
-
}),
|
|
32
|
-
);
|
|
33
|
-
return (result.Responses?.[tableName] ?? []) as WorkspaceItem[];
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
export const workspacesRoute = new Hono<{ Variables: AppVariables }>();
|
|
37
|
-
|
|
38
|
-
workspacesRoute.get("/", async (c) => {
|
|
39
|
-
const claims = c.get("claims");
|
|
40
|
-
const userSub = claims.sub;
|
|
41
|
-
|
|
42
|
-
const [workspaceIds, activeWorkspaceId] = await Promise.all([
|
|
43
|
-
listWorkspaceMemberships(userSub),
|
|
44
|
-
getSessionWorkspaceId(userSub),
|
|
45
|
-
]);
|
|
46
|
-
|
|
47
|
-
const workspaces = await batchGetWorkspaces(workspaceIds);
|
|
48
|
-
|
|
49
|
-
const effectiveActiveId =
|
|
50
|
-
activeWorkspaceId && workspaceIds.includes(activeWorkspaceId)
|
|
51
|
-
? activeWorkspaceId
|
|
52
|
-
: (workspaceIds[0] ?? null);
|
|
53
|
-
|
|
54
|
-
const sorted = workspaceIds
|
|
55
|
-
.map((id) => workspaces.find((w) => w.workspaceId === id))
|
|
56
|
-
.filter((w): w is WorkspaceItem => w !== undefined);
|
|
57
|
-
|
|
58
|
-
return c.json({
|
|
59
|
-
workspaces: sorted.map((w) => ({
|
|
60
|
-
workspaceId: w.workspaceId,
|
|
61
|
-
name: w.name,
|
|
62
|
-
plan: w.plan,
|
|
63
|
-
active: w.workspaceId === effectiveActiveId,
|
|
64
|
-
})),
|
|
65
|
-
activeWorkspaceId: effectiveActiveId,
|
|
66
|
-
});
|
|
67
|
-
});
|
|
68
|
-
|
|
69
|
-
const setActiveSchema = z.object({
|
|
70
|
-
workspaceId: z.string().min(1),
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
workspacesRoute.put(
|
|
74
|
-
"/active",
|
|
75
|
-
zValidator("json", setActiveSchema),
|
|
76
|
-
async (c) => {
|
|
77
|
-
const claims = c.get("claims");
|
|
78
|
-
const userSub = claims.sub;
|
|
79
|
-
const { workspaceId } = c.req.valid("json");
|
|
80
|
-
|
|
81
|
-
const workspaceIds = await listWorkspaceMemberships(userSub);
|
|
82
|
-
if (!workspaceIds.includes(workspaceId)) {
|
|
83
|
-
return c.json({ error: "Not a member of this workspace" }, 403);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
await setSessionWorkspaceId(userSub, workspaceId);
|
|
87
|
-
return c.json({ activeWorkspaceId: workspaceId });
|
|
88
|
-
},
|
|
89
|
-
);
|
package/src/session.ts
DELETED
|
@@ -1,80 +0,0 @@
|
|
|
1
|
-
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
|
2
|
-
import {
|
|
3
|
-
DynamoDBDocumentClient,
|
|
4
|
-
GetCommand,
|
|
5
|
-
PutCommand,
|
|
6
|
-
} from "@aws-sdk/lib-dynamodb";
|
|
7
|
-
|
|
8
|
-
const SESSION_CACHE_TTL_MS = 15_000;
|
|
9
|
-
|
|
10
|
-
interface SessionCacheEntry {
|
|
11
|
-
activeWorkspaceId: string | null;
|
|
12
|
-
fetchedAt: number;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
const sessionCache = new Map<string, SessionCacheEntry>();
|
|
16
|
-
|
|
17
|
-
let ddbClient: DynamoDBDocumentClient | null = null;
|
|
18
|
-
|
|
19
|
-
function getDdb() {
|
|
20
|
-
if (!ddbClient) {
|
|
21
|
-
ddbClient = DynamoDBDocumentClient.from(
|
|
22
|
-
new DynamoDBClient({ region: process.env["AWS_REGION"] }),
|
|
23
|
-
);
|
|
24
|
-
}
|
|
25
|
-
return ddbClient;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function tableName(): string {
|
|
29
|
-
return process.env["USER_SESSIONS_TABLE_NAME"]!;
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
export async function getSessionWorkspaceId(
|
|
33
|
-
userSub: string,
|
|
34
|
-
): Promise<string | null> {
|
|
35
|
-
const now = Date.now();
|
|
36
|
-
const cached = sessionCache.get(userSub);
|
|
37
|
-
if (cached && now - cached.fetchedAt < SESSION_CACHE_TTL_MS) {
|
|
38
|
-
return cached.activeWorkspaceId;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const result = await getDdb().send(
|
|
42
|
-
new GetCommand({
|
|
43
|
-
TableName: tableName(),
|
|
44
|
-
Key: { userSub },
|
|
45
|
-
}),
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
const activeWorkspaceId =
|
|
49
|
-
(result.Item?.activeWorkspaceId as string | undefined) ?? null;
|
|
50
|
-
sessionCache.set(userSub, { activeWorkspaceId, fetchedAt: now });
|
|
51
|
-
return activeWorkspaceId;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export async function setSessionWorkspaceId(
|
|
55
|
-
userSub: string,
|
|
56
|
-
workspaceId: string,
|
|
57
|
-
): Promise<void> {
|
|
58
|
-
await getDdb().send(
|
|
59
|
-
new PutCommand({
|
|
60
|
-
TableName: tableName(),
|
|
61
|
-
Item: {
|
|
62
|
-
userSub,
|
|
63
|
-
activeWorkspaceId: workspaceId,
|
|
64
|
-
updatedAt: new Date().toISOString(),
|
|
65
|
-
},
|
|
66
|
-
}),
|
|
67
|
-
);
|
|
68
|
-
sessionCache.set(userSub, {
|
|
69
|
-
activeWorkspaceId: workspaceId,
|
|
70
|
-
fetchedAt: Date.now(),
|
|
71
|
-
});
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
export function clearSessionCache(userSub: string): void {
|
|
75
|
-
sessionCache.delete(userSub);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function resetSessionCache(): void {
|
|
79
|
-
sessionCache.clear();
|
|
80
|
-
}
|
|
@@ -1,164 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
import { Hono } from "hono";
|
|
3
|
-
import type { AppVariables } from "../../src/types";
|
|
4
|
-
import type { CognitoAccessTokenPayload } from "aws-jwt-verify/jwt-model";
|
|
5
|
-
|
|
6
|
-
vi.mock("@aws-sdk/client-dynamodb", () => ({
|
|
7
|
-
DynamoDBClient: vi.fn(() => ({})),
|
|
8
|
-
}));
|
|
9
|
-
|
|
10
|
-
const mockSend = vi.fn();
|
|
11
|
-
vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
12
|
-
DynamoDBDocumentClient: {
|
|
13
|
-
from: vi.fn(() => ({ send: mockSend })),
|
|
14
|
-
},
|
|
15
|
-
QueryCommand: vi.fn((input) => input),
|
|
16
|
-
GetCommand: vi.fn((input) => input),
|
|
17
|
-
PutCommand: vi.fn((input) => input),
|
|
18
|
-
BatchGetCommand: vi.fn((input) => input),
|
|
19
|
-
}));
|
|
20
|
-
|
|
21
|
-
const { workspacesRoute } = await import("../../src/routes/workspaces");
|
|
22
|
-
const { resetMembershipCache } = await import("../../src/middleware/workspace");
|
|
23
|
-
const { resetSessionCache } = await import("../../src/session");
|
|
24
|
-
|
|
25
|
-
const MEMBERSHIPS_TABLE = "test-memberships";
|
|
26
|
-
const WORKSPACE_TABLE = "test-workspaces";
|
|
27
|
-
const SESSIONS_TABLE = "test-user-sessions";
|
|
28
|
-
|
|
29
|
-
type MockCommand = { TableName?: string; RequestItems?: Record<string, unknown> };
|
|
30
|
-
|
|
31
|
-
const fakeClaims = { sub: "user-ws-test" } as unknown as CognitoAccessTokenPayload;
|
|
32
|
-
|
|
33
|
-
function buildApp() {
|
|
34
|
-
const app = new Hono<{ Variables: AppVariables }>();
|
|
35
|
-
app.use("/*", async (c, next) => {
|
|
36
|
-
c.set("claims", fakeClaims);
|
|
37
|
-
await next();
|
|
38
|
-
});
|
|
39
|
-
app.route("/workspaces", workspacesRoute);
|
|
40
|
-
return app;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
describe("GET /workspaces", () => {
|
|
44
|
-
beforeEach(() => {
|
|
45
|
-
vi.clearAllMocks();
|
|
46
|
-
resetMembershipCache();
|
|
47
|
-
resetSessionCache();
|
|
48
|
-
process.env["MEMBERSHIPS_TABLE_NAME"] = MEMBERSHIPS_TABLE;
|
|
49
|
-
process.env["WORKSPACE_TABLE_NAME"] = WORKSPACE_TABLE;
|
|
50
|
-
process.env["USER_SESSIONS_TABLE_NAME"] = SESSIONS_TABLE;
|
|
51
|
-
process.env["AWS_REGION"] = "us-east-1";
|
|
52
|
-
});
|
|
53
|
-
|
|
54
|
-
it("returns workspace list with active flag", async () => {
|
|
55
|
-
mockSend.mockImplementation((cmd: MockCommand) => {
|
|
56
|
-
if (cmd.TableName === SESSIONS_TABLE) {
|
|
57
|
-
return Promise.resolve({ Item: { activeWorkspaceId: "ws-b" } });
|
|
58
|
-
}
|
|
59
|
-
if (cmd.TableName === MEMBERSHIPS_TABLE) {
|
|
60
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }] });
|
|
61
|
-
}
|
|
62
|
-
if (cmd.RequestItems?.[WORKSPACE_TABLE]) {
|
|
63
|
-
return Promise.resolve({
|
|
64
|
-
Responses: {
|
|
65
|
-
[WORKSPACE_TABLE]: [
|
|
66
|
-
{ workspaceId: "ws-a", name: "Alpha", plan: "free" },
|
|
67
|
-
{ workspaceId: "ws-b", name: "Beta", plan: "pro" },
|
|
68
|
-
],
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
}
|
|
72
|
-
return Promise.resolve({});
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
const app = buildApp();
|
|
76
|
-
const res = await app.request("/workspaces");
|
|
77
|
-
expect(res.status).toBe(200);
|
|
78
|
-
const body = await res.json();
|
|
79
|
-
expect(body.activeWorkspaceId).toBe("ws-b");
|
|
80
|
-
const beta = body.workspaces.find((w: { workspaceId: string }) => w.workspaceId === "ws-b");
|
|
81
|
-
expect(beta?.active).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
it("falls back to first membership when no session preference", async () => {
|
|
85
|
-
mockSend.mockImplementation((cmd: MockCommand) => {
|
|
86
|
-
if (cmd.TableName === SESSIONS_TABLE) {
|
|
87
|
-
return Promise.resolve({ Item: undefined });
|
|
88
|
-
}
|
|
89
|
-
if (cmd.TableName === MEMBERSHIPS_TABLE) {
|
|
90
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }] });
|
|
91
|
-
}
|
|
92
|
-
if (cmd.RequestItems?.[WORKSPACE_TABLE]) {
|
|
93
|
-
return Promise.resolve({
|
|
94
|
-
Responses: { [WORKSPACE_TABLE]: [{ workspaceId: "ws-a", name: "Alpha", plan: "free" }] },
|
|
95
|
-
});
|
|
96
|
-
}
|
|
97
|
-
return Promise.resolve({});
|
|
98
|
-
});
|
|
99
|
-
|
|
100
|
-
const app = buildApp();
|
|
101
|
-
const res = await app.request("/workspaces");
|
|
102
|
-
const body = await res.json();
|
|
103
|
-
expect(body.activeWorkspaceId).toBe("ws-a");
|
|
104
|
-
expect(body.workspaces[0].active).toBe(true);
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
describe("PUT /workspaces/active", () => {
|
|
109
|
-
beforeEach(() => {
|
|
110
|
-
vi.clearAllMocks();
|
|
111
|
-
resetMembershipCache();
|
|
112
|
-
resetSessionCache();
|
|
113
|
-
process.env["MEMBERSHIPS_TABLE_NAME"] = MEMBERSHIPS_TABLE;
|
|
114
|
-
process.env["WORKSPACE_TABLE_NAME"] = WORKSPACE_TABLE;
|
|
115
|
-
process.env["USER_SESSIONS_TABLE_NAME"] = SESSIONS_TABLE;
|
|
116
|
-
process.env["AWS_REGION"] = "us-east-1";
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it("sets active workspace and returns it", async () => {
|
|
120
|
-
mockSend.mockImplementation((cmd: MockCommand) => {
|
|
121
|
-
if (cmd.TableName === MEMBERSHIPS_TABLE) {
|
|
122
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }, { workspaceId: "ws-b" }] });
|
|
123
|
-
}
|
|
124
|
-
return Promise.resolve({});
|
|
125
|
-
});
|
|
126
|
-
|
|
127
|
-
const app = buildApp();
|
|
128
|
-
const res = await app.request("/workspaces/active", {
|
|
129
|
-
method: "PUT",
|
|
130
|
-
headers: { "Content-Type": "application/json" },
|
|
131
|
-
body: JSON.stringify({ workspaceId: "ws-b" }),
|
|
132
|
-
});
|
|
133
|
-
expect(res.status).toBe(200);
|
|
134
|
-
const body = await res.json();
|
|
135
|
-
expect(body.activeWorkspaceId).toBe("ws-b");
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
it("returns 403 when workspace is not in user memberships", async () => {
|
|
139
|
-
mockSend.mockImplementation((cmd: MockCommand) => {
|
|
140
|
-
if (cmd.TableName === MEMBERSHIPS_TABLE) {
|
|
141
|
-
return Promise.resolve({ Items: [{ workspaceId: "ws-a" }] });
|
|
142
|
-
}
|
|
143
|
-
return Promise.resolve({});
|
|
144
|
-
});
|
|
145
|
-
|
|
146
|
-
const app = buildApp();
|
|
147
|
-
const res = await app.request("/workspaces/active", {
|
|
148
|
-
method: "PUT",
|
|
149
|
-
headers: { "Content-Type": "application/json" },
|
|
150
|
-
body: JSON.stringify({ workspaceId: "ws-other" }),
|
|
151
|
-
});
|
|
152
|
-
expect(res.status).toBe(403);
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
it("returns 400 for invalid request body", async () => {
|
|
156
|
-
const app = buildApp();
|
|
157
|
-
const res = await app.request("/workspaces/active", {
|
|
158
|
-
method: "PUT",
|
|
159
|
-
headers: { "Content-Type": "application/json" },
|
|
160
|
-
body: JSON.stringify({ workspaceId: "" }),
|
|
161
|
-
});
|
|
162
|
-
expect(res.status).toBe(400);
|
|
163
|
-
});
|
|
164
|
-
});
|
package/test/session.test.ts
DELETED
|
@@ -1,56 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
-
|
|
3
|
-
vi.mock("@aws-sdk/client-dynamodb", () => ({
|
|
4
|
-
DynamoDBClient: vi.fn(() => ({})),
|
|
5
|
-
}));
|
|
6
|
-
|
|
7
|
-
const mockSend = vi.fn();
|
|
8
|
-
vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
9
|
-
DynamoDBDocumentClient: {
|
|
10
|
-
from: vi.fn(() => ({ send: mockSend })),
|
|
11
|
-
},
|
|
12
|
-
GetCommand: vi.fn((input) => input),
|
|
13
|
-
PutCommand: vi.fn((input) => input),
|
|
14
|
-
}));
|
|
15
|
-
|
|
16
|
-
const { getSessionWorkspaceId, setSessionWorkspaceId, resetSessionCache } =
|
|
17
|
-
await import("../src/session");
|
|
18
|
-
|
|
19
|
-
describe("session", () => {
|
|
20
|
-
beforeEach(() => {
|
|
21
|
-
vi.clearAllMocks();
|
|
22
|
-
resetSessionCache();
|
|
23
|
-
process.env["USER_SESSIONS_TABLE_NAME"] = "test-user-sessions";
|
|
24
|
-
process.env["AWS_REGION"] = "us-east-1";
|
|
25
|
-
});
|
|
26
|
-
|
|
27
|
-
it("returns null when no session record exists", async () => {
|
|
28
|
-
mockSend.mockResolvedValueOnce({ Item: undefined });
|
|
29
|
-
const result = await getSessionWorkspaceId("user-abc");
|
|
30
|
-
expect(result).toBeNull();
|
|
31
|
-
});
|
|
32
|
-
|
|
33
|
-
it("returns activeWorkspaceId from session record", async () => {
|
|
34
|
-
mockSend.mockResolvedValueOnce({ Item: { userSub: "user-abc", activeWorkspaceId: "ws-xyz" } });
|
|
35
|
-
const result = await getSessionWorkspaceId("user-abc");
|
|
36
|
-
expect(result).toBe("ws-xyz");
|
|
37
|
-
});
|
|
38
|
-
|
|
39
|
-
it("caches the result and skips DDB on second call", async () => {
|
|
40
|
-
mockSend.mockResolvedValueOnce({ Item: { activeWorkspaceId: "ws-cached" } });
|
|
41
|
-
await getSessionWorkspaceId("user-cached");
|
|
42
|
-
await getSessionWorkspaceId("user-cached");
|
|
43
|
-
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
44
|
-
});
|
|
45
|
-
|
|
46
|
-
it("setSessionWorkspaceId writes to DDB and updates cache", async () => {
|
|
47
|
-
mockSend.mockResolvedValueOnce({});
|
|
48
|
-
await setSessionWorkspaceId("user-set", "ws-new");
|
|
49
|
-
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
50
|
-
|
|
51
|
-
// Second read should come from cache, not DDB
|
|
52
|
-
const result = await getSessionWorkspaceId("user-set");
|
|
53
|
-
expect(result).toBe("ws-new");
|
|
54
|
-
expect(mockSend).toHaveBeenCalledTimes(1);
|
|
55
|
-
});
|
|
56
|
-
});
|