refactor(frontend): delete utility libraries and validation

This commit is contained in:
Lewis
2026-03-19 14:26:26 +00:00
committed by Tangled
parent 7f66419036
commit 5b503c3f7f
9 changed files with 7 additions and 1204 deletions
-205
View File
@@ -1,205 +0,0 @@
import type { Option } from "./option.ts";
export function first<T>(arr: readonly T[]): Option<T> {
return arr[0] ?? null;
}
export function last<T>(arr: readonly T[]): Option<T> {
return arr[arr.length - 1] ?? null;
}
export function at<T>(arr: readonly T[], index: number): Option<T> {
if (index < 0) index = arr.length + index;
return arr[index] ?? null;
}
export function find<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): Option<T> {
return arr.find(predicate) ?? null;
}
export function findMap<T, U>(
arr: readonly T[],
fn: (t: T) => Option<U>,
): Option<U> {
for (const item of arr) {
const result = fn(item);
if (result != null) return result;
}
return null;
}
export function findIndex<T>(
arr: readonly T[],
predicate: (t: T) => boolean,
): Option<number> {
const index = arr.findIndex(predicate);
return index >= 0 ? index : null;
}
export function partition<T>(
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<T, K extends string | number>(
arr: readonly T[],
keyFn: (t: T) => K,
): Record<K, T[]> {
const result = {} as Record<K, T[]>;
for (const item of arr) {
const key = keyFn(item);
if (!result[key]) {
result[key] = [];
}
result[key].push(item);
}
return result;
}
export function unique<T>(arr: readonly T[]): T[] {
return [...new Set(arr)];
}
export function uniqueBy<T, K>(arr: readonly T[], keyFn: (t: T) => K): T[] {
const seen = new Set<K>();
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<T>(
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<T>(
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<T>(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<T, U>(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<T, U, R>(
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<T>(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<T>(arr: readonly T[]): boolean {
return arr.length === 0;
}
export function isNonEmpty<T>(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<T>(arr: readonly T[], fn: (t: T) => number): number {
return arr.reduce((acc, t) => acc + fn(t), 0);
}
export function maxBy<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
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<T>(arr: readonly T[], fn: (t: T) => number): Option<T> {
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;
}
-245
View File
@@ -1,245 +0,0 @@
import { err, type Result } from "../types/result.ts";
export function debounce<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number,
): T & { cancel: () => void } {
let timeoutId: ReturnType<typeof setTimeout> | null = null;
const debounced = ((...args: Parameters<T>) => {
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<T extends (...args: Parameters<T>) => void>(
fn: T,
ms: number,
): T {
let lastCall = 0;
let timeoutId: ReturnType<typeof setTimeout> | null = null;
return ((...args: Parameters<T>) => {
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
export async function retry<T>(
fn: () => Promise<T>,
options: {
attempts?: number;
delay?: number;
backoff?: number;
shouldRetry?: (error: unknown, attempt: number) => boolean;
} = {},
): Promise<T> {
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<T, E>(
fn: () => Promise<Result<T, E>>,
options: {
attempts?: number;
delay?: number;
backoff?: number;
shouldRetry?: (error: E, attempt: number) => boolean;
} = {},
): Promise<Result<T, E>> {
const {
attempts = 3,
delay = 1000,
backoff = 2,
shouldRetry = () => true,
} = options;
let lastResult: Result<T, E> | 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<T>(promise: Promise<T>, ms: number): Promise<T> {
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<T>(
promise: Promise<Result<T, Error>>,
ms: number,
): Promise<Result<T, Error>> {
try {
return await timeout(promise, ms);
} catch (e) {
return err(e instanceof Error ? e : new Error(String(e)));
}
}
export async function parallel<T>(
tasks: (() => Promise<T>)[],
concurrency: number,
): Promise<T[]> {
const results: T[] = [];
const executing: Promise<void>[] = [];
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<T, U>(
items: T[],
fn: (item: T, index: number) => Promise<U>,
concurrency: number,
): Promise<U[]> {
const results: U[] = new Array(items.length);
const executing: Promise<void>[] = [];
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<void> & { _done?: boolean })._done !== false,
);
if (doneIndex >= 0) {
executing.splice(doneIndex, 1);
}
}
}
await Promise.all(executing);
return results;
}
export function createAbortable<T>(
fn: (signal: AbortSignal) => Promise<T>,
): { promise: Promise<T>; abort: () => void } {
const controller = new AbortController();
return {
promise: fn(controller.signal),
abort: () => controller.abort(),
};
}
export interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
-27
View File
@@ -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";
-85
View File
@@ -1,85 +0,0 @@
export type Option<T> = T | null | undefined;
export function isSome<T>(opt: Option<T>): opt is T {
return opt != null;
}
export function isNone<T>(opt: Option<T>): opt is null | undefined {
return opt == null;
}
export function map<T, U>(opt: Option<T>, fn: (t: T) => U): Option<U> {
return isSome(opt) ? fn(opt) : null;
}
export function flatMap<T, U>(
opt: Option<T>,
fn: (t: T) => Option<U>,
): Option<U> {
return isSome(opt) ? fn(opt) : null;
}
export function filter<T>(
opt: Option<T>,
predicate: (t: T) => boolean,
): Option<T> {
return isSome(opt) && predicate(opt) ? opt : null;
}
export function getOrElse<T>(opt: Option<T>, defaultValue: T): T {
return isSome(opt) ? opt : defaultValue;
}
export function getOrElseLazy<T>(opt: Option<T>, fn: () => T): T {
return isSome(opt) ? opt : fn();
}
export function getOrThrow<T>(opt: Option<T>, 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<T>(opt: Option<T>, fn: (t: T) => void): Option<T> {
if (isSome(opt)) fn(opt);
return opt;
}
export function match<T, U>(
opt: Option<T>,
handlers: { some: (t: T) => U; none: () => U },
): U {
return isSome(opt) ? handlers.some(opt) : handlers.none();
}
export function toArray<T>(opt: Option<T>): T[] {
return isSome(opt) ? [opt] : [];
}
export function fromArray<T>(arr: T[]): Option<T> {
return arr.length > 0 ? arr[0] : null;
}
export function zip<T, U>(a: Option<T>, b: Option<U>): Option<[T, U]> {
return isSome(a) && isSome(b) ? [a, b] : null;
}
export function zipWith<T, U, R>(
a: Option<T>,
b: Option<U>,
fn: (t: T, u: U) => R,
): Option<R> {
return isSome(a) && isSome(b) ? fn(a, b) : null;
}
export function or<T>(a: Option<T>, b: Option<T>): Option<T> {
return isSome(a) ? a : b;
}
export function orLazy<T>(a: Option<T>, fn: () => Option<T>): Option<T> {
return isSome(a) ? a : fn();
}
export function and<T, U>(a: Option<T>, b: Option<U>): Option<U> {
return isSome(a) ? b : null;
}
-286
View File
@@ -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<Did, ValidationError> {
if (isDid(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid DID: ${s}`, "did", s));
}
export function parseDidPlc(s: string): Result<DidPlc, ValidationError> {
if (isDidPlc(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid DID:PLC: ${s}`, "did", s));
}
export function parseDidWeb(s: string): Result<DidWeb, ValidationError> {
if (isDidWeb(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid DID:WEB: ${s}`, "did", s));
}
export function parseHandle(s: string): Result<Handle, ValidationError> {
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<EmailAddress, ValidationError> {
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<AtUri, ValidationError> {
if (isAtUri(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid AT-URI: ${s}`, "uri", s));
}
export function parseCid(s: string): Result<Cid, ValidationError> {
if (isCid(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid CID: ${s}`, "cid", s));
}
export function parseNsid(s: string): Result<Nsid, ValidationError> {
if (isNsid(s)) {
return ok(s);
}
return err(new ValidationError(`Invalid NSID: ${s}`, "nsid", s));
}
export function parseISODate(
s: string,
): Result<ISODateString, ValidationError> {
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<Handle, ValidationError> {
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<string, ValidationError> {
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<string, ValidationError> {
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<string, ValidationError> {
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<T> {
validate: () => Result<T, ValidationError[]>;
field: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>,
) => FormValidation<T>;
optional: <K extends keyof T>(
key: K,
validator: (value: unknown) => Result<T[K], ValidationError>,
) => FormValidation<T>;
}
export function createFormValidation<T extends Record<string, unknown>>(
data: Record<string, unknown>,
): FormValidation<T> {
const validators: Array<{
key: string;
validator: (value: unknown) => Result<unknown, ValidationError>;
optional: boolean;
}> = [];
const builder: FormValidation<T> = {
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<string, unknown> = {};
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;
}
@@ -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), {
+5 -2
View File
@@ -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", () => {
@@ -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(() => {
-97
View File
@@ -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<typeof render>[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<HTMLElement> {
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<void> {
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<void> {
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<string, string> = {},
): void {
const store: Record<string, string> = { ...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");
}