@aprovan/chat-backend 0.1.0-dev.4d82df8
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 +20 -0
- package/LICENSE +373 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +1158 -0
- package/dist/index.js.map +1 -0
- package/dist/lambda.d.ts +5 -0
- package/dist/lambda.js +1145 -0
- package/dist/lambda.js.map +1 -0
- package/package.json +47 -0
- package/src/app.ts +33 -0
- package/src/env.ts +27 -0
- package/src/fallback-prompts.ts +343 -0
- package/src/gateway-session.ts +148 -0
- package/src/index.ts +19 -0
- package/src/lambda.ts +17 -0
- package/src/middleware/.gitkeep +0 -0
- package/src/middleware/auth.ts +38 -0
- package/src/middleware/plan.ts +67 -0
- package/src/middleware/workspace.ts +96 -0
- package/src/posthog.ts +48 -0
- package/src/providers/openrouter.ts +37 -0
- package/src/routes/chat.ts +235 -0
- package/src/routes/edit.ts +57 -0
- package/src/routes/health.ts +7 -0
- package/src/routes/proxy.ts +60 -0
- package/src/routes/services.ts +82 -0
- package/src/routes/workspaces.ts +89 -0
- package/src/session.ts +80 -0
- package/src/tool-docs.ts +77 -0
- package/src/types.ts +29 -0
- package/test/app.test.ts +62 -0
- package/test/gateway-session.test.ts +211 -0
- package/test/middleware/auth.test.ts +87 -0
- package/test/middleware/plan.test.ts +99 -0
- package/test/middleware/workspace.test.ts +122 -0
- package/test/routes/chat.test.ts +587 -0
- package/test/routes/proxy.test.ts +133 -0
- package/test/routes/services.test.ts +128 -0
- package/test/routes/workspaces.test.ts +164 -0
- package/test/session.test.ts +56 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +17 -0
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createChatApp } from "../../src/app";
|
|
3
|
+
import { resetGatewaySessionCache } from "../../src/gateway-session";
|
|
4
|
+
|
|
5
|
+
// ── Global fetch stub ─────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
9
|
+
|
|
10
|
+
// ── Middleware mocks ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
vi.mock("aws-jwt-verify", () => {
|
|
13
|
+
const verify = vi.fn().mockResolvedValue({
|
|
14
|
+
sub: "user-123",
|
|
15
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
16
|
+
token_use: "access",
|
|
17
|
+
});
|
|
18
|
+
return { CognitoJwtVerifier: { create: vi.fn(() => ({ verify })) } };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("@aws-sdk/client-dynamodb", () => ({
|
|
22
|
+
DynamoDBClient: vi.fn(() => ({})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockSend = vi.fn();
|
|
26
|
+
vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
27
|
+
DynamoDBDocumentClient: { from: vi.fn(() => ({ send: mockSend })) },
|
|
28
|
+
QueryCommand: vi.fn((i) => i),
|
|
29
|
+
GetCommand: vi.fn((i) => i),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// ── Environment + DDB setup ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
process.env["COGNITO_USER_POOL_ID"] = "us-east-1_test";
|
|
36
|
+
process.env["COGNITO_CLIENT_ID"] = "test-client-id";
|
|
37
|
+
process.env["AWS_REGION"] = "us-east-1";
|
|
38
|
+
process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
|
|
39
|
+
process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
|
|
40
|
+
process.env["GATEWAY_URL"] = "https://gateway.test";
|
|
41
|
+
process.env["OPENROUTER_API_KEY"] = "test-key";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
mockFetch.mockReset();
|
|
46
|
+
resetGatewaySessionCache();
|
|
47
|
+
|
|
48
|
+
mockSend.mockImplementation((cmd: { IndexName?: string }) => {
|
|
49
|
+
if (cmd.IndexName === "ByUserSub") {
|
|
50
|
+
return Promise.resolve({ Items: [{ workspaceId: "ws-test" }] });
|
|
51
|
+
}
|
|
52
|
+
return Promise.resolve({
|
|
53
|
+
Item: {
|
|
54
|
+
workspaceId: "ws-test",
|
|
55
|
+
plan: "pro",
|
|
56
|
+
limits: {},
|
|
57
|
+
features: {},
|
|
58
|
+
createdAt: "",
|
|
59
|
+
updatedAt: "",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
resetGatewaySessionCache();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("POST /api/proxy/:ns/:proc", () => {
|
|
73
|
+
it("forwards body and session token to the gateway and returns the response", async () => {
|
|
74
|
+
const app = createChatApp();
|
|
75
|
+
const expectedResult = { data: [{ id: "repo-1" }], meta: { requestId: "r1" } };
|
|
76
|
+
|
|
77
|
+
mockFetch
|
|
78
|
+
// POST /auth/sessions
|
|
79
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ workspace_id: "ws-test" }), { status: 200 }))
|
|
80
|
+
// POST /tools/github/repos.list
|
|
81
|
+
.mockResolvedValueOnce(new Response(JSON.stringify(expectedResult), { status: 200 }));
|
|
82
|
+
|
|
83
|
+
const res = await app.request("/api/proxy/github/repos.list", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
headers: {
|
|
86
|
+
"Content-Type": "application/json",
|
|
87
|
+
Authorization: "Bearer cognito-token",
|
|
88
|
+
},
|
|
89
|
+
body: JSON.stringify({ per_page: 10 }),
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
expect(res.status).toBe(200);
|
|
93
|
+
const body = await res.json();
|
|
94
|
+
expect(body).toEqual(expectedResult);
|
|
95
|
+
|
|
96
|
+
// Verify the gateway call used the session bearer and forwarded the body
|
|
97
|
+
const toolCall = mockFetch.mock.calls.find(
|
|
98
|
+
(c) => typeof c[0] === "string" && (c[0] as string).includes("/tools/github/repos.list"),
|
|
99
|
+
);
|
|
100
|
+
expect(toolCall).toBeDefined();
|
|
101
|
+
const toolCallOptions = toolCall![1] as RequestInit;
|
|
102
|
+
expect((toolCallOptions.headers as Record<string, string>)["Authorization"]).toBe(
|
|
103
|
+
"Bearer cognito-token",
|
|
104
|
+
);
|
|
105
|
+
expect(JSON.parse(toolCallOptions.body as string)).toEqual({ per_page: 10 });
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns 401 without Authorization header", async () => {
|
|
109
|
+
const app = createChatApp();
|
|
110
|
+
const res = await app.request("/api/proxy/github/repos.list", { method: "POST" });
|
|
111
|
+
expect(res.status).toBe(401);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("forwards the gateway error status when gateway returns non-2xx", async () => {
|
|
115
|
+
const app = createChatApp();
|
|
116
|
+
|
|
117
|
+
mockFetch
|
|
118
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ workspace_id: "ws-test" }), { status: 200 }))
|
|
119
|
+
.mockResolvedValueOnce(
|
|
120
|
+
new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }),
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
const res = await app.request("/api/proxy/github/repos.list", {
|
|
124
|
+
method: "POST",
|
|
125
|
+
headers: {
|
|
126
|
+
"Content-Type": "application/json",
|
|
127
|
+
Authorization: "Bearer cognito-token",
|
|
128
|
+
},
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
expect(res.status).toBe(403);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { createChatApp } from "../../src/app";
|
|
3
|
+
import { resetGatewaySessionCache } from "../../src/gateway-session";
|
|
4
|
+
|
|
5
|
+
// ── Global fetch stub ─────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
const mockFetch = vi.fn();
|
|
8
|
+
vi.stubGlobal("fetch", mockFetch);
|
|
9
|
+
|
|
10
|
+
// ── Middleware mocks ──────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
vi.mock("aws-jwt-verify", () => {
|
|
13
|
+
const verify = vi.fn().mockResolvedValue({
|
|
14
|
+
sub: "user-123",
|
|
15
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
16
|
+
token_use: "access",
|
|
17
|
+
});
|
|
18
|
+
return { CognitoJwtVerifier: { create: vi.fn(() => ({ verify })) } };
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
vi.mock("@aws-sdk/client-dynamodb", () => ({
|
|
22
|
+
DynamoDBClient: vi.fn(() => ({})),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
const mockSend = vi.fn();
|
|
26
|
+
vi.mock("@aws-sdk/lib-dynamodb", () => ({
|
|
27
|
+
DynamoDBDocumentClient: { from: vi.fn(() => ({ send: mockSend })) },
|
|
28
|
+
QueryCommand: vi.fn((i) => i),
|
|
29
|
+
GetCommand: vi.fn((i) => i),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
// ── Environment + DDB setup ───────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
beforeAll(() => {
|
|
35
|
+
process.env["COGNITO_USER_POOL_ID"] = "us-east-1_test";
|
|
36
|
+
process.env["COGNITO_CLIENT_ID"] = "test-client-id";
|
|
37
|
+
process.env["AWS_REGION"] = "us-east-1";
|
|
38
|
+
process.env["WORKSPACE_TABLE_NAME"] = "test-workspaces";
|
|
39
|
+
process.env["MEMBERSHIPS_TABLE_NAME"] = "test-memberships";
|
|
40
|
+
process.env["GATEWAY_URL"] = "https://gateway.test";
|
|
41
|
+
process.env["OPENROUTER_API_KEY"] = "test-key";
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
beforeEach(() => {
|
|
45
|
+
mockFetch.mockReset();
|
|
46
|
+
resetGatewaySessionCache();
|
|
47
|
+
|
|
48
|
+
mockSend.mockImplementation((cmd: { IndexName?: string }) => {
|
|
49
|
+
if (cmd.IndexName === "ByUserSub") {
|
|
50
|
+
return Promise.resolve({ Items: [{ workspaceId: "ws-test" }] });
|
|
51
|
+
}
|
|
52
|
+
return Promise.resolve({
|
|
53
|
+
Item: {
|
|
54
|
+
workspaceId: "ws-test",
|
|
55
|
+
plan: "pro",
|
|
56
|
+
limits: {},
|
|
57
|
+
features: {},
|
|
58
|
+
createdAt: "",
|
|
59
|
+
updatedAt: "",
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
afterEach(() => {
|
|
66
|
+
vi.clearAllMocks();
|
|
67
|
+
resetGatewaySessionCache();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
describe("GET /api/services", () => {
|
|
73
|
+
it("returns namespaces and services from the gateway", async () => {
|
|
74
|
+
const app = createChatApp();
|
|
75
|
+
|
|
76
|
+
mockFetch
|
|
77
|
+
// POST /auth/sessions
|
|
78
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ workspace_id: "ws-test" }), { status: 200 }))
|
|
79
|
+
// GET /tools
|
|
80
|
+
.mockResolvedValueOnce(
|
|
81
|
+
new Response(
|
|
82
|
+
JSON.stringify({
|
|
83
|
+
tools: [
|
|
84
|
+
{
|
|
85
|
+
provider: "github",
|
|
86
|
+
name: "github.repos_list",
|
|
87
|
+
operation: "repos_list",
|
|
88
|
+
description: "List repos",
|
|
89
|
+
inputSchema: { type: "object", properties: { per_page: { type: "number" } } },
|
|
90
|
+
},
|
|
91
|
+
],
|
|
92
|
+
workspace_id: "ws-test",
|
|
93
|
+
}),
|
|
94
|
+
{ status: 200 },
|
|
95
|
+
),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
const res = await app.request("/api/services", {
|
|
99
|
+
headers: { Authorization: "Bearer test-token" },
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(res.status).toBe(200);
|
|
103
|
+
const body = await res.json() as { namespaces: string[]; services: unknown[] };
|
|
104
|
+
expect(body.namespaces).toContain("github");
|
|
105
|
+
expect(body.services).toHaveLength(1);
|
|
106
|
+
expect((body.services[0] as { namespace: string }).namespace).toBe("github");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("returns 502 when gateway tools fetch fails", async () => {
|
|
110
|
+
const app = createChatApp();
|
|
111
|
+
|
|
112
|
+
mockFetch
|
|
113
|
+
.mockResolvedValueOnce(new Response(JSON.stringify({ workspace_id: "ws-test" }), { status: 200 }))
|
|
114
|
+
.mockResolvedValueOnce(new Response("Internal error", { status: 500 }));
|
|
115
|
+
|
|
116
|
+
const res = await app.request("/api/services", {
|
|
117
|
+
headers: { Authorization: "Bearer test-token" },
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(res.status).toBe(502);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("returns 401 without Authorization header", async () => {
|
|
124
|
+
const app = createChatApp();
|
|
125
|
+
const res = await app.request("/api/services");
|
|
126
|
+
expect(res.status).toBe(401);
|
|
127
|
+
});
|
|
128
|
+
});
|
|
@@ -0,0 +1,164 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
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
|
+
});
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "./dist",
|
|
5
|
+
"rootDir": "./src",
|
|
6
|
+
"declaration": true,
|
|
7
|
+
"declarationMap": true,
|
|
8
|
+
"module": "ESNext",
|
|
9
|
+
"moduleResolution": "bundler"
|
|
10
|
+
},
|
|
11
|
+
"include": ["src/**/*"],
|
|
12
|
+
"exclude": ["node_modules", "dist", "test"]
|
|
13
|
+
}
|
package/tsup.config.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineConfig } from "tsup";
|
|
2
|
+
|
|
3
|
+
export default defineConfig({
|
|
4
|
+
entry: {
|
|
5
|
+
index: "src/index.ts",
|
|
6
|
+
lambda: "src/lambda.ts",
|
|
7
|
+
},
|
|
8
|
+
format: ["esm"],
|
|
9
|
+
target: "node20",
|
|
10
|
+
platform: "node",
|
|
11
|
+
dts: true,
|
|
12
|
+
clean: true,
|
|
13
|
+
sourcemap: true,
|
|
14
|
+
splitting: false,
|
|
15
|
+
minify: false,
|
|
16
|
+
shims: true,
|
|
17
|
+
});
|