From 5b503c3f7fcdadade48799a909946f7b942ea256 Mon Sep 17 00:00:00 2001 From: Lewis Date: Wed, 18 Mar 2026 18:36:16 +0200 Subject: [PATCH] refactor(frontend): delete utility libraries and validation --- frontend/src/lib/utils/array.ts | 205 ------------- frontend/src/lib/utils/async.ts | 245 --------------- frontend/src/lib/utils/index.ts | 27 -- frontend/src/lib/utils/option.ts | 85 ------ frontend/src/lib/validation.ts | 286 ------------------ .../tests/migration/atproto-client.test.ts | 255 ---------------- frontend/src/tests/migration/storage.test.ts | 7 +- frontend/src/tests/oauth-registration.test.ts | 4 +- frontend/src/tests/utils.ts | 97 ------ 9 files changed, 7 insertions(+), 1204 deletions(-) delete mode 100644 frontend/src/lib/utils/array.ts delete mode 100644 frontend/src/lib/utils/async.ts delete mode 100644 frontend/src/lib/utils/index.ts delete mode 100644 frontend/src/lib/utils/option.ts delete mode 100644 frontend/src/lib/validation.ts delete mode 100644 frontend/src/tests/utils.ts diff --git a/frontend/src/lib/utils/array.ts b/frontend/src/lib/utils/array.ts deleted file mode 100644 index 3ad9228..0000000 --- a/frontend/src/lib/utils/array.ts +++ /dev/null @@ -1,205 +0,0 @@ -import type { Option } from "./option.ts"; - -export function first(arr: readonly T[]): Option { - return arr[0] ?? null; -} - -export function last(arr: readonly T[]): Option { - return arr[arr.length - 1] ?? null; -} - -export function at(arr: readonly T[], index: number): Option { - if (index < 0) index = arr.length + index; - return arr[index] ?? null; -} - -export function find( - arr: readonly T[], - predicate: (t: T) => boolean, -): Option { - return arr.find(predicate) ?? null; -} - -export function findMap( - arr: readonly T[], - fn: (t: T) => Option, -): Option { - for (const item of arr) { - const result = fn(item); - if (result != null) return result; - } - return null; -} - -export function findIndex( - arr: readonly T[], - predicate: (t: T) => boolean, -): Option { - const index = arr.findIndex(predicate); - return index >= 0 ? index : null; -} - -export function partition( - arr: readonly T[], - predicate: (t: T) => boolean, -): [T[], T[]] { - const pass: T[] = []; - const fail: T[] = []; - for (const item of arr) { - if (predicate(item)) { - pass.push(item); - } else { - fail.push(item); - } - } - return [pass, fail]; -} - -export function groupBy( - arr: readonly T[], - keyFn: (t: T) => K, -): Record { - const result = {} as Record; - for (const item of arr) { - const key = keyFn(item); - if (!result[key]) { - result[key] = []; - } - result[key].push(item); - } - return result; -} - -export function unique(arr: readonly T[]): T[] { - return [...new Set(arr)]; -} - -export function uniqueBy(arr: readonly T[], keyFn: (t: T) => K): T[] { - const seen = new Set(); - const result: T[] = []; - for (const item of arr) { - const key = keyFn(item); - if (!seen.has(key)) { - seen.add(key); - result.push(item); - } - } - return result; -} - -export function sortBy( - arr: readonly T[], - keyFn: (t: T) => number | string, -): T[] { - return [...arr].sort((a, b) => { - const ka = keyFn(a); - const kb = keyFn(b); - if (ka < kb) return -1; - if (ka > kb) return 1; - return 0; - }); -} - -export function sortByDesc( - arr: readonly T[], - keyFn: (t: T) => number | string, -): T[] { - return [...arr].sort((a, b) => { - const ka = keyFn(a); - const kb = keyFn(b); - if (ka > kb) return -1; - if (ka < kb) return 1; - return 0; - }); -} - -export function chunk(arr: readonly T[], size: number): T[][] { - const result: T[][] = []; - for (let i = 0; i < arr.length; i += size) { - result.push(arr.slice(i, i + size)); - } - return result; -} - -export function zip(a: readonly T[], b: readonly U[]): [T, U][] { - const length = Math.min(a.length, b.length); - const result: [T, U][] = []; - for (let i = 0; i < length; i++) { - result.push([a[i], b[i]]); - } - return result; -} - -export function zipWith( - a: readonly T[], - b: readonly U[], - fn: (t: T, u: U) => R, -): R[] { - const length = Math.min(a.length, b.length); - const result: R[] = []; - for (let i = 0; i < length; i++) { - result.push(fn(a[i], b[i])); - } - return result; -} - -export function intersperse(arr: readonly T[], separator: T): T[] { - if (arr.length <= 1) return [...arr]; - const result: T[] = [arr[0]]; - for (let i = 1; i < arr.length; i++) { - result.push(separator, arr[i]); - } - return result; -} - -export function range(start: number, end: number): number[] { - const result: number[] = []; - for (let i = start; i < end; i++) { - result.push(i); - } - return result; -} - -export function isEmpty(arr: readonly T[]): boolean { - return arr.length === 0; -} - -export function isNonEmpty(arr: readonly T[]): arr is [T, ...T[]] { - return arr.length > 0; -} - -export function sum(arr: readonly number[]): number { - return arr.reduce((acc, n) => acc + n, 0); -} - -export function sumBy(arr: readonly T[], fn: (t: T) => number): number { - return arr.reduce((acc, t) => acc + fn(t), 0); -} - -export function maxBy(arr: readonly T[], fn: (t: T) => number): Option { - if (arr.length === 0) return null; - let max = arr[0]; - let maxValue = fn(max); - for (let i = 1; i < arr.length; i++) { - const value = fn(arr[i]); - if (value > maxValue) { - max = arr[i]; - maxValue = value; - } - } - return max; -} - -export function minBy(arr: readonly T[], fn: (t: T) => number): Option { - if (arr.length === 0) return null; - let min = arr[0]; - let minValue = fn(min); - for (let i = 1; i < arr.length; i++) { - const value = fn(arr[i]); - if (value < minValue) { - min = arr[i]; - minValue = value; - } - } - return min; -} diff --git a/frontend/src/lib/utils/async.ts b/frontend/src/lib/utils/async.ts deleted file mode 100644 index 90e71da..0000000 --- a/frontend/src/lib/utils/async.ts +++ /dev/null @@ -1,245 +0,0 @@ -import { err, type Result } from "../types/result.ts"; - -export function debounce) => void>( - fn: T, - ms: number, -): T & { cancel: () => void } { - let timeoutId: ReturnType | null = null; - - const debounced = ((...args: Parameters) => { - if (timeoutId) clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - fn(...args); - timeoutId = null; - }, ms); - }) as T & { cancel: () => void }; - - debounced.cancel = () => { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - }; - - return debounced; -} - -export function throttle) => void>( - fn: T, - ms: number, -): T { - let lastCall = 0; - let timeoutId: ReturnType | null = null; - - return ((...args: Parameters) => { - const now = Date.now(); - const remaining = ms - (now - lastCall); - - if (remaining <= 0) { - if (timeoutId) { - clearTimeout(timeoutId); - timeoutId = null; - } - lastCall = now; - fn(...args); - } else if (!timeoutId) { - timeoutId = setTimeout(() => { - lastCall = Date.now(); - timeoutId = null; - fn(...args); - }, remaining); - } - }) as T; -} - -export function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} - -export async function retry( - fn: () => Promise, - options: { - attempts?: number; - delay?: number; - backoff?: number; - shouldRetry?: (error: unknown, attempt: number) => boolean; - } = {}, -): Promise { - const { - attempts = 3, - delay = 1000, - backoff = 2, - shouldRetry = () => true, - } = options; - - let lastError: unknown; - let currentDelay = delay; - - for (let attempt = 1; attempt <= attempts; attempt++) { - try { - return await fn(); - } catch (error) { - lastError = error; - if (attempt === attempts || !shouldRetry(error, attempt)) { - throw error; - } - await sleep(currentDelay); - currentDelay *= backoff; - } - } - - throw lastError; -} - -export async function retryResult( - fn: () => Promise>, - options: { - attempts?: number; - delay?: number; - backoff?: number; - shouldRetry?: (error: E, attempt: number) => boolean; - } = {}, -): Promise> { - const { - attempts = 3, - delay = 1000, - backoff = 2, - shouldRetry = () => true, - } = options; - - let lastResult: Result | null = null; - let currentDelay = delay; - - for (let attempt = 1; attempt <= attempts; attempt++) { - const result = await fn(); - lastResult = result; - - if (result.ok) { - return result; - } - - if (attempt === attempts || !shouldRetry(result.error, attempt)) { - return result; - } - - await sleep(currentDelay); - currentDelay *= backoff; - } - - return lastResult!; -} - -export function timeout(promise: Promise, ms: number): Promise { - return new Promise((resolve, reject) => { - const timeoutId = setTimeout(() => { - reject(new Error(`Timeout after ${ms}ms`)); - }, ms); - - promise - .then((value) => { - clearTimeout(timeoutId); - resolve(value); - }) - .catch((error) => { - clearTimeout(timeoutId); - reject(error); - }); - }); -} - -export async function timeoutResult( - promise: Promise>, - ms: number, -): Promise> { - try { - return await timeout(promise, ms); - } catch (e) { - return err(e instanceof Error ? e : new Error(String(e))); - } -} - -export async function parallel( - tasks: (() => Promise)[], - concurrency: number, -): Promise { - const results: T[] = []; - const executing: Promise[] = []; - - for (const task of tasks) { - const p = task().then((result) => { - results.push(result); - }); - - executing.push(p); - - if (executing.length >= concurrency) { - await Promise.race(executing); - executing.splice( - executing.findIndex((e) => e === p), - 1, - ); - } - } - - await Promise.all(executing); - return results; -} - -export async function mapParallel( - items: T[], - fn: (item: T, index: number) => Promise, - concurrency: number, -): Promise { - const results: U[] = new Array(items.length); - const executing: Promise[] = []; - - for (let i = 0; i < items.length; i++) { - const index = i; - const p = fn(items[index], index).then((result) => { - results[index] = result; - }); - - executing.push(p); - - if (executing.length >= concurrency) { - await Promise.race(executing); - const doneIndex = executing.findIndex( - (e) => (e as Promise & { _done?: boolean })._done !== false, - ); - if (doneIndex >= 0) { - executing.splice(doneIndex, 1); - } - } - } - - await Promise.all(executing); - return results; -} - -export function createAbortable( - fn: (signal: AbortSignal) => Promise, -): { promise: Promise; abort: () => void } { - const controller = new AbortController(); - return { - promise: fn(controller.signal), - abort: () => controller.abort(), - }; -} - -export interface Deferred { - promise: Promise; - resolve: (value: T) => void; - reject: (error: unknown) => void; -} - -export function deferred(): Deferred { - let resolve!: (value: T) => void; - let reject!: (error: unknown) => void; - - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - - return { promise, resolve, reject }; -} diff --git a/frontend/src/lib/utils/index.ts b/frontend/src/lib/utils/index.ts deleted file mode 100644 index e2c95c8..0000000 --- a/frontend/src/lib/utils/index.ts +++ /dev/null @@ -1,27 +0,0 @@ -export * from "./option.ts"; -export { - at, - chunk, - find, - findIndex, - findMap, - first, - groupBy, - intersperse, - isEmpty, - isNonEmpty, - last, - maxBy, - minBy, - partition, - range, - sortBy, - sortByDesc, - sum, - sumBy, - unique, - uniqueBy, - zip as zipArrays, - zipWith as zipArraysWith, -} from "./array.ts"; -export * from "./async.ts"; diff --git a/frontend/src/lib/utils/option.ts b/frontend/src/lib/utils/option.ts deleted file mode 100644 index 252fa8e..0000000 --- a/frontend/src/lib/utils/option.ts +++ /dev/null @@ -1,85 +0,0 @@ -export type Option = T | null | undefined; - -export function isSome(opt: Option): opt is T { - return opt != null; -} - -export function isNone(opt: Option): opt is null | undefined { - return opt == null; -} - -export function map(opt: Option, fn: (t: T) => U): Option { - return isSome(opt) ? fn(opt) : null; -} - -export function flatMap( - opt: Option, - fn: (t: T) => Option, -): Option { - return isSome(opt) ? fn(opt) : null; -} - -export function filter( - opt: Option, - predicate: (t: T) => boolean, -): Option { - return isSome(opt) && predicate(opt) ? opt : null; -} - -export function getOrElse(opt: Option, defaultValue: T): T { - return isSome(opt) ? opt : defaultValue; -} - -export function getOrElseLazy(opt: Option, fn: () => T): T { - return isSome(opt) ? opt : fn(); -} - -export function getOrThrow(opt: Option, error?: string | Error): T { - if (isSome(opt)) return opt; - if (error instanceof Error) throw error; - throw new Error(error ?? "Expected value but got null/undefined"); -} - -export function tap(opt: Option, fn: (t: T) => void): Option { - if (isSome(opt)) fn(opt); - return opt; -} - -export function match( - opt: Option, - handlers: { some: (t: T) => U; none: () => U }, -): U { - return isSome(opt) ? handlers.some(opt) : handlers.none(); -} - -export function toArray(opt: Option): T[] { - return isSome(opt) ? [opt] : []; -} - -export function fromArray(arr: T[]): Option { - return arr.length > 0 ? arr[0] : null; -} - -export function zip(a: Option, b: Option): Option<[T, U]> { - return isSome(a) && isSome(b) ? [a, b] : null; -} - -export function zipWith( - a: Option, - b: Option, - fn: (t: T, u: U) => R, -): Option { - return isSome(a) && isSome(b) ? fn(a, b) : null; -} - -export function or(a: Option, b: Option): Option { - return isSome(a) ? a : b; -} - -export function orLazy(a: Option, fn: () => Option): Option { - return isSome(a) ? a : fn(); -} - -export function and(a: Option, b: Option): Option { - return isSome(a) ? b : null; -} diff --git a/frontend/src/lib/validation.ts b/frontend/src/lib/validation.ts deleted file mode 100644 index ed816cb..0000000 --- a/frontend/src/lib/validation.ts +++ /dev/null @@ -1,286 +0,0 @@ -import { err, ok, type Result } from "./types/result.ts"; -import { - type AtUri, - type Cid, - type Did, - type DidPlc, - type DidWeb, - type EmailAddress, - type Handle, - isAtUri, - isCid, - isDid, - isDidPlc, - isDidWeb, - isEmail, - isHandle, - isISODate, - isNsid, - type ISODateString, - type Nsid, -} from "./types/branded.ts"; - -export class ValidationError extends Error { - constructor( - message: string, - public readonly field?: string, - public readonly value?: unknown, - ) { - super(message); - this.name = "ValidationError"; - } -} - -export function parseDid(s: string): Result { - if (isDid(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid DID: ${s}`, "did", s)); -} - -export function parseDidPlc(s: string): Result { - if (isDidPlc(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s)); -} - -export function parseDidWeb(s: string): Result { - if (isDidWeb(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s)); -} - -export function parseHandle(s: string): Result { - const trimmed = s.trim().toLowerCase(); - if (isHandle(trimmed)) { - return ok(trimmed); - } - return err(new ValidationError(`Invalid handle: ${s}`, "handle", s)); -} - -export function parseEmail(s: string): Result { - const trimmed = s.trim().toLowerCase(); - if (isEmail(trimmed)) { - return ok(trimmed); - } - return err(new ValidationError(`Invalid email: ${s}`, "email", s)); -} - -export function parseAtUri(s: string): Result { - if (isAtUri(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s)); -} - -export function parseCid(s: string): Result { - if (isCid(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid CID: ${s}`, "cid", s)); -} - -export function parseNsid(s: string): Result { - if (isNsid(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s)); -} - -export function parseISODate( - s: string, -): Result { - if (isISODate(s)) { - return ok(s); - } - return err(new ValidationError(`Invalid ISO date: ${s}`, "date", s)); -} - -export interface PasswordValidationResult { - valid: boolean; - errors: string[]; - strength: "weak" | "fair" | "good" | "strong"; -} - -export function validatePassword(password: string): PasswordValidationResult { - const errors: string[] = []; - - if (password.length < 8) { - errors.push("Password must be at least 8 characters"); - } - if (password.length > 256) { - errors.push("Password must be at most 256 characters"); - } - if (!/[a-z]/.test(password)) { - errors.push("Password must contain a lowercase letter"); - } - if (!/[A-Z]/.test(password)) { - errors.push("Password must contain an uppercase letter"); - } - if (!/\d/.test(password)) { - errors.push("Password must contain a number"); - } - - let strength: PasswordValidationResult["strength"] = "weak"; - if (errors.length === 0) { - const hasSpecial = /[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]/.test(password); - const isLong = password.length >= 12; - const isVeryLong = password.length >= 16; - - if (isVeryLong && hasSpecial) { - strength = "strong"; - } else if (isLong || hasSpecial) { - strength = "good"; - } else { - strength = "fair"; - } - } - - return { - valid: errors.length === 0, - errors, - strength, - }; -} - -export function validateHandle( - handle: string, -): Result { - const trimmed = handle.trim().toLowerCase(); - - if (trimmed.length < 3) { - return err( - new ValidationError( - "Handle must be at least 3 characters", - "handle", - handle, - ), - ); - } - - if (trimmed.length > 253) { - return err( - new ValidationError( - "Handle must be at most 253 characters", - "handle", - handle, - ), - ); - } - - if (!isHandle(trimmed)) { - return err(new ValidationError("Invalid handle format", "handle", handle)); - } - - return ok(trimmed); -} - -export function validateInviteCode( - code: string, -): Result { - const trimmed = code.trim(); - - if (trimmed.length === 0) { - return err( - new ValidationError("Invite code is required", "inviteCode", code), - ); - } - - const pattern = /^[a-zA-Z0-9-]+$/; - if (!pattern.test(trimmed)) { - return err( - new ValidationError("Invalid invite code format", "inviteCode", code), - ); - } - - return ok(trimmed); -} - -export function validateTotpCode( - code: string, -): Result { - const trimmed = code.trim().replace(/\s/g, ""); - - if (!/^\d{6}$/.test(trimmed)) { - return err(new ValidationError("TOTP code must be 6 digits", "code", code)); - } - - return ok(trimmed); -} - -export function validateBackupCode( - code: string, -): Result { - const trimmed = code.trim().replace(/\s/g, "").toLowerCase(); - - if (!/^[a-z0-9]{8}$/.test(trimmed)) { - return err(new ValidationError("Invalid backup code format", "code", code)); - } - - return ok(trimmed); -} - -export interface FormValidation { - validate: () => Result; - field: ( - key: K, - validator: (value: unknown) => Result, - ) => FormValidation; - optional: ( - key: K, - validator: (value: unknown) => Result, - ) => FormValidation; -} - -export function createFormValidation>( - data: Record, -): FormValidation { - const validators: Array<{ - key: string; - validator: (value: unknown) => Result; - optional: boolean; - }> = []; - - const builder: FormValidation = { - field: (key, validator) => { - validators.push({ key: key as string, validator, optional: false }); - return builder; - }, - optional: (key, validator) => { - validators.push({ key: key as string, validator, optional: true }); - return builder; - }, - validate: () => { - const errors: ValidationError[] = []; - const result: Record = {}; - - for (const { key, validator, optional } of validators) { - const value = data[key]; - - if (value == null || value === "") { - if (!optional) { - errors.push(new ValidationError(`${key} is required`, key)); - } - continue; - } - - const validated = validator(value); - if (validated.ok) { - result[key] = validated.value; - } else { - errors.push(validated.error); - } - } - - if (errors.length > 0) { - return err(errors); - } - - return ok(result as T); - }, - }; - - return builder; -} diff --git a/frontend/src/tests/migration/atproto-client.test.ts b/frontend/src/tests/migration/atproto-client.test.ts index b21a69a..9582915 100644 --- a/frontend/src/tests/migration/atproto-client.test.ts +++ b/frontend/src/tests/migration/atproto-client.test.ts @@ -1,17 +1,12 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AtprotoClient, - base64UrlDecode, - base64UrlEncode, buildOAuthAuthorizationUrl, clearDPoPKey, generateDPoPKeyPair, - generateOAuthState, - generatePKCE, getMigrationOAuthClientId, getMigrationOAuthRedirectUri, loadDPoPKey, - prepareWebAuthnCreationOptions, saveDPoPKey, } from "../../lib/migration/atproto-client.ts"; import type { OAuthServerMetadata } from "../../lib/migration/types.ts"; @@ -23,135 +18,6 @@ describe("migration/atproto-client", () => { localStorage.removeItem(DPOP_KEY_STORAGE); }); - describe("base64UrlEncode", () => { - it("encodes empty buffer", () => { - const result = base64UrlEncode(new Uint8Array([])); - expect(result).toBe(""); - }); - - it("encodes simple data", () => { - const data = new TextEncoder().encode("hello"); - const result = base64UrlEncode(data); - expect(result).toBe("aGVsbG8"); - }); - - it("uses URL-safe characters (no +, /, or =)", () => { - const data = new Uint8Array([251, 255, 254]); - const result = base64UrlEncode(data); - expect(result).not.toContain("+"); - expect(result).not.toContain("/"); - expect(result).not.toContain("="); - }); - - it("replaces + with -", () => { - const data = new Uint8Array([251]); - const result = base64UrlEncode(data); - expect(result).toContain("-"); - }); - - it("replaces / with _", () => { - const data = new Uint8Array([255]); - const result = base64UrlEncode(data); - expect(result).toContain("_"); - }); - - it("accepts ArrayBuffer", () => { - const arrayBuffer = new ArrayBuffer(4); - const view = new Uint8Array(arrayBuffer); - view[0] = 116; // t - view[1] = 101; // e - view[2] = 115; // s - view[3] = 116; // t - const result = base64UrlEncode(arrayBuffer); - expect(result).toBe("dGVzdA"); - }); - }); - - describe("base64UrlDecode", () => { - it("decodes empty string", () => { - const result = base64UrlDecode(""); - expect(result.length).toBe(0); - }); - - it("decodes URL-safe base64", () => { - const result = base64UrlDecode("aGVsbG8"); - expect(new TextDecoder().decode(result)).toBe("hello"); - }); - - it("handles - and _ characters", () => { - const encoded = base64UrlEncode(new Uint8Array([251, 255, 254])); - const decoded = base64UrlDecode(encoded); - expect(decoded).toEqual(new Uint8Array([251, 255, 254])); - }); - - it("is inverse of base64UrlEncode", () => { - const original = new Uint8Array([0, 1, 2, 255, 254, 253]); - const encoded = base64UrlEncode(original); - const decoded = base64UrlDecode(encoded); - expect(decoded).toEqual(original); - }); - - it("handles missing padding", () => { - const result = base64UrlDecode("YQ"); - expect(new TextDecoder().decode(result)).toBe("a"); - }); - }); - - describe("generateOAuthState", () => { - it("generates a non-empty string", () => { - const state = generateOAuthState(); - expect(state).toBeTruthy(); - expect(typeof state).toBe("string"); - }); - - it("generates URL-safe characters only", () => { - const state = generateOAuthState(); - expect(state).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("generates different values each time", () => { - const state1 = generateOAuthState(); - const state2 = generateOAuthState(); - expect(state1).not.toBe(state2); - }); - }); - - describe("generatePKCE", () => { - it("generates code_verifier and code_challenge", async () => { - const pkce = await generatePKCE(); - expect(pkce.codeVerifier).toBeTruthy(); - expect(pkce.codeChallenge).toBeTruthy(); - }); - - it("generates URL-safe code_verifier", async () => { - const pkce = await generatePKCE(); - expect(pkce.codeVerifier).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("generates URL-safe code_challenge", async () => { - const pkce = await generatePKCE(); - expect(pkce.codeChallenge).toMatch(/^[A-Za-z0-9_-]+$/); - }); - - it("code_challenge is SHA-256 hash of code_verifier", async () => { - const pkce = await generatePKCE(); - - const encoder = new TextEncoder(); - const data = encoder.encode(pkce.codeVerifier); - const digest = await crypto.subtle.digest("SHA-256", data); - const expectedChallenge = base64UrlEncode(new Uint8Array(digest)); - - expect(pkce.codeChallenge).toBe(expectedChallenge); - }); - - it("generates different values each time", async () => { - const pkce1 = await generatePKCE(); - const pkce2 = await generatePKCE(); - expect(pkce1.codeVerifier).not.toBe(pkce2.codeVerifier); - expect(pkce1.codeChallenge).not.toBe(pkce2.codeChallenge); - }); - }); - describe("buildOAuthAuthorizationUrl", () => { const mockMetadata: OAuthServerMetadata = { issuer: "https://bsky.social", @@ -398,127 +264,6 @@ describe("migration/atproto-client", () => { }); }); - describe("prepareWebAuthnCreationOptions", () => { - it("decodes challenge from base64url", () => { - const options = { - publicKey: { - challenge: "dGVzdC1jaGFsbGVuZ2U", - user: { - id: "dXNlci1pZA", - name: "test@example.com", - displayName: "Test User", - }, - excludeCredentials: [], - rp: { name: "Test" }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - }, - }; - - const prepared = prepareWebAuthnCreationOptions(options); - - expect(prepared.challenge).toBeInstanceOf(Uint8Array); - expect(new TextDecoder().decode(prepared.challenge as Uint8Array)).toBe( - "test-challenge", - ); - }); - - it("decodes user.id from base64url", () => { - const options = { - publicKey: { - challenge: "Y2hhbGxlbmdl", - user: { - id: "dXNlci1pZA", - name: "test@example.com", - displayName: "Test User", - }, - excludeCredentials: [], - rp: { name: "Test" }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - }, - }; - - const prepared = prepareWebAuthnCreationOptions(options); - - expect(prepared.user?.id).toBeInstanceOf(Uint8Array); - expect(new TextDecoder().decode(prepared.user?.id as Uint8Array)).toBe( - "user-id", - ); - }); - - it("decodes excludeCredentials ids from base64url", () => { - const options = { - publicKey: { - challenge: "Y2hhbGxlbmdl", - user: { - id: "dXNlcg", - name: "test@example.com", - displayName: "Test User", - }, - excludeCredentials: [ - { id: "Y3JlZDE", type: "public-key" }, - { id: "Y3JlZDI", type: "public-key" }, - ], - rp: { name: "Test" }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - }, - }; - - const prepared = prepareWebAuthnCreationOptions(options); - - expect(prepared.excludeCredentials).toHaveLength(2); - expect( - new TextDecoder().decode( - prepared.excludeCredentials![0].id as Uint8Array, - ), - ).toBe("cred1"); - expect( - new TextDecoder().decode( - prepared.excludeCredentials![1].id as Uint8Array, - ), - ).toBe("cred2"); - }); - - it("handles empty excludeCredentials", () => { - const options = { - publicKey: { - challenge: "Y2hhbGxlbmdl", - user: { - id: "dXNlcg", - name: "test@example.com", - displayName: "Test User", - }, - rp: { name: "Test" }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - }, - }; - - const prepared = prepareWebAuthnCreationOptions(options); - - expect(prepared.excludeCredentials).toEqual([]); - }); - - it("preserves other user properties", () => { - const options = { - publicKey: { - challenge: "Y2hhbGxlbmdl", - user: { - id: "dXNlcg", - name: "test@example.com", - displayName: "Test User", - }, - excludeCredentials: [], - rp: { name: "Test" }, - pubKeyCredParams: [{ type: "public-key", alg: -7 }], - }, - }; - - const prepared = prepareWebAuthnCreationOptions(options); - - expect(prepared.user?.name).toBe("test@example.com"); - expect(prepared.user?.displayName).toBe("Test User"); - }); - }); - describe("AtprotoClient.verifyHandleOwnership", () => { function createMockJsonResponse(data: unknown, status = 200) { return new Response(JSON.stringify(data), { diff --git a/frontend/src/tests/migration/storage.test.ts b/frontend/src/tests/migration/storage.test.ts index 97ecf1a..ba9d0fd 100644 --- a/frontend/src/tests/migration/storage.test.ts +++ b/frontend/src/tests/migration/storage.test.ts @@ -88,6 +88,10 @@ function createInboundState( generatedAppPasswordName: null, handlePreservation: "new", existingHandleVerified: false, + verificationChannel: "email", + discordUsername: "", + telegramUsername: "", + signalUsername: "", ...overrides, }; } @@ -130,8 +134,7 @@ function createOutboundState( describe("migration/storage", () => { beforeEach(() => { - localStorage.removeItem(STORAGE_KEY); - localStorage.removeItem(DPOP_KEY_STORAGE); + localStorage.clear(); }); describe("saveMigrationState", () => { diff --git a/frontend/src/tests/oauth-registration.test.ts b/frontend/src/tests/oauth-registration.test.ts index aca1e6c..cb4e5de 100644 --- a/frontend/src/tests/oauth-registration.test.ts +++ b/frontend/src/tests/oauth-registration.test.ts @@ -218,7 +218,7 @@ describe("OAuth Registration Flow", () => { }); const RegisterPassword = - (await import("../routes/RegisterPassword.svelte")).default; + (await import("../routes/Register.svelte")).default; render(RegisterPassword); await waitFor( @@ -253,7 +253,7 @@ describe("OAuth Registration Flow", () => { ); const RegisterPassword = - (await import("../routes/RegisterPassword.svelte")).default; + (await import("../routes/Register.svelte")).default; render(RegisterPassword); await waitFor(() => { diff --git a/frontend/src/tests/utils.ts b/frontend/src/tests/utils.ts deleted file mode 100644 index 6ba91c2..0000000 --- a/frontend/src/tests/utils.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { render } from "@testing-library/svelte"; -import { tick } from "svelte"; -import type { ComponentType } from "svelte"; - -export async function renderAndWait( - component: ComponentType, - options?: Parameters[1], -) { - const result = render(component, options); - await tick(); - await new Promise((resolve) => setTimeout(resolve, 0)); - return result; -} - -export async function waitForElement( - queryFn: () => HTMLElement | null, - timeout = 1000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - const element = queryFn(); - if (element) return element; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Element not found within timeout"); -} - -export async function waitForElementToDisappear( - queryFn: () => HTMLElement | null, - timeout = 1000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - const element = queryFn(); - if (!element) return; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error("Element still present after timeout"); -} - -export async function waitForText( - container: HTMLElement, - text: string | RegExp, - timeout = 1000, -): Promise { - const start = Date.now(); - while (Date.now() - start < timeout) { - const content = container.textContent || ""; - if ( - typeof text === "string" ? content.includes(text) : text.test(content) - ) { - return; - } - await new Promise((resolve) => setTimeout(resolve, 10)); - } - throw new Error(`Text "${text}" not found within timeout`); -} - -export function mockLocalStorage( - initialData: Record = {}, -): void { - const store: Record = { ...initialData }; - Object.defineProperty(window, "localStorage", { - value: { - getItem: (key: string) => store[key] || null, - setItem: (key: string, value: string) => { - store[key] = value; - }, - removeItem: (key: string) => { - delete store[key]; - }, - clear: () => { - Object.keys(store).forEach((key) => delete store[key]); - }, - key: (index: number) => Object.keys(store)[index] || null, - get length() { - return Object.keys(store).length; - }, - }, - writable: true, - }); -} - -export function setAuthState(session: { - did: string; - handle: string; - email?: string; - emailConfirmed?: boolean; - accessJwt: string; - refreshJwt: string; -}): void { - localStorage.setItem("session", JSON.stringify(session)); -} - -export function clearAuthState(): void { - localStorage.removeItem("session"); -}