diff --git a/packages/atproto-browser/lib/navigation.test.ts b/packages/atproto-browser/lib/navigation.test.ts new file mode 100644 index 00000000..f4e13543 --- /dev/null +++ b/packages/atproto-browser/lib/navigation.test.ts @@ -0,0 +1,127 @@ +import { test, vi, expect, describe, afterAll } from "vitest"; +import { navigateAtUri } from "./navigation"; + +vi.mock("server-only", () => { + return { + // mock server-only module + }; +}); + +class RedirectError extends Error { + constructor(public readonly location: string) { + super(`Redirecting to ${location}`); + } +} + +vi.mock("next/navigation", () => ({ + redirect: vi.fn((path) => { + throw new RedirectError(path); + }), +})); + +const PATH_SUFFIXES = [ + "", + "/collection", + "/collection/rkey", + "/collection/rkey", +]; + +const makeValidCases = (authority: string) => + PATH_SUFFIXES.flatMap((suffix) => { + const result = `/at/${authority}${suffix}`; + return [ + [`${authority}${suffix}`, result], + [`at://${authority}${suffix}`, result], + ]; + }); + +const VALID_CASES = [ + ...makeValidCases("valid-handle.com"), + ...makeValidCases("did:plc:hello"), + ...makeValidCases("did:web:hello"), + + // punycode + ...PATH_SUFFIXES.flatMap((suffix) => { + const result = `/at/xn--maana-pta.com${suffix}`; + return [ + [`mañana.com${suffix}`, result], + [`at://mañana.com${suffix}`, result], + ]; + }), + + ["@valid-handle.com", "/at/valid-handle.com"], +]; + +describe("navigates valid input", () => { + test.each(VALID_CASES)("%s -> %s", async (input, expectedRedirect) => { + await expect(navigateAtUri(input)).rejects.toThrowError( + new RedirectError(expectedRedirect), + ); + }); +}); + +describe("strips whitespace and zero-width characters from valid input", () => { + test.each(VALID_CASES.map((c) => [...c]))( + "%s -> %s", + async (input, expectedRedirect) => { + await expect( + navigateAtUri(` ${input}\u200B\u200D\uFEFF \u202C`), + ).rejects.toThrowError(new RedirectError(expectedRedirect)); + }, + ); +}); + +describe("shows error on invalid input", () => { + test.each([ + ["@", "Invalid URI: @"], + ["@invalid", "Invalid URI: @invalid"], + // ["invalid", "Invalid URI: invalid"], + ])('"%s" -> "%s"', async (input, expectedError) => { + expect((await navigateAtUri(input)).error).toMatch(expectedError); + }); +}); + +const originalFetch = global.fetch; +const mockFetch = vi.fn(); +global.fetch = mockFetch; +afterAll(() => { + global.fetch = originalFetch; +}); + +describe("valid http input with link", () => { + // Include only cases with the protocol prefix + test.each(VALID_CASES.filter((c) => c[0]!.startsWith("at://")))( + 'valid http input with "%s" -> "%s"', + async (link, expectedUri) => { + mockFetch.mockResolvedValueOnce( + new Response(/*html*/ ` + + + + + + + `), + ); + + await expect(navigateAtUri("http://example.com")).rejects.toThrowError( + new RedirectError(expectedUri), + ); + }, + ); +}); + +test("valid http input without included at uri", async () => { + mockFetch.mockResolvedValueOnce( + new Response(/*html*/ ` + + + + + `), + ); + + expect(await navigateAtUri("http://example.com")).toEqual({ + error: "No AT URI found in http://example.com", + }); +}); diff --git a/packages/atproto-browser/lib/navigation.ts b/packages/atproto-browser/lib/navigation.ts index 561e3036..b69f38f3 100644 --- a/packages/atproto-browser/lib/navigation.ts +++ b/packages/atproto-browser/lib/navigation.ts @@ -9,7 +9,9 @@ import { domainToASCII } from "url"; export async function navigateAtUri(input: string) { // Remove all zero-width characters and weird control codes from the input - const sanitizedInput = input.replace(/[\u200B-\u200D\uFEFF\u202C]/g, ""); + const sanitizedInput = input + .replace(/[\u200B-\u200D\uFEFF\u202C]/g, "") + .trim(); // Try punycode encoding the input as a domain name and parse it as a handle const handle = parseHandle(domainToASCII(sanitizedInput) || sanitizedInput); diff --git a/packages/atproto-browser/package.json b/packages/atproto-browser/package.json index fd0b35e7..988112e0 100644 --- a/packages/atproto-browser/package.json +++ b/packages/atproto-browser/package.json @@ -7,7 +7,8 @@ "build": "next build", "start": "next start", "lint": "next lint", - "type-check": "tsc --noEmit" + "type-check": "tsc --noEmit", + "test": "vitest" }, "dependencies": { "@atproto/did": "^0.1.1", @@ -36,6 +37,8 @@ "eslint": "^8", "eslint-config-next": "15.0.0-rc.0", "tsx": "^4.16.5", - "typescript": "^5" + "typescript": "^5", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^2.0.4" } } diff --git a/packages/atproto-browser/vite.config.ts b/packages/atproto-browser/vite.config.ts new file mode 100644 index 00000000..ff8ab952 --- /dev/null +++ b/packages/atproto-browser/vite.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; +import tsconfigPaths from "vite-tsconfig-paths"; + +export default defineConfig({ + plugins: [tsconfigPaths()], + test: { + environment: "node", + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ad1429f..2ab396bc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -93,6 +93,12 @@ importers: typescript: specifier: ^5 version: 5.5.2 + vite-tsconfig-paths: + specifier: ^4.3.2 + version: 4.3.2(typescript@5.5.2)(vite@5.3.5(@types/node@20.13.0)(terser@5.34.1)) + vitest: + specifier: ^2.0.4 + version: 2.0.4(@types/node@20.13.0)(jsdom@24.1.1(bufferutil@4.0.8)(utf-8-validate@6.0.3))(terser@5.34.1) packages/eslint-config: devDependencies: @@ -11709,6 +11715,10 @@ snapshots: optionalDependencies: typescript: 5.4.5 + tsconfck@3.1.1(typescript@5.5.2): + optionalDependencies: + typescript: 5.5.2 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -11923,6 +11933,17 @@ snapshots: - supports-color - typescript + vite-tsconfig-paths@4.3.2(typescript@5.5.2)(vite@5.3.5(@types/node@20.13.0)(terser@5.34.1)): + dependencies: + debug: 4.3.5 + globrex: 0.1.2 + tsconfck: 3.1.1(typescript@5.5.2) + optionalDependencies: + vite: 5.3.5(@types/node@20.13.0)(terser@5.34.1) + transitivePeerDependencies: + - supports-color + - typescript + vite@5.3.5(@types/node@20.13.0)(terser@5.34.1): dependencies: esbuild: 0.21.5