Skip to content
File

Blob: tests/worker/routes/party-admission.workers.test.ts

typescript165 lines
1import { env } from "cloudflare:workers";
2import { beforeEach, describe, expect, it } from "vitest";
3 
4import { PROD_ORIGIN, apiRequest } from "@tests/worker/helpers/request";
5import { bearerFor } from "@tests/worker/helpers/auth";
6import { createAccessToken } from "@/worker/lib/auth";
7import { resetD1Tables } from "@tests/worker/helpers/db";
8import { seedMembership, seedPage, seedPageShare, seedUser, seedWorkspace } from "@tests/worker/helpers/seeds";
9 
10async function upgradeRequest(
11 pageId: string,
12 params: { token?: string; share?: string; origin?: string; originHeader?: string } = {},
13): Promise<Response> {
14 const search: Record<string, string> = {};
15 if (params.token) search.token = params.token;
16 if (params.share) search.share = params.share;
17 return apiRequest(`/parties/doc-sync/${pageId}`, {
18 origin: params.origin ?? PROD_ORIGIN,
19 search,
20 headers: {
21 upgrade: "websocket",
22 connection: "upgrade",
23 "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==",
24 "sec-websocket-version": "13",
25 ...(params.originHeader ? { origin: params.originHeader } : {}),
26 },
27 });
28}
29 
30async function accessTokenFor(userId: string): Promise<string> {
31 const header = await bearerFor(userId);
32 return header.replace(/^Bearer\s+/, "");
33}
34 
35describe("routePartykitRequest: DocSync onBeforeConnect admission", () => {
36 beforeEach(async () => {
37 await resetD1Tables();
38 });
39 
40 it("rejects requests with no token and no share with 401", async () => {
41 const owner = await seedUser();
42 const ws = await seedWorkspace({ owner_id: owner.id });
43 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
44 
45 const res = await upgradeRequest(page.id);
46 expect(res.status).toBe(401);
47 expect(await res.text()).toBe("Authentication required");
48 });
49 
50 it("rejects requests with an unknown page id with 404", async () => {
51 const owner = await seedUser();
52 const token = await accessTokenFor(owner.id);
53 
54 const res = await upgradeRequest("page-unknown", { token });
55 expect(res.status).toBe(404);
56 expect(await res.text()).toBe("Page not found");
57 });
58 
59 it("rejects requests for an archived page with 404", async () => {
60 const owner = await seedUser();
61 const ws = await seedWorkspace({ owner_id: owner.id });
62 const page = await seedPage({
63 workspace_id: ws.id,
64 created_by: owner.id,
65 archived_at: "2026-04-01T00:00:00.000Z",
66 });
67 const token = await accessTokenFor(owner.id);
68 
69 const res = await upgradeRequest(page.id, { token });
70 expect(res.status).toBe(404);
71 expect(await res.text()).toBe("Page not found");
72 });
73 
74 it("rejects requests with an invalid bearer token with 401", async () => {
75 const owner = await seedUser();
76 const ws = await seedWorkspace({ owner_id: owner.id });
77 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
78 
79 const res = await upgradeRequest(page.id, { token: "not-a-valid-jwt" });
80 expect(res.status).toBe(401);
81 expect(await res.text()).toBe("Invalid token");
82 });
83 
84 it("rejects requests for a token referring to a user that does not exist with 401", async () => {
85 const owner = await seedUser();
86 const ws = await seedWorkspace({ owner_id: owner.id });
87 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
88 const ghostToken = await createAccessToken("user-does-not-exist", env);
89 
90 const res = await upgradeRequest(page.id, { token: ghostToken });
91 expect(res.status).toBe(401);
92 expect(await res.text()).toBe("Invalid token");
93 });
94 
95 it("rejects a user with no membership and no share with 403", async () => {
96 const owner = await seedUser();
97 const outsider = await seedUser();
98 const ws = await seedWorkspace({ owner_id: owner.id });
99 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
100 const token = await accessTokenFor(outsider.id);
101 
102 const res = await upgradeRequest(page.id, { token });
103 expect(res.status).toBe(403);
104 expect(await res.text()).toBe("You do not have access to this page");
105 });
106 
107 it("rejects an unknown share token with 403 and the shared-surface message", async () => {
108 const owner = await seedUser();
109 const ws = await seedWorkspace({ owner_id: owner.id });
110 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
111 
112 const res = await upgradeRequest(page.id, { share: "tok-not-real" });
113 expect(res.status).toBe(403);
114 expect(await res.text()).toBe("Invalid or expired share link");
115 });
116 
117 it("rejects browser-declared disallowed origins with 403 before any DB lookups", async () => {
118 const owner = await seedUser();
119 const ws = await seedWorkspace({ owner_id: owner.id });
120 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
121 const token = await accessTokenFor(owner.id);
122 
123 const res = await upgradeRequest(page.id, { token, originHeader: "https://evil.example" });
124 expect(res.status).toBe(403);
125 expect(await res.text()).toBe("Forbidden origin");
126 });
127 
128 it("admits full workspace members with a 101 switching-protocols response carrying a WebSocket", async () => {
129 const owner = await seedUser();
130 const member = await seedUser();
131 const ws = await seedWorkspace({ owner_id: owner.id });
132 await seedMembership({ user_id: member.id, workspace_id: ws.id, role: "member" });
133 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
134 const token = await accessTokenFor(member.id);
135 
136 const res = await upgradeRequest(page.id, { token });
137 
138 expect(res.status).toBe(101);
139 expect(res.webSocket).toBeTruthy();
140 res.webSocket?.accept();
141 res.webSocket?.close(1000, "test-cleanup");
142 });
143 
144 it("admits shared viewers presenting a valid link token", async () => {
145 const owner = await seedUser();
146 const ws = await seedWorkspace({ owner_id: owner.id });
147 const page = await seedPage({ workspace_id: ws.id, created_by: owner.id });
148 await seedPageShare({
149 page_id: page.id,
150 created_by: owner.id,
151 grantee_type: "link",
152 grantee_id: null,
153 link_token: "tok-view-admit",
154 permission: "view",
155 });
156 
157 const res = await upgradeRequest(page.id, { share: "tok-view-admit" });
158 
159 expect(res.status).toBe(101);
160 expect(res.webSocket).toBeTruthy();
161 res.webSocket?.accept();
162 res.webSocket?.close(1000, "test-cleanup");
163 });
164});