Skip to content

Commit 7004321

Browse files
backnotpropclaude
andauthored
test: add E2E encryption tests for paste service (#204)
Unit tests for encrypt/decrypt round-trip (key correctness, IV uniqueness, tamper detection) and live integration tests that verify the full compress → encrypt → POST → GET → decrypt → decompress pipeline against the deployed Cloudflare Worker. Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent b1f9193 commit 7004321

1 file changed

Lines changed: 172 additions & 0 deletions

File tree

packages/shared/crypto.test.ts

Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
/**
2+
* End-to-end encryption tests for the paste service.
3+
*
4+
* Tests the full pipeline: compress → encrypt → store → retrieve → decrypt → decompress
5+
* Run: bun test packages/shared/crypto.test.ts
6+
*/
7+
8+
import { describe, expect, test } from "bun:test";
9+
import { encrypt, decrypt } from "./crypto";
10+
import { deflateSync, inflateSync } from "bun";
11+
12+
// Bun's test runner doesn't have CompressionStream (browser API).
13+
// Use Bun's native zlib for the same deflate-raw + base64url pipeline.
14+
function compress(data: unknown): string {
15+
const json = JSON.stringify(data);
16+
const compressed = deflateSync(new TextEncoder().encode(json));
17+
let binary = "";
18+
for (let i = 0; i < compressed.length; i++) {
19+
binary += String.fromCharCode(compressed[i]);
20+
}
21+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
22+
}
23+
24+
function decompress(b64: string): unknown {
25+
const base64 = b64.replace(/-/g, "+").replace(/_/g, "/");
26+
const binary = atob(base64);
27+
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
28+
const decompressed = inflateSync(bytes);
29+
return JSON.parse(new TextDecoder().decode(decompressed));
30+
}
31+
32+
const PASTE_API = "https://plannotator-paste.plannotator.workers.dev";
33+
34+
// Realistic plan payload matching SharePayload shape
35+
const SAMPLE_PAYLOAD = {
36+
p: "# Implementation Plan\n\n## Step 1: Add authentication\n\nWe'll use JWT tokens stored in httpOnly cookies.\n\n## Step 2: Create middleware\n\nAdd auth middleware to all protected routes.\n\n```typescript\nconst authMiddleware = async (req: Request) => {\n const token = getCookie(req, 'auth');\n if (!token) throw new UnauthorizedError();\n return verify(token, SECRET);\n};\n```\n\n## Step 3: Update database schema\n\nAdd users table with email, password_hash, created_at columns.",
37+
a: [
38+
["C", "Add auth middleware", "Consider rate limiting on login endpoint", "reviewer", []],
39+
["R", "JWT tokens stored in httpOnly cookies", "Use refresh token rotation instead of single JWT", null, []],
40+
["D", "Add users table with email, password_hash, created_at columns.", null, []],
41+
],
42+
};
43+
44+
// --- Unit tests (no network) ---
45+
46+
describe("encrypt / decrypt round-trip", () => {
47+
test("encrypts and decrypts a string", async () => {
48+
const plaintext = "hello world";
49+
const { ciphertext, key } = await encrypt(plaintext);
50+
51+
expect(ciphertext).not.toBe(plaintext);
52+
expect(key.length).toBeGreaterThan(0);
53+
54+
const decrypted = await decrypt(ciphertext, key);
55+
expect(decrypted).toBe(plaintext);
56+
});
57+
58+
test("each encryption produces a unique ciphertext (random IV)", async () => {
59+
const plaintext = "same input";
60+
const a = await encrypt(plaintext);
61+
const b = await encrypt(plaintext);
62+
63+
expect(a.ciphertext).not.toBe(b.ciphertext);
64+
expect(a.key).not.toBe(b.key);
65+
});
66+
67+
test("wrong key fails to decrypt", async () => {
68+
const { ciphertext } = await encrypt("secret");
69+
const { key: wrongKey } = await encrypt("other");
70+
71+
expect(decrypt(ciphertext, wrongKey)).rejects.toThrow();
72+
});
73+
74+
test("tampered ciphertext fails to decrypt", async () => {
75+
const { ciphertext, key } = await encrypt("secret");
76+
77+
// Flip a character in the middle of the ciphertext
78+
const mid = Math.floor(ciphertext.length / 2);
79+
const tampered = ciphertext.slice(0, mid) +
80+
(ciphertext[mid] === 'A' ? 'B' : 'A') +
81+
ciphertext.slice(mid + 1);
82+
83+
expect(decrypt(tampered, key)).rejects.toThrow();
84+
});
85+
86+
test("handles large payloads", async () => {
87+
const large = "x".repeat(100_000);
88+
const { ciphertext, key } = await encrypt(large);
89+
const decrypted = await decrypt(ciphertext, key);
90+
expect(decrypted).toBe(large);
91+
});
92+
});
93+
94+
describe("full pipeline: compress → encrypt → decrypt → decompress", () => {
95+
test("round-trips a SharePayload", async () => {
96+
const compressed = await compress(SAMPLE_PAYLOAD);
97+
const { ciphertext, key } = await encrypt(compressed);
98+
99+
// Ciphertext should not contain the original plan text
100+
expect(ciphertext).not.toContain("Implementation Plan");
101+
102+
const decrypted = await decrypt(ciphertext, key);
103+
const decompressed = await decompress(decrypted);
104+
105+
expect(decompressed).toEqual(SAMPLE_PAYLOAD);
106+
});
107+
});
108+
109+
// --- Integration tests (hit live paste service) ---
110+
111+
describe("live paste service E2E", () => {
112+
test("encrypt → POST → GET → decrypt → decompress", async () => {
113+
// 1. Compress
114+
const compressed = await compress(SAMPLE_PAYLOAD);
115+
116+
// 2. Encrypt
117+
const { ciphertext, key } = await encrypt(compressed);
118+
119+
// 3. Store — server sees only ciphertext
120+
const postRes = await fetch(`${PASTE_API}/api/paste`, {
121+
method: "POST",
122+
headers: { "Content-Type": "application/json" },
123+
body: JSON.stringify({ data: ciphertext }),
124+
});
125+
expect(postRes.status).toBe(201);
126+
const { id } = (await postRes.json()) as { id: string };
127+
expect(id).toMatch(/^[A-Za-z0-9]{8}$/);
128+
129+
// 4. Retrieve
130+
const getRes = await fetch(`${PASTE_API}/api/paste/${id}`);
131+
expect(getRes.status).toBe(200);
132+
133+
// Verify Cache-Control header
134+
expect(getRes.headers.get("cache-control")).toBe("private, no-store");
135+
136+
const { data: storedData } = (await getRes.json()) as { data: string };
137+
138+
// 5. Verify server stores only ciphertext (not readable plan data)
139+
expect(storedData).toBe(ciphertext);
140+
expect(storedData).not.toContain("Implementation Plan");
141+
142+
// 6. Decrypt
143+
const decrypted = await decrypt(storedData, key);
144+
145+
// 7. Decompress
146+
const result = await decompress(decrypted);
147+
expect(result).toEqual(SAMPLE_PAYLOAD);
148+
});
149+
150+
test("GET without key returns opaque ciphertext", async () => {
151+
const compressed = await compress({ p: "secret plan", a: [] });
152+
const { ciphertext } = await encrypt(compressed);
153+
154+
const postRes = await fetch(`${PASTE_API}/api/paste`, {
155+
method: "POST",
156+
headers: { "Content-Type": "application/json" },
157+
body: JSON.stringify({ data: ciphertext }),
158+
});
159+
const { id } = (await postRes.json()) as { id: string };
160+
161+
const getRes = await fetch(`${PASTE_API}/api/paste/${id}`);
162+
const { data } = (await getRes.json()) as { data: string };
163+
164+
// Data is opaque — cannot be decompressed without decryption
165+
expect(() => decompress(data)).toThrow();
166+
});
167+
168+
test("expired/nonexistent paste returns 404", async () => {
169+
const res = await fetch(`${PASTE_API}/api/paste/ZZZZZZZZ`);
170+
expect(res.status).toBe(404);
171+
});
172+
});

0 commit comments

Comments
 (0)