this repo has no description

DPoP in frontend, why not

lewis cd09e6c7 41d703d8

+9
frontend/src/lib/auth.svelte.ts
··· 20 20 import { assertNever } from "./types/exhaustive.ts"; 21 21 import { 22 22 checkForOAuthCallback, 23 + clearAllOAuthState, 23 24 clearOAuthCallbackParams, 24 25 handleOAuthCallback, 25 26 refreshOAuthToken, ··· 274 275 setState(createLoading(getSavedAccounts(), previousSession)); 275 276 } 276 277 278 + export function clearError(): void { 279 + if (state.current.kind === "error") { 280 + setState(createUnauthenticated(getSavedAccounts())); 281 + } 282 + } 283 + 277 284 async function tryRefreshToken(): Promise<string | null> { 278 285 if (state.current.kind !== "authenticated") return null; 279 286 const currentSession = state.current.session; ··· 323 330 applyLocaleFromSession(session); 324 331 return { oauthLoginCompleted: true }; 325 332 } catch (e) { 333 + clearAllOAuthState(); 326 334 setError({ 327 335 type: "oauth", 328 336 message: e instanceof Error ? e.message : "OAuth login failed", ··· 398 406 } 399 407 400 408 export async function loginWithOAuth(): Promise<Result<void, AuthError>> { 409 + clearAllOAuthState(); 401 410 setLoading(); 402 411 try { 403 412 await startOAuthLogin();
+244 -39
frontend/src/lib/oauth.ts
··· 1 1 const OAUTH_STATE_KEY = "tranquil_pds_oauth_state"; 2 2 const OAUTH_VERIFIER_KEY = "tranquil_pds_oauth_verifier"; 3 + const DPOP_KEY_STORE = "tranquil_pds_dpop_keys"; 4 + const DPOP_NONCE_KEY = "tranquil_pds_dpop_nonce"; 5 + 3 6 const SCOPES = [ 4 7 "atproto", 5 8 "repo:*?action=create", ··· 7 10 "repo:*?action=delete", 8 11 "blob:*/*", 9 12 ].join(" "); 13 + 10 14 const CLIENT_ID = !(import.meta.env.DEV) 11 15 ? `${globalThis.location.origin}/oauth/client-metadata.json` 12 16 : `http://localhost/?scope=${SCOPES}`; 17 + 13 18 const REDIRECT_URI = `${globalThis.location.origin}/app/`; 14 19 15 20 interface OAuthState { 16 21 state: string; 17 22 codeVerifier: string; 18 23 returnTo?: string; 24 + } 25 + 26 + interface DPoPKeyPair { 27 + publicKey: CryptoKey; 28 + privateKey: CryptoKey; 29 + jwk: JsonWebKey; 19 30 } 20 31 21 32 function generateRandomString(length: number): string { ··· 73 84 sessionStorage.removeItem(OAUTH_VERIFIER_KEY); 74 85 } 75 86 87 + function clearDPoPNonce(): void { 88 + sessionStorage.removeItem(DPOP_NONCE_KEY); 89 + } 90 + 91 + export function clearAllOAuthState(): void { 92 + clearOAuthState(); 93 + clearDPoPNonce(); 94 + } 95 + 96 + async function openKeyStore(): Promise<IDBDatabase> { 97 + return new Promise((resolve, reject) => { 98 + const request = indexedDB.open(DPOP_KEY_STORE, 1); 99 + request.onerror = () => reject(request.error); 100 + request.onsuccess = () => resolve(request.result); 101 + request.onupgradeneeded = () => { 102 + const db = request.result; 103 + if (!db.objectStoreNames.contains("keys")) { 104 + db.createObjectStore("keys"); 105 + } 106 + }; 107 + }); 108 + } 109 + 110 + async function storeDPoPKeyPair(keyPair: DPoPKeyPair): Promise<void> { 111 + const db = await openKeyStore(); 112 + return new Promise((resolve, reject) => { 113 + const tx = db.transaction("keys", "readwrite"); 114 + const store = tx.objectStore("keys"); 115 + store.put(keyPair.publicKey, "publicKey"); 116 + store.put(keyPair.privateKey, "privateKey"); 117 + store.put(keyPair.jwk, "jwk"); 118 + tx.oncomplete = () => { 119 + db.close(); 120 + resolve(); 121 + }; 122 + tx.onerror = () => { 123 + db.close(); 124 + reject(tx.error); 125 + }; 126 + }); 127 + } 128 + 129 + async function loadDPoPKeyPair(): Promise<DPoPKeyPair | null> { 130 + try { 131 + const db = await openKeyStore(); 132 + return new Promise((resolve, reject) => { 133 + const tx = db.transaction("keys", "readonly"); 134 + const store = tx.objectStore("keys"); 135 + const publicKeyReq = store.get("publicKey"); 136 + const privateKeyReq = store.get("privateKey"); 137 + const jwkReq = store.get("jwk"); 138 + tx.oncomplete = () => { 139 + db.close(); 140 + if (publicKeyReq.result && privateKeyReq.result && jwkReq.result) { 141 + resolve({ 142 + publicKey: publicKeyReq.result, 143 + privateKey: privateKeyReq.result, 144 + jwk: jwkReq.result, 145 + }); 146 + } else { 147 + resolve(null); 148 + } 149 + }; 150 + tx.onerror = () => { 151 + db.close(); 152 + reject(tx.error); 153 + }; 154 + }); 155 + } catch { 156 + return null; 157 + } 158 + } 159 + 160 + async function generateDPoPKeyPair(): Promise<DPoPKeyPair> { 161 + const keyPair = await crypto.subtle.generateKey( 162 + { name: "ECDSA", namedCurve: "P-256" }, 163 + true, 164 + ["sign", "verify"], 165 + ); 166 + const jwk = await crypto.subtle.exportKey("jwk", keyPair.publicKey); 167 + return { 168 + publicKey: keyPair.publicKey, 169 + privateKey: keyPair.privateKey, 170 + jwk, 171 + }; 172 + } 173 + 174 + async function getOrCreateDPoPKeyPair(): Promise<DPoPKeyPair> { 175 + const existing = await loadDPoPKeyPair(); 176 + if (existing) return existing; 177 + 178 + const keyPair = await generateDPoPKeyPair(); 179 + await storeDPoPKeyPair(keyPair); 180 + return keyPair; 181 + } 182 + 183 + async function createDPoPProof( 184 + keyPair: DPoPKeyPair, 185 + method: string, 186 + url: string, 187 + nonce?: string, 188 + accessTokenHash?: string, 189 + ): Promise<string> { 190 + const header = { 191 + typ: "dpop+jwt", 192 + alg: "ES256", 193 + jwk: { 194 + kty: keyPair.jwk.kty, 195 + crv: keyPair.jwk.crv, 196 + x: keyPair.jwk.x, 197 + y: keyPair.jwk.y, 198 + }, 199 + }; 200 + 201 + const payload: Record<string, unknown> = { 202 + jti: generateRandomString(16), 203 + htm: method.toUpperCase(), 204 + htu: url.split("?")[0], 205 + iat: Math.floor(Date.now() / 1000), 206 + }; 207 + 208 + if (nonce) { 209 + payload.nonce = nonce; 210 + } 211 + 212 + if (accessTokenHash) { 213 + payload.ath = accessTokenHash; 214 + } 215 + 216 + const headerB64 = base64UrlEncode( 217 + new TextEncoder().encode(JSON.stringify(header)).buffer as ArrayBuffer, 218 + ); 219 + const payloadB64 = base64UrlEncode( 220 + new TextEncoder().encode(JSON.stringify(payload)).buffer as ArrayBuffer, 221 + ); 222 + const signingInput = `${headerB64}.${payloadB64}`; 223 + 224 + const signature = await crypto.subtle.sign( 225 + { name: "ECDSA", hash: "SHA-256" }, 226 + keyPair.privateKey, 227 + new TextEncoder().encode(signingInput), 228 + ); 229 + 230 + const sigBytes = new Uint8Array(signature); 231 + const signatureB64 = base64UrlEncode(sigBytes.buffer); 232 + 233 + return `${signingInput}.${signatureB64}`; 234 + } 235 + 236 + async function computeJwkThumbprint(jwk: JsonWebKey): Promise<string> { 237 + const canonical = JSON.stringify({ 238 + crv: jwk.crv, 239 + kty: jwk.kty, 240 + x: jwk.x, 241 + y: jwk.y, 242 + }); 243 + const hash = await sha256(canonical); 244 + return base64UrlEncode(hash); 245 + } 246 + 247 + function getDPoPNonce(): string | null { 248 + return sessionStorage.getItem(DPOP_NONCE_KEY); 249 + } 250 + 251 + function setDPoPNonce(nonce: string): void { 252 + sessionStorage.setItem(DPOP_NONCE_KEY, nonce); 253 + } 254 + 255 + function extractDPoPNonceFromResponse(response: Response): void { 256 + const nonce = response.headers.get("DPoP-Nonce"); 257 + if (nonce) { 258 + setDPoPNonce(nonce); 259 + } 260 + } 261 + 76 262 export async function startOAuthLogin(): Promise<void> { 263 + clearAllOAuthState(); 264 + 77 265 const state = generateState(); 78 266 const codeVerifier = generateCodeVerifier(); 79 267 const codeChallenge = await generateCodeChallenge(codeVerifier); 268 + 269 + const keyPair = await getOrCreateDPoPKeyPair(); 270 + const dpopJkt = await computeJwkThumbprint(keyPair.jwk); 80 271 81 272 saveOAuthState({ state, codeVerifier }); 82 273 ··· 91 282 state: state, 92 283 code_challenge: codeChallenge, 93 284 code_challenge_method: "S256", 285 + dpop_jkt: dpopJkt, 94 286 }), 95 287 }); 96 288 ··· 121 313 sub: string; 122 314 } 123 315 316 + async function tokenRequest( 317 + params: URLSearchParams, 318 + retryWithNonce = true, 319 + ): Promise<OAuthTokens> { 320 + const keyPair = await getOrCreateDPoPKeyPair(); 321 + const tokenEndpoint = `${globalThis.location.origin}/oauth/token`; 322 + 323 + const dpopProof = await createDPoPProof( 324 + keyPair, 325 + "POST", 326 + tokenEndpoint, 327 + getDPoPNonce() ?? undefined, 328 + ); 329 + 330 + const response = await fetch("/oauth/token", { 331 + method: "POST", 332 + headers: { 333 + "Content-Type": "application/x-www-form-urlencoded", 334 + "DPoP": dpopProof, 335 + }, 336 + body: params, 337 + }); 338 + 339 + extractDPoPNonceFromResponse(response); 340 + 341 + if (!response.ok) { 342 + const error = await response.json().catch(() => ({ error: "Unknown error" })); 343 + 344 + if (retryWithNonce && error.error === "use_dpop_nonce" && getDPoPNonce()) { 345 + return tokenRequest(params, false); 346 + } 347 + 348 + throw new Error( 349 + error.error_description || error.error || "Token request failed", 350 + ); 351 + } 352 + 353 + return response.json(); 354 + } 355 + 124 356 export async function handleOAuthCallback( 125 357 code: string, 126 358 state: string, ··· 135 367 throw new Error("OAuth state mismatch. Please try logging in again."); 136 368 } 137 369 138 - const tokenResponse = await fetch("/oauth/token", { 139 - method: "POST", 140 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 141 - body: new URLSearchParams({ 142 - grant_type: "authorization_code", 143 - client_id: CLIENT_ID, 144 - code: code, 145 - redirect_uri: REDIRECT_URI, 146 - code_verifier: savedState.codeVerifier, 147 - }), 370 + const params = new URLSearchParams({ 371 + grant_type: "authorization_code", 372 + client_id: CLIENT_ID, 373 + code: code, 374 + redirect_uri: REDIRECT_URI, 375 + code_verifier: savedState.codeVerifier, 148 376 }); 149 377 150 378 clearOAuthState(); 151 379 152 - if (!tokenResponse.ok) { 153 - const error = await tokenResponse.json().catch(() => ({ 154 - error: "Unknown error", 155 - })); 156 - throw new Error( 157 - error.error_description || error.error || 158 - "Failed to exchange code for tokens", 159 - ); 160 - } 161 - 162 - return tokenResponse.json(); 380 + return tokenRequest(params); 163 381 } 164 382 165 383 export async function refreshOAuthToken( 166 384 refreshToken: string, 167 385 ): Promise<OAuthTokens> { 168 - const tokenResponse = await fetch("/oauth/token", { 169 - method: "POST", 170 - headers: { "Content-Type": "application/x-www-form-urlencoded" }, 171 - body: new URLSearchParams({ 172 - grant_type: "refresh_token", 173 - client_id: CLIENT_ID, 174 - refresh_token: refreshToken, 175 - }), 386 + const params = new URLSearchParams({ 387 + grant_type: "refresh_token", 388 + client_id: CLIENT_ID, 389 + refresh_token: refreshToken, 176 390 }); 177 391 178 - if (!tokenResponse.ok) { 179 - const error = await tokenResponse.json().catch(() => ({ 180 - error: "Unknown error", 181 - })); 182 - throw new Error( 183 - error.error_description || error.error || "Failed to refresh token", 184 - ); 185 - } 186 - 187 - return tokenResponse.json(); 392 + return tokenRequest(params); 188 393 } 189 394 190 395 export function checkForOAuthCallback():
+22 -4
frontend/src/routes/Dashboard.svelte
··· 285 285 justify-content: space-between; 286 286 align-items: center; 287 287 margin-bottom: var(--space-7); 288 + gap: var(--space-4); 289 + } 290 + 291 + @media (max-width: 500px) { 292 + header { 293 + flex-direction: column-reverse; 294 + align-items: flex-start; 295 + } 288 296 } 289 297 290 298 header h1 { 291 299 margin: 0; 300 + min-width: 0; 292 301 } 293 302 294 303 .account-dropdown { 295 304 position: relative; 305 + max-width: 100%; 296 306 } 297 307 298 308 .account-trigger { ··· 305 315 border-radius: var(--radius-md); 306 316 cursor: pointer; 307 317 color: var(--text-primary); 318 + max-width: 100%; 319 + } 320 + 321 + .account-trigger .account-handle { 322 + font-weight: var(--font-medium); 323 + overflow: hidden; 324 + text-overflow: ellipsis; 325 + white-space: nowrap; 308 326 } 309 327 310 328 .account-trigger:hover:not(:disabled) { ··· 314 332 .account-trigger:disabled { 315 333 opacity: 0.6; 316 334 cursor: not-allowed; 317 - } 318 - 319 - .account-trigger .account-handle { 320 - font-weight: var(--font-medium); 321 335 } 322 336 323 337 .dropdown-arrow { ··· 383 397 padding: var(--space-6); 384 398 border-radius: var(--radius-xl); 385 399 margin-bottom: var(--space-7); 400 + overflow: hidden; 401 + min-width: 0; 386 402 } 387 403 388 404 section h2 { ··· 400 416 dt { 401 417 font-weight: var(--font-medium); 402 418 color: var(--text-secondary); 419 + max-width: 6rem; 403 420 } 404 421 405 422 dd { 406 423 margin: 0; 424 + min-width: 0; 407 425 } 408 426 409 427 .mono {
+9 -12
frontend/src/routes/Login.svelte
··· 6 6 getAuthState, 7 7 switchAccount, 8 8 forgetAccount, 9 + clearError, 9 10 matchAuthState, 10 11 type SavedAccount, 11 12 type AuthError, ··· 14 15 import { _ } from '../lib/i18n' 15 16 import { isOk, isErr } from '../lib/types/result' 16 17 import { unsafeAsDid, type Did } from '../lib/types/branded' 18 + import { toast } from '../lib/toast.svelte' 17 19 18 20 type PageState = 19 21 | { kind: 'login' } ··· 32 34 return auth.savedAccounts 33 35 } 34 36 35 - function getErrorMessage(): string | null { 36 - if (auth.kind === 'error') { 37 - return auth.error.message 38 - } 39 - return null 40 - } 41 - 42 37 function isLoading(): boolean { 43 38 return auth.kind === 'loading' 44 39 } 40 + 41 + $effect(() => { 42 + if (auth.kind === 'error') { 43 + toast.error(auth.error.message) 44 + clearError() 45 + } 46 + }) 45 47 46 48 $effect(() => { 47 49 const accounts = getSavedAccounts() ··· 108 110 resendMessage = null 109 111 } 110 112 111 - const errorMessage = $derived(getErrorMessage()) 112 113 const savedAccounts = $derived(getSavedAccounts()) 113 114 const loading = $derived(isLoading()) 114 115 </script> 115 116 116 117 <div class="login-page"> 117 - {#if errorMessage} 118 - <div class="message error">{errorMessage}</div> 119 - {/if} 120 - 121 118 {#if pageState.kind === 'verification'} 122 119 <header class="page-header"> 123 120 <h1>{$_('verification.title')}</h1>
+8
frontend/src/styles/base.css
··· 29 29 -webkit-font-smoothing: antialiased; 30 30 -moz-osx-font-smoothing: grayscale; 31 31 transition: background-color 0.3s ease; 32 + overflow-wrap: anywhere; 33 + word-break: break-word; 32 34 } 33 35 34 36 h1, h2, h3, h4, h5, h6 { ··· 336 338 border: 1px solid var(--border-color); 337 339 border-radius: var(--radius-xl); 338 340 padding: var(--space-6); 341 + overflow: hidden; 342 + min-width: 0; 339 343 } 340 344 341 345 .section { 342 346 background: var(--bg-secondary); 343 347 border-radius: var(--radius-xl); 344 348 padding: var(--space-6); 349 + overflow: hidden; 350 + min-width: 0; 345 351 } 346 352 347 353 .section + .section { ··· 469 475 border-radius: var(--radius-xl); 470 476 padding: var(--space-6); 471 477 height: fit-content; 478 + overflow: hidden; 479 + min-width: 0; 472 480 } 473 481 474 482 .info-panel h3 {
+13 -15
frontend/src/tests/AppPasswords.test.ts
··· 4 4 import { 5 5 clearMocks, 6 6 errorResponse, 7 + getErrorToasts, 7 8 jsonResponse, 8 9 mockData, 9 10 mockEndpoint, ··· 51 52 beforeEach(() => { 52 53 setupAuthenticatedUser(); 53 54 }); 54 - it("shows loading text while fetching passwords", () => { 55 + it("shows loading skeleton while fetching passwords", () => { 55 56 mockEndpoint( 56 57 "com.atproto.server.listAppPasswords", 57 58 () => ··· 59 60 setTimeout(() => resolve(jsonResponse({ passwords: [] })), 100) 60 61 ), 61 62 ); 62 - render(AppPasswords); 63 - expect(screen.getByText(/loading/i)).toBeInTheDocument(); 63 + const { container } = render(AppPasswords); 64 + expect(container.querySelectorAll(".skeleton-item").length).toBeGreaterThan(0); 64 65 }); 65 66 }); 66 67 describe("empty state", () => { ··· 236 237 .toBeInTheDocument(); 237 238 }); 238 239 }); 239 - it("shows error when creation fails", async () => { 240 + it("shows error toast when creation fails", async () => { 240 241 mockEndpoint( 241 242 "com.atproto.server.createAppPassword", 242 243 () => errorResponse("InvalidRequest", "Name already exists", 400), ··· 250 251 }); 251 252 await fireEvent.click(screen.getByRole("button", { name: /create/i })); 252 253 await waitFor(() => { 253 - expect(screen.getByText(/name already exists/i)).toBeInTheDocument(); 254 - expect(screen.getByText(/name already exists/i)).toHaveClass("error"); 254 + const errors = getErrorToasts(); 255 + expect(errors.some((e) => /name already exists/i.test(e))).toBe(true); 255 256 }); 256 257 }); 257 258 }); ··· 358 359 expect(screen.getByText(/no app passwords yet/i)).toBeInTheDocument(); 359 360 }); 360 361 }); 361 - it("shows error when revocation fails", async () => { 362 + it("shows error toast when revocation fails", async () => { 362 363 globalThis.confirm = vi.fn(() => true); 363 364 mockEndpoint( 364 365 "com.atproto.server.listAppPasswords", ··· 374 375 }); 375 376 await fireEvent.click(screen.getByRole("button", { name: /revoke/i })); 376 377 await waitFor(() => { 377 - expect(screen.getByText(/server error/i)).toBeInTheDocument(); 378 - expect(screen.getByText(/server error/i)).toHaveClass("error"); 378 + const errors = getErrorToasts(); 379 + expect(errors.some((e) => /server error/i.test(e))).toBe(true); 379 380 }); 380 381 }); 381 382 }); ··· 383 384 beforeEach(() => { 384 385 setupAuthenticatedUser(); 385 386 }); 386 - it("shows error when loading passwords fails", async () => { 387 + it("shows error toast when loading passwords fails", async () => { 387 388 mockEndpoint( 388 389 "com.atproto.server.listAppPasswords", 389 390 () => errorResponse("InternalError", "Database connection failed", 500), 390 391 ); 391 392 render(AppPasswords); 392 393 await waitFor(() => { 393 - expect(screen.getByText(/database connection failed/i)) 394 - .toBeInTheDocument(); 395 - expect(screen.getByText(/database connection failed/i)).toHaveClass( 396 - "error", 397 - ); 394 + const errors = getErrorToasts(); 395 + expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 398 396 }); 399 397 }); 400 398 });
+14 -17
frontend/src/tests/Comms.test.ts
··· 4 4 import { 5 5 clearMocks, 6 6 errorResponse, 7 + getErrorToasts, 8 + getToasts, 7 9 jsonResponse, 8 10 mockData, 9 11 mockEndpoint, ··· 71 73 () => jsonResponse({ notifications: [] }), 72 74 ); 73 75 }); 74 - it("shows loading text while fetching preferences", () => { 76 + it("shows loading skeleton while fetching preferences", () => { 75 77 mockEndpoint( 76 78 "_account.getNotificationPrefs", 77 79 () => ··· 82 84 ) 83 85 ), 84 86 ); 85 - render(Comms); 86 - expect(screen.getByText(/loading/i)).toBeInTheDocument(); 87 + const { container } = render(Comms); 88 + expect(container.querySelectorAll(".skeleton-section").length).toBeGreaterThan(0); 87 89 }); 88 90 }); 89 91 describe("channel options", () => { ··· 354 356 .toBeInTheDocument(); 355 357 expect(screen.getByRole("button", { name: /saving/i })).toBeDisabled(); 356 358 }); 357 - it("shows success message after saving", async () => { 359 + it("shows success toast after saving", async () => { 358 360 mockEndpoint( 359 361 "_account.getNotificationPrefs", 360 362 () => jsonResponse(mockData.notificationPrefs()), ··· 372 374 screen.getByRole("button", { name: /save preferences/i }), 373 375 ); 374 376 await waitFor(() => { 375 - expect(screen.getByText(/preferences saved/i)) 376 - .toBeInTheDocument(); 377 + const toasts = getToasts(); 378 + expect(toasts.some((t) => t.type === "success" && /saved/i.test(t.message))).toBe(true); 377 379 }); 378 380 }); 379 - it("shows error when save fails", async () => { 381 + it("shows error toast when save fails", async () => { 380 382 mockEndpoint( 381 383 "_account.getNotificationPrefs", 382 384 () => jsonResponse(mockData.notificationPrefs()), ··· 395 397 screen.getByRole("button", { name: /save preferences/i }), 396 398 ); 397 399 await waitFor(() => { 398 - expect(screen.getByText(/invalid channel configuration/i)) 399 - .toBeInTheDocument(); 400 - expect( 401 - screen.getByText(/invalid channel configuration/i).closest( 402 - ".message", 403 - ), 404 - ).toHaveClass("error"); 400 + const errors = getErrorToasts(); 401 + expect(errors.some((e) => /invalid channel configuration/i.test(e))).toBe(true); 405 402 }); 406 403 }); 407 404 it("reloads preferences after successful save", async () => { ··· 490 487 () => jsonResponse({ notifications: [] }), 491 488 ); 492 489 }); 493 - it("shows error when loading preferences fails", async () => { 490 + it("shows error toast when loading preferences fails", async () => { 494 491 mockEndpoint( 495 492 "_account.getNotificationPrefs", 496 493 () => errorResponse("InternalError", "Database connection failed", 500), 497 494 ); 498 495 render(Comms); 499 496 await waitFor(() => { 500 - expect(screen.getByText(/database connection failed/i)) 501 - .toBeInTheDocument(); 497 + const errors = getErrorToasts(); 498 + expect(errors.some((e) => /database connection failed/i.test(e))).toBe(true); 502 499 }); 503 500 }); 504 501 });
+3 -2
frontend/src/tests/Dashboard.test.ts
··· 25 25 }); 26 26 }); 27 27 it("shows loading state while checking auth", () => { 28 - render(Dashboard); 29 - expect(screen.getByText(/loading/i)).toBeInTheDocument(); 28 + const { container } = render(Dashboard); 29 + expect(container.querySelector(".skeleton-section")).toBeInTheDocument(); 30 + expect(container.querySelectorAll(".skeleton-card").length).toBeGreaterThan(0); 30 31 }); 31 32 }); 32 33 describe("authenticated view", () => {
+7 -3
frontend/src/tests/Login.test.ts
··· 15 15 unsafeAsHandle, 16 16 unsafeAsRefreshToken, 17 17 } from "../lib/types/branded.ts"; 18 + import { getToasts } from "../lib/toast.svelte.ts"; 18 19 19 20 describe("Login", () => { 20 21 beforeEach(() => { ··· 147 148 }); 148 149 149 150 describe("error handling", () => { 150 - it("displays error message when auth state has error", async () => { 151 + it("displays error message as toast when auth state has error", async () => { 151 152 _testSetState({ 152 153 session: null, 153 154 loading: false, ··· 156 157 }); 157 158 render(Login); 158 159 await waitFor(() => { 159 - expect(screen.getByText(/oauth login failed/i)).toBeInTheDocument(); 160 - expect(screen.getByText(/oauth login failed/i)).toHaveClass("error"); 160 + const toasts = getToasts(); 161 + const errorToast = toasts.find( 162 + (t) => t.type === "error" && t.message.includes("OAuth login failed"), 163 + ); 164 + expect(errorToast).toBeDefined(); 161 165 }); 162 166 }); 163 167 });
+17 -15
frontend/src/tests/Settings.test.ts
··· 4 4 import { 5 5 clearMocks, 6 6 errorResponse, 7 + getErrorToasts, 8 + getToasts, 7 9 jsonResponse, 8 10 mockData, 9 11 mockEndpoint, ··· 140 142 expect(capturedBody?.token).toBe("123456"); 141 143 }); 142 144 }); 143 - it("shows success message after email update", async () => { 145 + it("shows success toast after email update", async () => { 144 146 mockEndpoint( 145 147 "com.atproto.server.requestEmailUpdate", 146 148 () => jsonResponse({ tokenRequired: true }), ··· 171 173 screen.getByRole("button", { name: /confirm email change/i }), 172 174 ); 173 175 await waitFor(() => { 174 - expect(screen.getByText(/email updated/i)) 175 - .toBeInTheDocument(); 176 + const toasts = getToasts(); 177 + expect(toasts.some((t) => t.type === "success" && /email.*updated/i.test(t.message))).toBe(true); 176 178 }); 177 179 }); 178 180 it("shows cancel button to return to initial state", async () => { ··· 205 207 .toBeInTheDocument(); 206 208 }); 207 209 }); 208 - it("shows error when request fails", async () => { 210 + it("shows error toast when request fails", async () => { 209 211 mockEndpoint( 210 212 "com.atproto.server.requestEmailUpdate", 211 213 () => errorResponse("InvalidEmail", "Invalid email format", 400), ··· 219 221 screen.getByRole("button", { name: /change email/i }), 220 222 ); 221 223 await waitFor(() => { 222 - expect(screen.getByText(/invalid email format/i)).toBeInTheDocument(); 224 + const errors = getErrorToasts(); 225 + expect(errors.some((e) => /invalid email format/i.test(e))).toBe(true); 223 226 }); 224 227 }); 225 228 }); ··· 261 264 expect(screen.getByRole("button", { name: /change handle/i })) 262 265 .toBeInTheDocument(); 263 266 }); 264 - it("shows success message after handle change", async () => { 267 + it("shows success toast after handle change", async () => { 265 268 mockEndpoint("com.atproto.identity.updateHandle", () => jsonResponse({})); 266 269 mockEndpoint( 267 270 "com.atproto.server.getSession", ··· 279 282 const button = screen.getByRole("button", { name: /change handle/i }); 280 283 await fireEvent.submit(button.closest("form")!); 281 284 await waitFor(() => { 282 - expect(screen.getByText(/handle updated/i)) 283 - .toBeInTheDocument(); 285 + const toasts = getToasts(); 286 + expect(toasts.some((t) => t.type === "success" && /handle.*updated/i.test(t.message))).toBe(true); 284 287 }); 285 288 }); 286 - it("shows error when handle change fails", async () => { 289 + it("shows error toast when handle change fails", async () => { 287 290 mockEndpoint( 288 291 "com.atproto.identity.updateHandle", 289 292 () => ··· 302 305 const button = screen.getByRole("button", { name: /change handle/i }); 303 306 await fireEvent.submit(button.closest("form")!); 304 307 await waitFor(() => { 305 - const errorMessage = screen.queryByText(/handle is already taken/i) || 306 - screen.queryByText(/handle update failed/i); 307 - expect(errorMessage).toBeInTheDocument(); 308 + const errors = getErrorToasts(); 309 + expect(errors.some((e) => /handle is already taken/i.test(e))).toBe(true); 308 310 }); 309 311 }); 310 312 }); ··· 500 502 ).toBeInTheDocument(); 501 503 }); 502 504 }); 503 - it("shows error when deletion fails", async () => { 505 + it("shows error toast when deletion fails", async () => { 504 506 globalThis.confirm = vi.fn(() => true); 505 507 mockEndpoint( 506 508 "com.atproto.server.requestAccountDelete", ··· 532 534 screen.getByRole("button", { name: /permanently delete account/i }), 533 535 ); 534 536 await waitFor(() => { 535 - expect(screen.getByText(/invalid confirmation code/i)) 536 - .toBeInTheDocument(); 537 + const errors = getErrorToasts(); 538 + expect(errors.some((e) => /invalid confirmation code/i.test(e))).toBe(true); 537 539 }); 538 540 }); 539 541 });
+12 -1
frontend/src/tests/mocks.ts
··· 1 1 import { vi } from "vitest"; 2 2 import type { AppPassword, InviteCode, Session } from "../lib/api.ts"; 3 - import { _testSetState } from "../lib/auth.svelte.ts"; 3 + import { _testSetState, _testResetState } from "../lib/auth.svelte.ts"; 4 + import { toast, clearAllToasts, getToasts } from "../lib/toast.svelte.ts"; 4 5 import { 5 6 unsafeAsAccessToken, 6 7 unsafeAsDid, ··· 70 71 } 71 72 export function clearMocks(): void { 72 73 mockHandlers.clear(); 74 + _testResetState(); 75 + clearAllToasts(); 73 76 } 77 + 78 + export function getErrorToasts(): string[] { 79 + return getToasts() 80 + .filter((t) => t.type === "error") 81 + .map((t) => t.message); 82 + } 83 + 84 + export { toast, getToasts }; 74 85 function extractEndpoint(url: string): string { 75 86 const match = url.match(/\/xrpc\/([^?]+)/); 76 87 return match ? match[1] : url;
+232
frontend/src/tests/oauth.test.ts
··· 1 + import { beforeEach, describe, expect, it, vi } from "vitest"; 2 + import { 3 + generateCodeChallenge, 4 + generateCodeVerifier, 5 + generateState, 6 + saveOAuthState, 7 + checkForOAuthCallback, 8 + clearOAuthCallbackParams, 9 + } from "../lib/oauth"; 10 + 11 + describe("OAuth utilities", () => { 12 + beforeEach(() => { 13 + sessionStorage.clear(); 14 + vi.restoreAllMocks(); 15 + }); 16 + 17 + describe("generateState", () => { 18 + it("generates a 64-character hex string", () => { 19 + const state = generateState(); 20 + expect(state).toMatch(/^[0-9a-f]{64}$/); 21 + }); 22 + 23 + it("generates unique values", () => { 24 + const states = new Set(Array.from({ length: 100 }, () => generateState())); 25 + expect(states.size).toBe(100); 26 + }); 27 + }); 28 + 29 + describe("generateCodeVerifier", () => { 30 + it("generates a 64-character hex string", () => { 31 + const verifier = generateCodeVerifier(); 32 + expect(verifier).toMatch(/^[0-9a-f]{64}$/); 33 + }); 34 + 35 + it("generates unique values", () => { 36 + const verifiers = new Set( 37 + Array.from({ length: 100 }, () => generateCodeVerifier()), 38 + ); 39 + expect(verifiers.size).toBe(100); 40 + }); 41 + }); 42 + 43 + describe("generateCodeChallenge", () => { 44 + it("generates a base64url-encoded SHA-256 hash", async () => { 45 + const verifier = "test-verifier-12345"; 46 + const challenge = await generateCodeChallenge(verifier); 47 + 48 + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/); 49 + expect(challenge).not.toContain("+"); 50 + expect(challenge).not.toContain("/"); 51 + expect(challenge).not.toContain("="); 52 + }); 53 + 54 + it("produces consistent output for same input", async () => { 55 + const verifier = "consistent-test-verifier"; 56 + const challenge1 = await generateCodeChallenge(verifier); 57 + const challenge2 = await generateCodeChallenge(verifier); 58 + 59 + expect(challenge1).toBe(challenge2); 60 + }); 61 + 62 + it("produces different output for different inputs", async () => { 63 + const challenge1 = await generateCodeChallenge("verifier-1"); 64 + const challenge2 = await generateCodeChallenge("verifier-2"); 65 + 66 + expect(challenge1).not.toBe(challenge2); 67 + }); 68 + 69 + it("produces correct S256 challenge", async () => { 70 + const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"); 71 + expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"); 72 + }); 73 + }); 74 + 75 + describe("saveOAuthState", () => { 76 + it("stores state and verifier in sessionStorage", () => { 77 + saveOAuthState({ state: "test-state", codeVerifier: "test-verifier" }); 78 + 79 + expect(sessionStorage.getItem("tranquil_pds_oauth_state")).toBe( 80 + "test-state", 81 + ); 82 + expect(sessionStorage.getItem("tranquil_pds_oauth_verifier")).toBe( 83 + "test-verifier", 84 + ); 85 + }); 86 + }); 87 + 88 + describe("checkForOAuthCallback", () => { 89 + it("returns null when no code/state in URL", () => { 90 + Object.defineProperty(globalThis.location, "search", { 91 + value: "", 92 + writable: true, 93 + configurable: true, 94 + }); 95 + Object.defineProperty(globalThis.location, "pathname", { 96 + value: "/app/", 97 + writable: true, 98 + configurable: true, 99 + }); 100 + 101 + expect(checkForOAuthCallback()).toBeNull(); 102 + }); 103 + 104 + it("returns code and state when present in URL", () => { 105 + Object.defineProperty(globalThis.location, "search", { 106 + value: "?code=auth-code-123&state=state-456", 107 + writable: true, 108 + configurable: true, 109 + }); 110 + Object.defineProperty(globalThis.location, "pathname", { 111 + value: "/app/", 112 + writable: true, 113 + configurable: true, 114 + }); 115 + 116 + const result = checkForOAuthCallback(); 117 + expect(result).toEqual({ code: "auth-code-123", state: "state-456" }); 118 + }); 119 + 120 + it("returns null on migrate path even with code/state", () => { 121 + Object.defineProperty(globalThis.location, "search", { 122 + value: "?code=auth-code-123&state=state-456", 123 + writable: true, 124 + configurable: true, 125 + }); 126 + Object.defineProperty(globalThis.location, "pathname", { 127 + value: "/app/migrate", 128 + writable: true, 129 + configurable: true, 130 + }); 131 + 132 + expect(checkForOAuthCallback()).toBeNull(); 133 + }); 134 + 135 + it("returns null when only code is present", () => { 136 + Object.defineProperty(globalThis.location, "search", { 137 + value: "?code=auth-code-123", 138 + writable: true, 139 + configurable: true, 140 + }); 141 + Object.defineProperty(globalThis.location, "pathname", { 142 + value: "/app/", 143 + writable: true, 144 + configurable: true, 145 + }); 146 + 147 + expect(checkForOAuthCallback()).toBeNull(); 148 + }); 149 + 150 + it("returns null when only state is present", () => { 151 + Object.defineProperty(globalThis.location, "search", { 152 + value: "?state=state-456", 153 + writable: true, 154 + configurable: true, 155 + }); 156 + Object.defineProperty(globalThis.location, "pathname", { 157 + value: "/app/", 158 + writable: true, 159 + configurable: true, 160 + }); 161 + 162 + expect(checkForOAuthCallback()).toBeNull(); 163 + }); 164 + }); 165 + 166 + describe("clearOAuthCallbackParams", () => { 167 + it("removes query params from URL", () => { 168 + const replaceStateSpy = vi.spyOn(globalThis.history, "replaceState"); 169 + 170 + Object.defineProperty(globalThis.location, "href", { 171 + value: "http://localhost:3000/app/?code=123&state=456", 172 + writable: true, 173 + configurable: true, 174 + }); 175 + 176 + clearOAuthCallbackParams(); 177 + 178 + expect(replaceStateSpy).toHaveBeenCalled(); 179 + const callArgs = replaceStateSpy.mock.calls[0]; 180 + expect(callArgs[0]).toEqual({}); 181 + expect(callArgs[1]).toBe(""); 182 + const urlString = callArgs[2] as string; 183 + expect(urlString).toBe("http://localhost:3000/app/"); 184 + expect(urlString).not.toContain("?"); 185 + expect(urlString).not.toContain("code="); 186 + expect(urlString).not.toContain("state="); 187 + }); 188 + }); 189 + }); 190 + 191 + describe("DPoP proof generation", () => { 192 + it("base64url encoding produces valid output", async () => { 193 + const testData = new Uint8Array([72, 101, 108, 108, 111]); 194 + const buffer = testData.buffer; 195 + 196 + const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join(""); 197 + const base64url = btoa(binary) 198 + .replace(/\+/g, "-") 199 + .replace(/\//g, "_") 200 + .replace(/=+$/, ""); 201 + 202 + expect(base64url).toBe("SGVsbG8"); 203 + expect(base64url).not.toContain("+"); 204 + expect(base64url).not.toContain("/"); 205 + expect(base64url).not.toContain("="); 206 + }); 207 + 208 + it("JWK thumbprint uses correct key ordering for EC keys", () => { 209 + const jwk = { 210 + kty: "EC", 211 + crv: "P-256", 212 + x: "test-x", 213 + y: "test-y", 214 + }; 215 + 216 + const canonical = JSON.stringify({ 217 + crv: jwk.crv, 218 + kty: jwk.kty, 219 + x: jwk.x, 220 + y: jwk.y, 221 + }); 222 + 223 + expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}'); 224 + 225 + const keys = Object.keys(JSON.parse(canonical)); 226 + expect(keys).toEqual(["crv", "kty", "x", "y"]); 227 + 228 + for (let i = 1; i < keys.length; i++) { 229 + expect(keys[i - 1] < keys[i]).toBe(true); 230 + } 231 + }); 232 + });