mirror of
https://github.com/willchen96/mike.git
synced 2026-07-24 23:41:04 +02:00
Merge pull request #228 from amal66/olp-pr/test-harness
[Testing 05] test: minimal vitest harness for backend and frontend
This commit is contained in:
commit
8a34a6457f
7 changed files with 2683 additions and 31 deletions
1230
backend/package-lock.json
generated
1230
backend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,7 +5,8 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsc",
|
"build": "tsc",
|
||||||
"start": "node dist/index.js"
|
"start": "node dist/index.js",
|
||||||
|
"test": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@anthropic-ai/sdk": "^0.90.0",
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
|
|
@ -38,7 +39,8 @@
|
||||||
"@types/node": "^22.14.1",
|
"@types/node": "^22.14.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"tsx": "^4.19.3",
|
"tsx": "^4.19.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"vitest": "^4.1.9"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-only"
|
"license": "AGPL-3.0-only"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
109
backend/src/lib/__tests__/downloadTokens.test.ts
Normal file
109
backend/src/lib/__tests__/downloadTokens.test.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
import { describe, it, expect, beforeAll, afterAll } from "vitest";
|
||||||
|
import { signDownload, verifyDownload, buildDownloadUrl } from "../downloadTokens";
|
||||||
|
|
||||||
|
const SECRET = "test-secret-32-bytes-long-enough!!";
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(() => {
|
||||||
|
delete process.env.DOWNLOAD_SIGNING_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("signDownload", () => {
|
||||||
|
it("returns a two-part dot-separated token", () => {
|
||||||
|
const token = signDownload("documents/user/doc.pdf", "contract.pdf");
|
||||||
|
const parts = token.split(".");
|
||||||
|
expect(parts).toHaveLength(2);
|
||||||
|
expect(parts[0].length).toBeGreaterThan(0);
|
||||||
|
expect(parts[1].length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("produces different tokens for different paths", () => {
|
||||||
|
const t1 = signDownload("documents/a/file.pdf", "a.pdf");
|
||||||
|
const t2 = signDownload("documents/b/file.pdf", "b.pdf");
|
||||||
|
expect(t1).not.toBe(t2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses base64url characters only (no +, /, =)", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
expect(token).not.toMatch(/[+/=]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("verifyDownload", () => {
|
||||||
|
it("round-trips a valid token", () => {
|
||||||
|
const path = "documents/user123/doc456/source.pdf";
|
||||||
|
const filename = "Contract Final v2.pdf";
|
||||||
|
const token = signDownload(path, filename);
|
||||||
|
const result = verifyDownload(token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.path).toBe(path);
|
||||||
|
expect(result!.filename).toBe(filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a tampered payload", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
const [, sig] = token.split(".");
|
||||||
|
const fakePayload = Buffer.from(
|
||||||
|
JSON.stringify({ p: "documents/attacker/file.pdf", f: "file.pdf" }),
|
||||||
|
)
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
expect(verifyDownload(`${fakePayload}.${sig}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a tampered signature", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
const [enc] = token.split(".");
|
||||||
|
const fakeSig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
|
||||||
|
expect(verifyDownload(`${enc}.${fakeSig}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a token with too many parts", () => {
|
||||||
|
expect(verifyDownload("a.b.c")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for a token with too few parts", () => {
|
||||||
|
expect(verifyDownload("onlyonepart")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when payload JSON is missing required fields", () => {
|
||||||
|
const bad = Buffer.from(JSON.stringify({ x: 1 }))
|
||||||
|
.toString("base64")
|
||||||
|
.replace(/\+/g, "-")
|
||||||
|
.replace(/\//g, "_")
|
||||||
|
.replace(/=+$/g, "");
|
||||||
|
const sig = Buffer.alloc(32).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
||||||
|
expect(verifyDownload(`${bad}.${sig}`)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null when signed with a different secret", () => {
|
||||||
|
const token = signDownload("documents/user/file.pdf", "file.pdf");
|
||||||
|
process.env.DOWNLOAD_SIGNING_SECRET = "different-secret-value-!!";
|
||||||
|
const result = verifyDownload(token);
|
||||||
|
process.env.DOWNLOAD_SIGNING_SECRET = SECRET;
|
||||||
|
expect(result).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildDownloadUrl", () => {
|
||||||
|
it("returns a path starting with /download/", () => {
|
||||||
|
const url = buildDownloadUrl("documents/user/file.pdf", "file.pdf");
|
||||||
|
expect(url).toMatch(/^\/download\//);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("embeds a verifiable token in the URL", () => {
|
||||||
|
const path = "documents/user/file.pdf";
|
||||||
|
const filename = "file.pdf";
|
||||||
|
const url = buildDownloadUrl(path, filename);
|
||||||
|
const token = url.replace("/download/", "");
|
||||||
|
const result = verifyDownload(token);
|
||||||
|
expect(result).not.toBeNull();
|
||||||
|
expect(result!.path).toBe(path);
|
||||||
|
expect(result!.filename).toBe(filename);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -16,5 +16,5 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": ["src/**/*"],
|
"include": ["src/**/*"],
|
||||||
"exclude": ["node_modules", "dist"]
|
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/**/__tests__/**"]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1320
frontend/package-lock.json
generated
1320
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -7,6 +7,7 @@
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "eslint",
|
"lint": "eslint",
|
||||||
|
"test": "vitest run",
|
||||||
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
"preview": "opennextjs-cloudflare build && opennextjs-cloudflare preview",
|
||||||
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
"deploy": "opennextjs-cloudflare build && opennextjs-cloudflare deploy",
|
||||||
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
"upload": "opennextjs-cloudflare build && opennextjs-cloudflare upload",
|
||||||
|
|
@ -73,6 +74,7 @@
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5",
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.1.9",
|
||||||
"wrangler": "^4.90.0"
|
"wrangler": "^4.90.0"
|
||||||
},
|
},
|
||||||
"license": "AGPL-3.0-only"
|
"license": "AGPL-3.0-only"
|
||||||
|
|
|
||||||
45
frontend/src/app/lib/utils.test.ts
Normal file
45
frontend/src/app/lib/utils.test.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { cn, diceCoefficient, isFuzzyMatch } from "./utils";
|
||||||
|
|
||||||
|
describe("cn", () => {
|
||||||
|
it("joins truthy class names and drops falsy ones", () => {
|
||||||
|
expect(cn("a", false && "b", undefined, "c")).toBe("a c");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("merges conflicting tailwind classes, keeping the last", () => {
|
||||||
|
expect(cn("px-2", "px-4")).toBe("px-4");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("diceCoefficient", () => {
|
||||||
|
it("returns 1 for identical strings (ignoring case and punctuation)", () => {
|
||||||
|
expect(diceCoefficient("Hello, World!", "hello world")).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 0 when either input is empty", () => {
|
||||||
|
expect(diceCoefficient("", "anything")).toBe(0);
|
||||||
|
expect(diceCoefficient("anything", "")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns a partial score for partially overlapping strings", () => {
|
||||||
|
const score = diceCoefficient("night", "nacht");
|
||||||
|
expect(score).toBeGreaterThan(0);
|
||||||
|
expect(score).toBeLessThan(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isFuzzyMatch", () => {
|
||||||
|
it("matches near-identical strings above the default threshold", () => {
|
||||||
|
expect(isFuzzyMatch("organization", "organisation")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects clearly different strings", () => {
|
||||||
|
expect(isFuzzyMatch("apple", "zebra")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("respects a custom threshold", () => {
|
||||||
|
// "night" vs "nacht" scores ~0.25 — passes a low bar, fails a high one.
|
||||||
|
expect(isFuzzyMatch("night", "nacht", 0.1)).toBe(true);
|
||||||
|
expect(isFuzzyMatch("night", "nacht", 0.9)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue