diff --git a/CHANGELOG.md b/CHANGELOG.md index f8dca2e..18f4245 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,26 @@ auto-generated per-PR notes; this file is the curated, human-readable history. ## [Unreleased] ### Added +- **OAuth reauthentication now preserves dirty document work across its page + redirect** (#512 phase 3). Before navigation, a versioned, expiring + `sessionStorage` checkpoint captures authored tab state only and binds it to + the OAuth attempt plus the current workspace. A successful callback loads + the committed workspace, reconciles linked tabs, restores and revalidates raw + Spec drafts before first render, reinstalls the dirty unload guard, and only + then consumes the checkpoint. Results, ClickHouse session ids, credentials, + and other execution state are never checkpointed. Failed callbacks retain + the drafts for a state-rebound retry; malformed, expired, or + workspace-mismatched payloads fail closed into the normal boot path. The + separate tab-scoped, 15-minute validated-callback marker contains only state + and validation time; a checkpoint alone is never retry authority, and expiry + is logical eligibility rather than a promise of eager deletion. Unavailable + workspaces and prepublication storage/validation failures retain unpublished + recovery. Valid pending recovery suppresses legacy shared content and retries + after authoritative workspace load without overwriting newer save-relevant + dirty RAM; its marker is retired before publication. A new OAuth attempt + invalidates older authority. The intentional redirect receives a one-shot + unload bypass only after storage succeeds, and explicit Log out clears the + checkpoint and marker. - **Temporary ClickHouse authentication loss now suspends only authenticated execution, not the in-memory document session** (#512 phase 2, absorbing #502/#520/#522). A disposable, epoch-fenced execution scope coordinates @@ -24,8 +44,8 @@ auto-generated per-PR notes; this file is the curated, human-readable history. Successful Basic reauthentication installs a fresh scope and reloads connection metadata in place; its reusable inline form clears the password, resets submission state, and hides password visibility after success, ready - for a later recovery. Inline OAuth deliberately remains unavailable until the - Phase 3 checkpoint; explicit Log out remains destructive. + for a later recovery. Inline OAuth uses Phase 3's durable document checkpoint; + explicit Log out remains destructive. Async error-body classification and config discovery are epoch-fenced; refresh authority snapshots its epoch and rechecks it after config discovery before token-endpoint I/O, so stale discovery cannot replace a newer auth-header diff --git a/build/e2e-serve.mjs b/build/e2e-serve.mjs index d03fa31..eb528ad 100644 --- a/build/e2e-serve.mjs +++ b/build/e2e-serve.mjs @@ -25,6 +25,36 @@ const MIME = { '.png': 'image/png', '.map': 'application/json; charset=utf-8', }; +const oauthRecoveryRoot = '/tests/e2e/oauth-document-recovery'; +const oauthRecoveryPage = `${oauthRecoveryRoot}/index.html`; +const oauthRecoveryConfig = `${oauthRecoveryRoot}/config.json`; +const oauthRecoveryOidc = `${oauthRecoveryRoot}/oidc`; +const oauthRecoveryCh = `${oauthRecoveryRoot}/ch`; +const oauthRecoveryClient = 'fixture-client'; +const oauthRecoveryCode = 'fixture-code'; +const maxFixtureBodyBytes = 64 * 1024; + +function json(res, value, status = 200) { + res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' }).end(JSON.stringify(value)); +} + +function fixtureToken() { + const encode = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); + return `${encode({ alg: 'none' })}.${encode({ + email: 'recovery@example.test', exp: Math.floor(Date.now() / 1000) + 3600, + })}.sig`; +} + +async function requestBody(req, limit = maxFixtureBodyBytes) { + const chunks = []; + let size = 0; + for await (const chunk of req) { + size += chunk.length; + if (size > limit) return null; + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} async function read(path) { try { return await readFile(path); } catch { return null; } @@ -36,7 +66,82 @@ async function stripTypes(source) { } createServer(async (req, res) => { - const pathname = decodeURIComponent(new URL(req.url, `http://127.0.0.1:${port}`).pathname); + const origin = `http://127.0.0.1:${port}`; + const url = new URL(req.url, origin); + const pathname = decodeURIComponent(url.pathname); + // Isolated real-browser OAuth recovery fixture (#512 Phase 3). These are + // deliberately server routes rather than Playwright interception so the app + // follows its normal config discovery, authorization redirect, token exchange + // and ClickHouse 401 paths across real documents. + if (pathname === oauthRecoveryConfig && req.method === 'GET') { + json(res, { + idps: [{ id: 'fixture', label: 'Fixture SSO', issuer: `${origin}${oauthRecoveryOidc}`, client_id: oauthRecoveryClient }], + basic_login: false, + }); + return; + } + if (pathname === `${oauthRecoveryOidc}/.well-known/openid-configuration` && req.method === 'GET') { + json(res, { + authorization_endpoint: `${origin}${oauthRecoveryOidc}/authorize`, + token_endpoint: `${origin}${oauthRecoveryOidc}/token`, + }); + return; + } + if (pathname === `${oauthRecoveryOidc}/authorize` && req.method === 'GET') { + const redirectUri = url.searchParams.get('redirect_uri'); + const state = url.searchParams.get('state'); + if ( + redirectUri !== origin + oauthRecoveryPage + || url.searchParams.get('client_id') !== oauthRecoveryClient + || url.searchParams.get('response_type') !== 'code' + || url.searchParams.get('code_challenge_method') !== 'S256' + || !url.searchParams.get('code_challenge') + || !state + ) { + res.writeHead(400).end('invalid authorize request'); + return; + } + const callback = new URL(origin + oauthRecoveryPage); + callback.searchParams.set('code', oauthRecoveryCode); + callback.searchParams.set('state', state); + res.writeHead(302, { location: callback.href }).end(); + return; + } + if (pathname === `${oauthRecoveryOidc}/token` && req.method === 'POST') { + const body = await requestBody(req); + if (body === null) { + res.writeHead(413).end('request body too large'); + return; + } + const form = new URLSearchParams(body); + if ( + form.get('grant_type') !== 'authorization_code' + || form.get('code') !== oauthRecoveryCode + || form.get('redirect_uri') !== origin + oauthRecoveryPage + || form.get('client_id') !== oauthRecoveryClient + || !form.get('code_verifier') + ) { + res.writeHead(400).end('invalid token request'); + return; + } + json(res, { id_token: fixtureToken() }); + return; + } + if (req.method === 'POST' && pathname === oauthRecoveryCh) { + const body = await requestBody(req); + if (body === null) { + res.writeHead(413).end('request body too large'); + return; + } + if (body.includes('E2E_FORCE_AUTH_LOSS')) { + res.writeHead(401, { 'content-type': 'text/plain; charset=utf-8' }).end('Code: 516. Authentication failed'); + return; + } + // Catalog/version reads are real application traffic but must not consume + // the fixture's deliberate first-contact 401. + json(res, { data: [{ v: '25.1.0', u: 1 }] }); + return; + } const path = resolve(join(root, pathname)); if (path !== root && !path.startsWith(root + sep)) { res.writeHead(403).end(); @@ -58,6 +163,6 @@ createServer(async (req, res) => { return; } res.writeHead(200, { 'content-type': type }).end(body); -}).listen(port, () => { +}).listen(port, '127.0.0.1', () => { console.log(`e2e harness serving ${root} on http://127.0.0.1:${port}`); }); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60f274c..e2d0b53 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -102,10 +102,43 @@ controls replace authenticated actions. In-place Basic login creates a fresh scope for the new credential epoch and reloads connection-scoped metadata without rebuilding the shell. Its inline form is reusable: a successful recovery resets submission state, clears the password, and hides password -visibility, so a later loss can be recovered the same way. Inline OAuth is -deliberately unavailable until the Phase 3 checkpoint. Explicit Log out is the -separate destructive policy: it closes the scope, tears down the workbench and -Dashboard, clears credentials, and renders the signed-out login surface. +visibility, so a later loss can be recovered the same way. + +OAuth reauthentication must reload the page, so it crosses that boundary with +a separate, versioned `sessionStorage` checkpoint. The checkpoint is written +only for save-relevant dirty work and is bound to both the generated OAuth +state and the current workspace id/key. It contains authored document state +only: tab order and identity, query or Dashboard-variable bindings, SQL and raw +Spec drafts, editor mode, dirty flags, and saved-query reconciliation metadata. +It never contains credentials, results, ClickHouse session ids, result-column +metadata, or running/export state. A retained checkpoint is rebound to a new +OAuth state on retry without replacing its drafts. Retry authority is kept +separately in a tab-scoped, TTL-bound validated-callback marker containing only +the callback state and validation time; a checkpoint alone never authorizes an +automatic restore. Starting a new OAuth attempt invalidates an older marker. + +After a successful callback, bootstrap restores the return route and loads the +committed workspace normally. It then applies a matching checkpoint, reconciles +linked tabs, and revalidates every raw Spec draft before the first signed-in +render; invalid in-progress Spec text remains byte-for-byte authored while its +diagnostics are rebuilt. Workspace-unavailable and prepublication storage or +validator failures retain the recovery unpublished. A valid pending recovery +suppresses legacy shared content and retries only after an authoritative +workspace load; it never replaces newer save-relevant dirty work in RAM. Its +validated-callback marker is retired before publication. The ordinary dirty +unload guard is restored before the checkpoint is consumed. Malformed, +unsupported, logically expired after 15 minutes, or workspace-mismatched +checkpoints are ineligible and cleared best-effort while normal bootstrap +continues; an OAuth-state mismatch is not consumed, so an older callback cannot +apply or destroy a newer retry. TTL expiry controls restore eligibility and +does not promise eager physical deletion from browser storage. The one-shot +unload bypass is armed only after a durable checkpoint write; a write failure +leaves navigation and the normal dirty guard untouched. + +Explicit Log out remains the separate destructive policy: it closes the scope, +tears down the workbench and Dashboard, clears credentials and any pending +OAuth document checkpoint and validated-callback marker, and renders the +signed-out login surface. Connection lifecycle ownership follows the same rule. `ConnectionSession` publishes one read-only signal and assigns a monotonically increasing diff --git a/docs/LOGIN-SCREEN.md b/docs/LOGIN-SCREEN.md index e03358d..3698c67 100644 --- a/docs/LOGIN-SCREEN.md +++ b/docs/LOGIN-SCREEN.md @@ -29,6 +29,50 @@ on your IdP and threat model. Common, all valid, variants: The code treats `client_secret` as optional, so any of these is a config-only choice. +### Reauthentication and unsaved work + +Temporary ClickHouse authentication loss does not end the document session. +The editor, tabs, drafts, navigation, dirty state, and completed results remain +mounted while the same authentication controls appear in the application +shell. A successful Basic sign-in resumes that exact in-memory session without +a page reload; the inline form clears its password and resets password +visibility after success so it is safe to reuse after another loss. + +OAuth requires a page redirect. Before navigating, the browser writes a +versioned recovery checkpoint to this tab's `sessionStorage`, but only when +there is save-relevant dirty work. The checkpoint is bound to the OAuth +attempt's random state and the current workspace id/key. It contains authored +tab state (including SQL and raw Spec drafts and Dashboard-variable bindings), +not OAuth tokens, passwords, query results, ClickHouse session ids, result +columns, or running/export state. Like the OAuth token itself, +`sessionStorage` is tab-scoped browser storage, not encryption: script running +in the same origin can read it. + +The intentional redirect bypasses the dirty-page warning exactly once, and +only after that checkpoint write succeeds. If serialization or storage fails, +the redirect does not start, the warning remains armed, and the inline controls +show the failure so sign-in can be retried. A failed IdP callback keeps the +draft checkpoint; a retry binds the same authored payload to its new OAuth +state. A separate tab-scoped, TTL-bound validated-callback marker contains only +that state and its validation time; the checkpoint alone never authorizes an +automatic restore. Starting another OAuth attempt invalidates the older marker. + +After a successful callback, the app restores the route, loads the committed +workspace, validates the checkpoint against that workspace and callback state, +reconciles linked saved-query tabs, and reparses every raw Spec before the first +signed-in render. Invalid JSON drafts survive as authored and regain normal +diagnostics. If the workspace is unavailable, or storage/validation fails +before publication, recovery remains unpublished and retryable. A valid pending +recovery suppresses legacy shared content and retries automatically only after +an authoritative workspace load; it never overwrites newer save-relevant dirty +work in memory, and its marker is retired before publication. Results and other +execution state do not survive an OAuth reload. Malformed, unsupported, +logically expired (after 15 minutes), or workspace-mismatched checkpoints are +ineligible and the normal signed-in flow continues; expiry does not promise +eager physical removal from browser storage. A stale callback cannot consume a +newer retry's checkpoint. Explicit **Log out** clears any pending checkpoint +and validated-callback marker. + ### Multiple IdPs `config.json` may instead list several providers, and the login screen shows diff --git a/src/application/connection-session.ts b/src/application/connection-session.ts index 471e95f..b48f23f 100644 --- a/src/application/connection-session.ts +++ b/src/application/connection-session.ts @@ -63,6 +63,16 @@ export interface ConnectionSessionDeps { * valid login). The session never renders — it calls this and lets the * shell decide how to show the login screen. */ onAuthLost: (detail?: string, lease?: AuthenticatedCancellationLease) => void; + /** Persist an OAuth-state-bound document recovery snapshot after this + * session has written its PKCE attempt. `true` means that snapshot is + * durable and the intentional redirect may bypass the dirty unload guard. */ + prepareOAuthRedirect?(oauthState: string): boolean; + /** Arm a one-shot dirty-unload bypass for the immediately following OAuth + * navigation. The returned callback undoes the arm if navigation fails. */ + armOAuthRedirectUnloadBypass?(): () => void; + /** Explicit logout is terminal for any persisted document-recovery payload. + * Auth loss deliberately does not call this: reauthentication needs it. */ + clearOAuthDocumentRecovery?(): void; } // ── The ClickHouse auth context ────────────────────────────────────────────── @@ -163,6 +173,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection const connectionSignal = signal( hasRestoredCredentials ? { kind: 'starting', epoch: 0 } : { kind: 'signed-out', epoch: 0 }, ); + let interactiveAuthenticationPrior: AuthenticationPriorState | null = null; const transition = (event: ConnectionLifecycleEvent): ConnectionLifecycleState => { const next = reduceConnectionLifecycle(connectionSignal.value, event); if (next !== connectionSignal.value) connectionSignal.value = next; @@ -230,6 +241,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection storeTokens(id, refresh); chCtx.authConfirmed = false; transition({ type: 'credentials-installed' }); + interactiveAuthenticationPrior = null; } function clearTokens(preserveBasicRecovery = false): void { token = null; @@ -247,15 +259,49 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection // shell's job now, done by whatever caller notices signOut() was called.) const signOut = (): void => { transition({ type: 'signed-out' }); - clearTokens(); + interactiveAuthenticationPrior = null; + let authClearFailed = false; + let authClearError: unknown; + try { + clearTokens(); + } catch (error) { + authClearFailed = true; + authClearError = error; + } finally { + try { + deps.clearOAuthDocumentRecovery?.(); + } catch (error) { + // Preserve a credential-storage failure as the primary sign-out error. + // With no earlier failure, the recovery cleanup error remains visible. + if (!authClearFailed) throw error; + } + } + if (authClearFailed) throw authClearError; }; function authenticationPrior(): AuthenticationPriorState { const current = connectionSignal.value; - if (current.kind === 'auth-required' || current.kind === 'signed-out') return current; + if (current.kind === 'auth-required' || current.kind === 'signed-out') { + interactiveAuthenticationPrior = current; + return current; + } + if (current.kind === 'reauthenticating' && interactiveAuthenticationPrior) { + return interactiveAuthenticationPrior; + } // Interactive authentication is entered from the login surface today. // Keep this fail-safe explicit in case a future caller opens it elsewhere. - return { kind: 'signed-out', epoch: current.epoch }; + const prior: AuthenticationPriorState = { kind: 'signed-out', epoch: current.epoch }; + interactiveAuthenticationPrior = prior; + return prior; + } + function failAuthentication( + epoch: number, + prior: AuthenticationPriorState, + ): void { + const failed = transition({ type: 'failed-authentication', epoch, prior }); + if (failed.epoch === epoch && failed.kind !== 'reauthenticating') { + interactiveAuthenticationPrior = null; + } } function assertCurrentAuthentication(epoch: number): void { const current = connectionSignal.value; @@ -268,34 +314,119 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection async function beginOAuth(idpArg?: string, targetOrigin?: string): Promise { const prior = authenticationPrior(); const authenticating = transition({ type: 'start-authentication' }); + const attemptKeys = ['oauth_verifier', 'oauth_state', 'oauth_return_route'] as const; + type AttemptValues = Record; + let priorAttemptValues: AttemptValues | null = null; + let ownedAttemptValues: AttemptValues | null = null; + let disarmUnloadBypass: (() => void) | undefined; + const ownsStoredAttempt = (): boolean => { + const current = connectionSignal.value; + if (!ownedAttemptValues + || current.epoch !== authenticating.epoch + || current.kind !== 'reauthenticating') return false; + try { + return attemptKeys.every((key) => ss.getItem(key) === ownedAttemptValues![key]); + } catch { + return false; + } + }; + const assertAttemptOwnership = (): void => { + if (!ownsStoredAttempt()) throw new Error('Authentication attempt superseded'); + }; try { - if (idpArg) selectIdp(idpArg); + // Keep these three legacy keys as the one canonical OAuth attempt. A + // failed post-PKCE preparation must leave a previous callback/retry + // attempt exactly as it was, including absent keys. + priorAttemptValues = { + oauth_verifier: ss.getItem('oauth_verifier'), + oauth_state: ss.getItem('oauth_state'), + oauth_return_route: ss.getItem('oauth_return_route'), + }; + ownedAttemptValues = { ...priorAttemptValues }; + if (idpArg) { + selectIdp(idpArg); + assertAttemptOwnership(); + } // A picked saved-connection can target another cluster: stash its origin so // the rebuilt chCtx (after the redirect reload) POSTs the bearer there. // Survives the redirect like oauth_state/oauth_idp; cleared for serving-host SSO. if (targetOrigin) ss.setItem('oauth_origin', targetOrigin); else ss.removeItem('oauth_origin'); + assertAttemptOwnership(); const cfg = await resolveConfig(); assertCurrentAuthentication(authenticating.epoch); const { verifier, challenge } = await generatePKCE(cryptoObj); - assertCurrentAuthentication(authenticating.epoch); const state = randomState(cryptoObj); - ss.setItem('oauth_verifier', verifier); - ss.setItem('oauth_state', state); const returnParams = new URLSearchParams(loc.search); ['code', 'state', 'scope', 'authuser', 'prompt', 'error', 'error_description', 'error_uri'] .forEach((key) => returnParams.delete(key)); const returnSearch = returnParams.toString(); - ss.setItem('oauth_return_route', JSON.stringify({ + const returnRoute = JSON.stringify({ state, search: returnSearch ? `?${returnSearch}` : '', - })); - loc.href = buildAuthorizeUrl(cfg, { + }); + const redirectUrl = buildAuthorizeUrl(cfg, { redirectUri: loc.origin + basePath, challenge, state, }); + // From this ownership check through navigation there is no asynchronous + // yield. Each successful write advances the exact triplet this attempt + // owns so a stale/reentrant failure cannot roll back a newer attempt. + assertCurrentAuthentication(authenticating.epoch); + ss.setItem('oauth_verifier', verifier); + ownedAttemptValues.oauth_verifier = verifier; + assertAttemptOwnership(); + ss.setItem('oauth_state', state); + ownedAttemptValues.oauth_state = state; + assertAttemptOwnership(); + ss.setItem('oauth_return_route', returnRoute); + ownedAttemptValues.oauth_return_route = returnRoute; + assertAttemptOwnership(); + const hasRecoverySnapshot = deps.prepareOAuthRedirect + ? deps.prepareOAuthRedirect(state) + : false; + assertAttemptOwnership(); + // Deliberately adjacent to navigation: no unrelated asynchronous work + // may run after the one-shot bypass is armed. + if (hasRecoverySnapshot) { + if (!deps.armOAuthRedirectUnloadBypass) { + throw new Error('OAuth recovery redirect requires an unload bypass'); + } + disarmUnloadBypass = deps.armOAuthRedirectUnloadBypass(); + assertAttemptOwnership(); + } + loc.href = redirectUrl; + assertAttemptOwnership(); } catch (error) { - transition({ type: 'failed-authentication', epoch: authenticating.epoch, prior }); + // A failed location assignment is observable in tests and can happen in + // embedded/browser-hosted surfaces. Always return the unload guard to + // its normal state before preserving the prior OAuth attempt. + try { + disarmUnloadBypass?.(); + } catch { + // The original redirect failure is the actionable error. Continue + // cleanup even when a host-provided disarm callback misbehaves. + } + if (priorAttemptValues && ownsStoredAttempt()) { + for (const key of attemptKeys) { + try { + const value = priorAttemptValues[key]; + if (value === null) ss.removeItem(key); + else ss.setItem(key, value); + ownedAttemptValues![key] = value; + } catch { + // A failed restore may have mutated storage before throwing. Stop + // rather than risk overwriting a reentrant/newer attempt. + break; + } + // Storage callbacks are externally supplied and may synchronously + // sign out or start another attempt. Advance this attempt's expected + // triplet one key at a time and never mutate the next key after it + // loses lifecycle or exact-storage ownership. + if (!ownsStoredAttempt()) break; + } + } + failAuthentication(authenticating.epoch, prior); throw error; } } @@ -491,7 +622,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection await queryJsonFn(probeCtx, 'SELECT 1'); assertCurrentAuthentication(authenticating.epoch); } catch (error) { - transition({ type: 'failed-authentication', epoch: authenticating.epoch, prior }); + failAuthentication(authenticating.epoch, prior); throw error; } // Probe passed → commit the session and switch the live ctx to the target. @@ -503,6 +634,7 @@ export function createConnectionSession(deps: ConnectionSessionDeps): Connection chCtx.origin = target; chCtx.authConfirmed = false; transition({ type: 'credentials-installed' }); + interactiveAuthenticationPrior = null; } // --- dashboard (#149 D1) ------------------------------------------------- diff --git a/src/application/oauth-document-recovery-session.ts b/src/application/oauth-document-recovery-session.ts new file mode 100644 index 0000000..551e575 --- /dev/null +++ b/src/application/oauth-document-recovery-session.ts @@ -0,0 +1,403 @@ +// OAuth redirect recovery storage/state coordinator (#512 Phase 3). This is +// deliberately below the application shell: it knows no UI, editor, OAuth +// transport, or workspace persistence. The shell decides when to prepare a +// redirect and when a successful callback is safe to restore. + +import { + OAUTH_DOCUMENT_RECOVERY_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + OAUTH_DOCUMENT_RECOVERY_VERSION, + decodeOAuthDocumentRecovery, + decodeOAuthDocumentRecoveryValidatedCallback, + encodeOAuthDocumentRecovery, + encodeOAuthDocumentRecoveryValidatedCallback, + rebindOAuthDocumentRecovery, + type OAuthDocumentRecoverySnapshot, + type OAuthDocumentRecoveryTab, + type OAuthDocumentRecoveryValidatedCallback, +} from '../core/oauth-document-recovery.js'; +import { + newTabObj, + reconcileLinkedTabsToLatest, + tabSaveDirty, + type AppState, + type QueryTab, + type SpecValidationService, +} from '../state.js'; +import type { StoredWorkspaceV5 } from '../generated/json-schema.types.js'; +import { signal } from '@preact/signals-core'; +import type { Signal } from '@preact/signals-core'; + +/** The deliberately narrow subset of sessionStorage used by this coordinator. */ +export interface OAuthDocumentRecoveryStorage { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +/** Only document-session fields which recovery may observe or replace. */ +export type OAuthDocumentRecoveryState = Pick< + AppState, + 'tabs' | 'activeTabId' | 'nextTabId' | 'workspaceId' | 'workspaceKey' +>; + +export interface OAuthDocumentRecoverySessionDeps { + storage: OAuthDocumentRecoveryStorage; + /** Injected rather than read from Date, so TTL behavior is deterministic. */ + now(): number; + state: OAuthDocumentRecoveryState; + /** Used only by linked-tab reconciliation after a successful restore. */ + specValidators: SpecValidationService; +} + +export type OAuthDocumentRecoveryRestoreResult = + | { kind: 'absent' } + /** A callback for an earlier OAuth attempt; its payload must remain retryable. */ + | { kind: 'callback-mismatch' } + | { kind: 'invalid-cleared'; reason: 'malformed' | 'unsupported' | 'expired' } + /** Workspace loading is temporarily unavailable; retain the valid payload. */ + | { kind: 'workspace-unavailable-retained' } + /** Pending callback authority could not be retired safely before publication. */ + | { kind: 'retry-deferred-retained' } + | { kind: 'workspace-mismatch-cleared' } + /** The snapshot deliberately remains until the shell has installed its guard. */ + | { kind: 'restored' }; + +export interface OAuthDocumentRecoverySession { + /** + * Persist a fresh dirty-session checkpoint, or bind an already-valid retained + * checkpoint to a retry's OAuth state. Storage/serialization failures are + * intentionally allowed to throw: ConnectionSession must then abort redirect. + */ + prepare(oauthState: string): boolean; + /** + * Restore only after OAuth callback validation and workspace loading. A + * restored payload is not consumed automatically; call consume once the + * shell has revalidated Specs and installed the ordinary dirty-tab guard. + */ + restore(callbackState: string, workspace: StoredWorkspaceV5 | null): OAuthDocumentRecoveryRestoreResult; + /** + * Retry a workspace-unavailable restore only when this session still owns + * fresh in-memory callback authority or a strict persisted callback marker. + * A checkpoint by itself is never authority and produces `kind: 'absent'`. + */ + retryPending(workspace: StoredWorkspaceV5 | null): OAuthDocumentRecoveryRestoreResult; + /** Remove the payload after the caller's post-restore work succeeded. */ + consume(): void; + /** Explicit logout/end-session cleanup. */ + clear(): void; +} + +function authoredTab(tab: QueryTab): OAuthDocumentRecoveryTab { + return { + id: tab.id, + doc: tab.doc.kind === 'query' + ? { kind: 'query' } + : { kind: 'dashboard-variable', dashboardId: tab.doc.dashboardId, variableName: tab.doc.variableName }, + name: tab.name, + sqlDraft: tab.sqlDraft, + specText: tab.specText, + specVersion: tab.specVersion, + editorMode: tab.editorMode, + dirtySql: tab.dirtySql, + dirtySpec: tab.dirtySpec, + savedId: tab.savedId, + ...(tab.lastCommittedQueryToken === undefined ? {} : { lastCommittedQueryToken: tab.lastCommittedQueryToken }), + ...(tab.externalState === undefined ? {} : { externalState: tab.externalState }), + }; +} + +function freshTab(tab: OAuthDocumentRecoveryTab): QueryTab { + // Start from the application's single canonical constructor. In particular, + // this keeps result/chSession/column metadata and Spec diagnostics transient. + const restored = newTabObj(tab.id); + restored.doc = tab.doc.kind === 'query' + ? { kind: 'query' } + : { kind: 'dashboard-variable', dashboardId: tab.doc.dashboardId, variableName: tab.doc.variableName }; + restored.name = tab.name; + restored.sqlDraft = tab.sqlDraft; + restored.specText = tab.specText; + restored.specVersion = tab.specVersion; + restored.editorMode = tab.editorMode; + restored.dirtySql = tab.dirtySql; + restored.dirtySpec = tab.dirtySpec; + restored.savedId = tab.savedId; + if (tab.lastCommittedQueryToken !== undefined) { + restored.lastCommittedQueryToken = tab.lastCommittedQueryToken; + } + if (tab.externalState !== undefined) restored.externalState = tab.externalState; + return restored; +} + +function snapshotFor(state: OAuthDocumentRecoveryState, oauthState: string, createdAt: number): OAuthDocumentRecoverySnapshot { + return { + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt, + workspaceId: state.workspaceId, + workspaceKey: state.workspaceKey, + oauthState, + tabs: state.tabs.value.map(authoredTab), + activeTabId: state.activeTabId.value, + nextTabId: state.nextTabId, + }; +} + +/** Construct a storage-backed OAuth document recovery transaction. */ +export function createOAuthDocumentRecoverySession( + deps: OAuthDocumentRecoverySessionDeps, +): OAuthDocumentRecoverySession { + let pendingValidatedCallback: OAuthDocumentRecoveryValidatedCallback | null = null; + type PendingCallbackLookup = + | { kind: 'absent' } + | { kind: 'available'; value: OAuthDocumentRecoveryValidatedCallback } + | { kind: 'deferred' }; + + const clearPendingBestEffort = (): void => { + pendingValidatedCallback = null; + try { + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + } catch { + // An orphan marker has no authority without a matching, valid checkpoint. + // Automatic result cleanup must not turn a published restore into a throw. + } + }; + + const rememberValidatedCallback = (oauthState: string, validatedAt: number): void => { + const marker: OAuthDocumentRecoveryValidatedCallback = { + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState, + validatedAt, + }; + pendingValidatedCallback = marker; + try { + deps.storage.setItem( + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + encodeOAuthDocumentRecoveryValidatedCallback(marker), + ); + } catch { + // Same-tab retry still has the in-memory authority. Persistence is + // best-effort because a completed sign-in must not become a failed one. + } + }; + + const pendingCallback = (now: number): PendingCallbackLookup => { + if (pendingValidatedCallback !== null) { + const decoded = decodeOAuthDocumentRecoveryValidatedCallback( + encodeOAuthDocumentRecoveryValidatedCallback(pendingValidatedCallback), + now, + ); + if (decoded.kind === 'valid') return { kind: 'available', value: decoded.value }; + clearPendingBestEffort(); + return { kind: 'absent' }; + } + let serialized: string | null; + try { + serialized = deps.storage.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + } catch { + // Without a trustworthy read, a checkpoint must never bootstrap its own + // authority. Keep all document state untouched for a later retry. + return { kind: 'deferred' }; + } + const decoded = decodeOAuthDocumentRecoveryValidatedCallback(serialized, now); + if (decoded.kind === 'valid') { + return { kind: 'available', value: decoded.value }; + } + if (decoded.kind === 'invalid') clearPendingBestEffort(); + return { kind: 'absent' }; + }; + + const retirePendingBeforePublish = ( + marker: OAuthDocumentRecoveryValidatedCallback, + ): boolean => { + // Retire memory first, then prove persisted authority is absent. If either + // the presence read, removal, or verification read fails, restore memory + // and leave the checkpoint/private candidate untouched for a later retry. + pendingValidatedCallback = null; + try { + const persisted = deps.storage.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + if (persisted !== null) { + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + } + if (deps.storage.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) !== null) { + pendingValidatedCallback = marker; + return false; + } + return true; + } catch { + pendingValidatedCallback = marker; + return false; + } + }; + + const consume = (): void => { + // Order is deliberate: if checkpoint removal fails, its callback authority + // remains available too. A marker orphaned by the second removal failing is + // harmless and is pruned when retryPending observes the absent checkpoint. + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_KEY); + pendingValidatedCallback = null; + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + }; + + const clear = (): void => { + pendingValidatedCallback = null; + let failed = false; + let primaryError: unknown; + try { + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_KEY); + } catch (error) { + failed = true; + primaryError = error; + } + try { + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + } catch (error) { + if (!failed) { + failed = true; + primaryError = error; + } + } + if (failed) throw primaryError; + }; + + const invalidatePendingAfterWrite = (): void => { + pendingValidatedCallback = null; + // Unlike result cleanup, failure is visible here: ConnectionSession must + // abort redirect if it cannot invalidate authority for an older callback. + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + }; + + function prepare(oauthState: string): boolean { + const now = deps.now(); + if (deps.state.tabs.value.some(tabSaveDirty)) { + // Encode before replacing storage. If encoding throws, an earlier retry + // payload stays intact and navigation is not allowed to continue. + deps.storage.setItem( + OAUTH_DOCUMENT_RECOVERY_KEY, + encodeOAuthDocumentRecovery(snapshotFor(deps.state, oauthState, now)), + ); + invalidatePendingAfterWrite(); + return true; + } + + const decoded = decodeOAuthDocumentRecovery(deps.storage.getItem(OAUTH_DOCUMENT_RECOVERY_KEY), now); + if (decoded.kind === 'missing') return false; + if (decoded.kind === 'invalid') { + clear(); + return false; + } + // Retrying a failed callback preserves every authored field; only the + // attempt binding and freshness timestamp change. + deps.storage.setItem( + OAUTH_DOCUMENT_RECOVERY_KEY, + encodeOAuthDocumentRecovery(rebindOAuthDocumentRecovery(decoded.value, oauthState, now)), + ); + invalidatePendingAfterWrite(); + return true; + } + + function restoreCheckpoint( + callbackState: string, + workspace: StoredWorkspaceV5 | null, + now: number, + beforePublish?: () => boolean, + ): OAuthDocumentRecoveryRestoreResult { + let serialized: string | null; + try { + serialized = deps.storage.getItem(OAUTH_DOCUMENT_RECOVERY_KEY); + } catch { + // Checkpoint access is pre-publication work. A fresh validated callback + // must retain authority so storage recovery can be retried safely. + return { kind: 'retry-deferred-retained' }; + } + const decoded = decodeOAuthDocumentRecovery(serialized, now); + if (decoded.kind === 'missing') return { kind: 'absent' }; + if (decoded.kind === 'invalid') { + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_KEY); + return { kind: 'invalid-cleared', reason: decoded.reason }; + } + if (decoded.value.oauthState !== callbackState) return { kind: 'callback-mismatch' }; + if (workspace === null) return { kind: 'workspace-unavailable-retained' }; + if (decoded.value.workspaceId !== workspace.id + || decoded.value.workspaceKey !== workspace.key) { + deps.storage.removeItem(OAUTH_DOCUMENT_RECOVERY_KEY); + return { kind: 'workspace-mismatch-cleared' }; + } + + let candidate: Signal; + try { + // Build every tab before touching live state. A strict decode has already + // proven ids/order/allocation valid; this step intentionally does not parse + // Spec text, so invalid in-progress JSON survives byte-for-byte for the + // document session to revalidate before first render. + const tabs = decoded.value.tabs.map(freshTab); + + // Run reconciliation against a private signal first. Besides keeping the + // restore transaction atomic, this means an injected validator failure + // cannot publish a partly restored document session. Its tab objects are + // exactly the ones published below; reconciliation never writes workspace. + candidate = signal(tabs); + reconcileLinkedTabsToLatest({ tabs: candidate }, workspace, deps.specValidators); + } catch { + // Candidate construction/reconciliation is pre-publication work. Keep the + // checkpoint and callback authority retryable rather than leaking a raw + // validator/constructor exception through bootstrap. + return { kind: 'retry-deferred-retained' }; + } + if (beforePublish && !beforePublish()) return { kind: 'retry-deferred-retained' }; + + // Publish the three coordinated document-session fields only after every + // tab has been built and reconciliation has succeeded. + deps.state.tabs.value = candidate.value; + deps.state.activeTabId.value = decoded.value.activeTabId; + deps.state.nextTabId = decoded.value.nextTabId; + return { kind: 'restored' }; + } + + function restore( + callbackState: string, + workspace: StoredWorkspaceV5 | null, + ): OAuthDocumentRecoveryRestoreResult { + const now = deps.now(); + const result = restoreCheckpoint(callbackState, workspace, now); + if (result.kind === 'workspace-unavailable-retained' + || result.kind === 'retry-deferred-retained') { + rememberValidatedCallback(callbackState, now); + } else { + // A fresh validated callback supersedes any earlier pending authority. + // Publication, absence, invalidity, and proven mismatch are all terminal. + clearPendingBestEffort(); + } + return result; + } + + function retryPending( + workspace: StoredWorkspaceV5 | null, + ): OAuthDocumentRecoveryRestoreResult { + const now = deps.now(); + const pending = pendingCallback(now); + if (pending.kind === 'absent') return { kind: 'absent' }; + if (pending.kind === 'deferred') return { kind: 'retry-deferred-retained' }; + // Never let an automatic retry replace save-relevant work authored in RAM + // after the callback was deferred. This gate precedes checkpoint access, + // candidate construction, reconciliation, and authority retirement. + if (deps.state.tabs.value.some(tabSaveDirty)) { + return { kind: 'retry-deferred-retained' }; + } + const marker = pending.value; + const result = restoreCheckpoint( + marker.oauthState, + workspace, + now, + () => retirePendingBeforePublish(marker), + ); + if (result.kind !== 'workspace-unavailable-retained' + && result.kind !== 'retry-deferred-retained' + && result.kind !== 'restored') { + clearPendingBestEffort(); + } + return result; + } + + return { prepare, restore, retryPending, consume, clear }; +} diff --git a/src/core/oauth-document-recovery.ts b/src/core/oauth-document-recovery.ts new file mode 100644 index 0000000..eae5ec8 --- /dev/null +++ b/src/core/oauth-document-recovery.ts @@ -0,0 +1,318 @@ +// Versioned, storage-agnostic OAuth redirect recovery wire codec (#512). +// This module deliberately knows nothing about AppState, storage, or OAuth +// transport. Callers provide the serialized value and their notion of `now`. + +export const OAUTH_DOCUMENT_RECOVERY_KEY = 'oauth_document_recovery'; +export const OAUTH_DOCUMENT_RECOVERY_VERSION = 1; +export const OAUTH_DOCUMENT_RECOVERY_TTL_MS = 15 * 60 * 1000; +export const OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY = + 'oauth_document_recovery_validated_callback'; +export const OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION = 1; +export const OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_TTL_MS = + OAUTH_DOCUMENT_RECOVERY_TTL_MS; + +export type OAuthDocumentRecoveryDocument = + | { kind: 'query' } + | { kind: 'dashboard-variable'; dashboardId: string; variableName: string }; + +/** The intentionally authored-only wire subset of a QueryTab. */ +export interface OAuthDocumentRecoveryTab { + id: string; + doc: OAuthDocumentRecoveryDocument; + name: string; + sqlDraft: string; + specText: string; + specVersion: number; + editorMode: 'sql' | 'spec'; + dirtySql: boolean; + dirtySpec: boolean; + savedId: string | null; + lastCommittedQueryToken?: string; + externalState?: 'conflict' | 'deleted' | null; +} + +export interface OAuthDocumentRecoverySnapshot { + version: typeof OAUTH_DOCUMENT_RECOVERY_VERSION; + createdAt: number; + workspaceId: string; + workspaceKey: string; + oauthState: string; + tabs: OAuthDocumentRecoveryTab[]; + activeTabId: string; + nextTabId: number; +} + +/** + * Proof that this tab completed one exact OAuth callback whose checkpoint could + * not yet be workspace-bound. It intentionally carries no authored content, + * workspace identity, credential, authorization code, or PKCE material. + */ +export interface OAuthDocumentRecoveryValidatedCallback { + version: typeof OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION; + oauthState: string; + validatedAt: number; +} + +export type OAuthDocumentRecoveryInvalidReason = 'malformed' | 'unsupported' | 'expired'; + +export type OAuthDocumentRecoveryDecodeResult = + | { kind: 'missing' } + | { kind: 'invalid'; reason: OAuthDocumentRecoveryInvalidReason } + | { kind: 'valid'; value: OAuthDocumentRecoverySnapshot }; + +export type OAuthDocumentRecoveryValidatedCallbackDecodeResult = + | { kind: 'missing' } + | { kind: 'invalid'; reason: OAuthDocumentRecoveryInvalidReason } + | { kind: 'valid'; value: OAuthDocumentRecoveryValidatedCallback }; + +type ShapeResult = + | { kind: 'invalid'; reason: 'malformed' | 'unsupported' } + | { kind: 'valid'; value: OAuthDocumentRecoverySnapshot }; + +type ValidatedCallbackShapeResult = + | { kind: 'invalid'; reason: 'malformed' | 'unsupported' } + | { kind: 'valid'; value: OAuthDocumentRecoveryValidatedCallback }; + +const ROOT_KEYS = [ + 'version', 'createdAt', 'workspaceId', 'workspaceKey', 'oauthState', 'tabs', 'activeTabId', 'nextTabId', +]; +const VALIDATED_CALLBACK_KEYS = ['version', 'oauthState', 'validatedAt']; +const TAB_KEYS = [ + 'id', 'doc', 'name', 'sqlDraft', 'specText', 'specVersion', 'editorMode', 'dirtySql', 'dirtySpec', 'savedId', +]; +const OPTIONAL_TAB_KEYS = ['lastCommittedQueryToken', 'externalState']; + +const isRecord = (value: unknown): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value); + +const has = (value: Record, key: string): boolean => + Object.prototype.hasOwnProperty.call(value, key); + +const finiteInteger = (value: unknown): value is number => + typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value); + +const nonBlank = (value: unknown): value is string => + typeof value === 'string' && value.trim().length > 0; + +function exactKeys(value: Record, required: readonly string[], optional: readonly string[] = [], strict = true): boolean { + if (!required.every((key) => has(value, key))) return false; + return !strict || Object.keys(value).every((key) => required.includes(key) || optional.includes(key)); +} + +/** A canonical tab id that can safely participate in numeric id allocation. */ +function tabOrdinal(id: unknown): number | null { + if (typeof id !== 'string' || !/^t[1-9]\d*$/.test(id)) return null; + const ordinal = Number(id.slice(1)); + return Number.isSafeInteger(ordinal) ? ordinal : null; +} + +function validDocument(value: unknown, strict = true): value is OAuthDocumentRecoveryDocument { + if (!isRecord(value) || typeof value.kind !== 'string') return false; + if (value.kind === 'query') return exactKeys(value, ['kind'], [], strict); + return value.kind === 'dashboard-variable' + && exactKeys(value, ['kind', 'dashboardId', 'variableName'], [], strict) + && nonBlank(value.dashboardId) + && nonBlank(value.variableName); +} + +function validTab(value: unknown, strict = true): value is OAuthDocumentRecoveryTab { + if (!isRecord(value) || !exactKeys(value, TAB_KEYS, OPTIONAL_TAB_KEYS, strict)) return false; + if (tabOrdinal(value.id) === null || !validDocument(value.doc, strict) + || typeof value.name !== 'string' || typeof value.sqlDraft !== 'string' || typeof value.specText !== 'string' + || !finiteInteger(value.specVersion) || value.specVersion < 1 + || (value.editorMode !== 'sql' && value.editorMode !== 'spec') + || typeof value.dirtySql !== 'boolean' || typeof value.dirtySpec !== 'boolean' + || (value.savedId !== null && !nonBlank(value.savedId))) return false; + if (value.doc.kind === 'dashboard-variable' && value.editorMode !== 'sql') return false; + if (has(value, 'lastCommittedQueryToken') && value.lastCommittedQueryToken !== undefined + && typeof value.lastCommittedQueryToken !== 'string') return false; + return !has(value, 'externalState') || (!strict && value.externalState === undefined) + || value.externalState === null || value.externalState === 'conflict' || value.externalState === 'deleted'; +} + +function readShape(value: unknown, strict = true): ShapeResult { + if (!isRecord(value) || !exactKeys(value, ROOT_KEYS, [], strict)) return { kind: 'invalid', reason: 'malformed' }; + if (!finiteInteger(value.version)) return { kind: 'invalid', reason: 'malformed' }; + if (value.version !== OAUTH_DOCUMENT_RECOVERY_VERSION) return { kind: 'invalid', reason: 'unsupported' }; + const { createdAt, workspaceId, workspaceKey, oauthState, tabs, activeTabId, nextTabId } = value; + if (!finiteInteger(createdAt) || !nonBlank(workspaceId) || !nonBlank(workspaceKey) + || !nonBlank(oauthState) || !Array.isArray(tabs) || tabs.length === 0 + || !nonBlank(activeTabId) || tabOrdinal(activeTabId) === null + || !finiteInteger(nextTabId) || !Number.isSafeInteger(nextTabId) || nextTabId < 1) { + return { kind: 'invalid', reason: 'malformed' }; + } + if (!tabs.every((tab) => validTab(tab, strict))) return { kind: 'invalid', reason: 'malformed' }; + const recoveredTabs = tabs as OAuthDocumentRecoveryTab[]; + const ids = new Set(); + let maxTabId = 0; + for (const tab of recoveredTabs) { + const ordinal = tabOrdinal(tab.id); + // validTab already established this; retaining the guard makes the invariant + // local if the validator changes later. + if (ordinal === null || ids.has(tab.id)) return { kind: 'invalid', reason: 'malformed' }; + ids.add(tab.id); + maxTabId = Math.max(maxTabId, ordinal); + } + if (!ids.has(activeTabId) || nextTabId <= maxTabId) { + return { kind: 'invalid', reason: 'malformed' }; + } + return { + kind: 'valid', + value: { + version: OAUTH_DOCUMENT_RECOVERY_VERSION, createdAt, workspaceId, workspaceKey, oauthState, + tabs: recoveredTabs, activeTabId, nextTabId, + }, + }; +} + +function readValidatedCallbackShape( + value: unknown, + strict = true, +): ValidatedCallbackShapeResult { + if (!isRecord(value) || !exactKeys(value, VALIDATED_CALLBACK_KEYS, [], strict)) { + return { kind: 'invalid', reason: 'malformed' }; + } + if (!finiteInteger(value.version)) return { kind: 'invalid', reason: 'malformed' }; + if (value.version !== OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION) { + return { kind: 'invalid', reason: 'unsupported' }; + } + if (!nonBlank(value.oauthState) || !finiteInteger(value.validatedAt)) { + return { kind: 'invalid', reason: 'malformed' }; + } + return { + kind: 'valid', + value: { + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState: value.oauthState, + validatedAt: value.validatedAt, + }, + }; +} + +/** + * The smallest allocation cursor that cannot collide with the supplied tabs. + * Invalid, duplicate, or numerically unsafe ids are programmer errors here; + * untrusted payloads should use decodeOAuthDocumentRecovery instead. + */ +export function minimumNextTabId(tabs: readonly Pick[]): number { + const ids = new Set(); + let maxTabId = 0; + for (const tab of tabs) { + const ordinal = tabOrdinal(tab?.id); + if (ordinal === null || ids.has(tab.id)) throw new TypeError('Invalid recovery tab id'); + ids.add(tab.id); + maxTabId = Math.max(maxTabId, ordinal); + } + if (maxTabId >= Number.MAX_SAFE_INTEGER) throw new RangeError('Recovery tab id allocation is unsafe'); + return maxTabId + 1; +} + +function assertShape(value: unknown): OAuthDocumentRecoverySnapshot { + const result = readShape(value, false); + if (result.kind !== 'valid') throw new TypeError(`Invalid OAuth document recovery snapshot: ${result.reason}`); + return result.value; +} + +function assertValidatedCallbackShape( + value: unknown, +): OAuthDocumentRecoveryValidatedCallback { + const result = readValidatedCallbackShape(value, false); + if (result.kind !== 'valid') { + throw new TypeError(`Invalid OAuth document recovery validated callback: ${result.reason}`); + } + return result.value; +} + +function canonicalSnapshot(value: OAuthDocumentRecoverySnapshot): OAuthDocumentRecoverySnapshot { + return { + version: value.version, + createdAt: value.createdAt, + workspaceId: value.workspaceId, + workspaceKey: value.workspaceKey, + oauthState: value.oauthState, + tabs: value.tabs.map((tab) => ({ + id: tab.id, + doc: tab.doc.kind === 'query' + ? { kind: 'query' as const } + : { kind: 'dashboard-variable' as const, dashboardId: tab.doc.dashboardId, variableName: tab.doc.variableName }, + name: tab.name, + sqlDraft: tab.sqlDraft, + specText: tab.specText, + specVersion: tab.specVersion, + editorMode: tab.editorMode, + dirtySql: tab.dirtySql, + dirtySpec: tab.dirtySpec, + savedId: tab.savedId, + ...(tab.lastCommittedQueryToken === undefined ? {} : { lastCommittedQueryToken: tab.lastCommittedQueryToken }), + ...(tab.externalState === undefined ? {} : { externalState: tab.externalState }), + })), + activeTabId: value.activeTabId, + nextTabId: value.nextTabId, + }; +} + +/** Serialize only the documented authored-state wire fields. */ +export function encodeOAuthDocumentRecovery(snapshot: OAuthDocumentRecoverySnapshot): string { + return JSON.stringify(canonicalSnapshot(assertShape(snapshot))); +} + +/** Decode a sessionStorage value without accessing storage or the clock itself. */ +export function decodeOAuthDocumentRecovery( + serialized: string | null | undefined, + now: number, +): OAuthDocumentRecoveryDecodeResult { + if (serialized === null || serialized === undefined) return { kind: 'missing' }; + let parsed: unknown; + try { parsed = JSON.parse(serialized); } catch { return { kind: 'invalid', reason: 'malformed' }; } + const shape = readShape(parsed); + if (shape.kind !== 'valid') return shape; + if (!finiteInteger(now)) return { kind: 'invalid', reason: 'malformed' }; + if (shape.value.createdAt > now || now - shape.value.createdAt > OAUTH_DOCUMENT_RECOVERY_TTL_MS) { + return { kind: 'invalid', reason: 'expired' }; + } + return shape; +} + +/** Serialize only the callback proof's version, OAuth state, and validation time. */ +export function encodeOAuthDocumentRecoveryValidatedCallback( + marker: OAuthDocumentRecoveryValidatedCallback, +): string { + const value = assertValidatedCallbackShape(marker); + return JSON.stringify({ + version: value.version, + oauthState: value.oauthState, + validatedAt: value.validatedAt, + }); +} + +/** + * Strictly decode a same-tab callback proof. Its independent TTL never extends + * the checkpoint TTL; retry callers must validate both records. + */ +export function decodeOAuthDocumentRecoveryValidatedCallback( + serialized: string | null | undefined, + now: number, +): OAuthDocumentRecoveryValidatedCallbackDecodeResult { + if (serialized === null || serialized === undefined) return { kind: 'missing' }; + let parsed: unknown; + try { parsed = JSON.parse(serialized); } catch { return { kind: 'invalid', reason: 'malformed' }; } + const shape = readValidatedCallbackShape(parsed); + if (shape.kind !== 'valid') return shape; + if (!finiteInteger(now)) return { kind: 'invalid', reason: 'malformed' }; + if (shape.value.validatedAt > now + || now - shape.value.validatedAt > OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_TTL_MS) { + return { kind: 'invalid', reason: 'expired' }; + } + return shape; +} + +/** Reuse authored payload across an OAuth retry, changing only its attempt binding. */ +export function rebindOAuthDocumentRecovery( + snapshot: OAuthDocumentRecoverySnapshot, + oauthState: string, + createdAt: number, +): OAuthDocumentRecoverySnapshot { + const value = assertShape(snapshot); + if (!nonBlank(oauthState) || !finiteInteger(createdAt)) throw new TypeError('Invalid OAuth recovery rebind'); + return { ...canonicalSnapshot(value), oauthState, createdAt }; +} diff --git a/src/main.ts b/src/main.ts index 5251678..74e0e54 100644 --- a/src/main.ts +++ b/src/main.ts @@ -17,11 +17,9 @@ import { createCodeViewer } from './editor/code-viewer.js'; import { handleKeydown } from './ui/shortcuts.js'; import { exchangeCodeForTokens, bearerFromTokens } from './net/oauth.js'; import { decodeShare } from './core/share.js'; -import { cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery } from './core/saved-query.js'; +import { queryPanel } from './core/saved-query.js'; import { normalizeSqlRouteSearch, parseSqlRoute } from './core/sql-route.js'; -import { isQuerylessPanel } from './core/panel-cfg.js'; -import { setTabSpecDraft, SAVED_VIEWS } from './state.js'; -import type { State } from './ui/app.types.js'; +import type { OAuthDocumentRecoveryApplyResult } from './ui/app.types.js'; import type { BootstrapEnv } from './env.types.js'; import type { ConnectionSession } from './application/connection-session.js'; import type { SpecEditorApp } from './editor/spec-editor.js'; @@ -36,7 +34,6 @@ import type { ShortcutKeydownEvent } from './ui/shortcuts.js'; * the flat `App` delegates were deleted; `loadConfig` is now `resolveConfig`, * its real name on `ConnectionSession`). */ export interface BootstrapApp { - state: Pick; catalog: { loadVersion(): Promise }; conn: Pick; @@ -56,10 +53,24 @@ export interface BootstrapApp { * return value is never read here (`Promise` is enough for * `bootstrap`'s own purposes). */ loadWorkspaceOnBoot(): Promise; + /** Restore an OAuth-state-bound document session after the committed + * workspace has loaded. The application owns validation and recovery-key + * lifecycle; bootstrap only decides whether legacy share seeding still runs. + */ + restoreOAuthDocumentRecovery(callbackState: string): OAuthDocumentRecoveryApplyResult; + /** Retry a callback-marked pending recovery after an authoritative workspace + * load. A plain checkpoint without that marker returns a non-restored result. */ + retryPendingOAuthDocumentRecovery(): OAuthDocumentRecoveryApplyResult; + /** Consume the one-shot shared-link handoff after workspace load. A restored + * OAuth document passes false so shared content is discarded, never merged. */ + consumeLegacyShared(allowRestore: boolean, consumedHandoff: string | null): boolean; } -/** `app.state.resultView`'s value union, reused at the one cast below. */ -type ResultView = State['resultView']['value']; +const recoveryOwnsLegacyShare = ( + recovery: OAuthDocumentRecoveryApplyResult | null, +): boolean => recovery?.kind === 'restored' + || recovery?.kind === 'retry-deferred-retained' + || recovery?.kind === 'workspace-unavailable-retained'; export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ callbackError: string | null; signedIn: boolean }> { const loc = env.location; @@ -85,6 +96,10 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ const expectedState = ss.getItem('oauth_state'); const errorParam = u.searchParams.get('error'); let callbackError: string | null = null; + // Recovery is bound to a fully completed callback, not merely a matching + // state parameter. In particular, a failed callback must leave a retained + // checkpoint retryable and must not ask the application to consume it. + let successfulCallbackState: string | null = null; if (errorParam) { // The IdP bounced back with an error (e.g. ?error=access_denied) instead of @@ -108,6 +123,7 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ const bearer = bearerFromTokens(tokens, cfg.bearer); if (!bearer) throw new Error('Token response missing bearer token'); app.conn.setTokens(bearer, tokens.refresh_token); + successfulCallbackState = stateParam; } catch (e) { callbackError = 'OAuth token exchange failed: ' + ((e instanceof Error && e.message) || e); } @@ -152,62 +168,46 @@ export async function bootstrap(app: BootstrapApp, env: BootstrapEnv): Promise<{ // The dashboard route has no editor tab to seed, so it skips this entirely. // Gates are `sql || panel` (#166): a text panel legitimately has no SQL, so // a sql-only check would silently drop its share link. - if (!dash) { - let shared = decodeShare(loc.hash); - if (shared.sql || queryPanel(shared)) ss.setItem('oauth_shared', JSON.stringify(shared)); - else { - // The stash is a second deserialization point that bypasses decodeShare — - // it may hold a pre-#166 `{sql, chart}` stash, so upgrade applies here too. - try { - // `JSON.parse`'s own return is untyped; the ingress shape here is the - // same loosely-checked `Record` saved-query.js's own - // helpers (isPlainObject et al.) treat arbitrary stored JSON as. - const raw = (JSON.parse(ss.getItem('oauth_shared') || 'null') || { sql: '' }) as Record; - shared = upgradeSavedQuery(raw.specVersion == null - ? { name: 'Shared query', ...raw } - : raw); - } catch { shared = decodeShare(''); } - } - const panel = queryPanel(shared); - if (shared.sql || panel) { - const t0 = app.state.tabs.value[0]; - t0.sqlDraft = shared.sql; - t0.name = queryName(shared); - t0.specVersion = shared.specVersion; - setTabSpecDraft(t0, cloneJson(shared.spec)); - // Restore the initial result view the share carries, regardless of whether - // it also carries SQL to run — a SQL-bearing share never auto-runs here, - // so this only pre-selects the drawer the recipient lands on before they - // click Run. (#447 dropped the role-owned transient preview that used to - // take precedence here: the Filter role was its only owner.) - const launchView = queryView(shared); - // Normalize a legacy `view: 'chart'` (pre-Panel shares) to 'panel', then - // validate against the resultView union before assigning (#266): the v2 - // tagged decode passes `spec.view` through verbatim, so a crafted share - // link could otherwise set `resultView` to an arbitrary string. Mirrors - // ui/saved-history.ts's own `SAVED_VIEWS.has(...)` guard — a persisted - // table/json/panel restores, and any other value silently falls back to - // the default view. `as`: that check is the runtime proof `normalized` is - // a resultView member. - const normalized = launchView === 'chart' ? 'panel' : launchView; - if (SAVED_VIEWS.has(normalized ?? '')) app.state.resultView.value = normalized as ResultView; - // A queryless panel with no role/persisted view (no SQL to run) still - // needs the Panel drawer open, or the recipient lands on an empty Table - // view and sees nothing. - else if (!shared.sql && isQuerylessPanel(panel)) app.state.resultView.value = 'panel'; - hist.replaceState(null, '', loc.pathname + loc.search); - } + const sharedFromHash = !dash ? decodeShare(loc.hash) : null; + if (sharedFromHash && (sharedFromHash.sql || queryPanel(sharedFromHash))) { + ss.setItem('oauth_shared', JSON.stringify(sharedFromHash)); } if (app.conn.isSignedIn()) { // Signed in either via a valid OAuth token or a restored basic session. - ss.removeItem('oauth_shared'); // consumed // Resolve config first so the header shows the real CH identity (the // ch_auth=basic username, not the raw email claim) on first paint. // (ensureConfig is a no-op in basic mode.) await app.conn.ensureConfig(); app.resumeAuthenticatedExecution(); - await app.loadWorkspaceOnBoot(); + const workspace = await app.loadWorkspaceOnBoot(); + // A successful OAuth callback may restore its authored document session + // now that the committed workspace projection is ready. A valid recovery + // wins over (and deliberately never merges with) a legacy shared query. + const recovery = successfulCallbackState === null + ? (workspace ? app.retryPendingOAuthDocumentRecovery() : null) + : app.restoreOAuthDocumentRecovery(successfulCallbackState); + // Both paths consume the legacy handoff exactly once. Any application-level + // restored outcome (including retained finalization warnings), and either + // retained recovery-authority outcome, suppress shared content. Retained + // recovery remains unpublished, so the normal available workspace renders + // while its checkpoint is preserved for a later retry. + let legacyShared: string | null = null; + let legacySharedTaken = false; + try { + legacyShared = ss.getItem('oauth_shared'); + ss.removeItem('oauth_shared'); + legacySharedTaken = true; + } catch { + // Storage cleanup is independent finalization. In particular, a storage + // backend that also rejected recovery-checkpoint removal must not hide + // already-published tabs. Without a confirmed one-shot take, suppress + // rather than apply the legacy handoff. + } + app.consumeLegacyShared( + !recoveryOwnsLegacyShare(recovery) && legacySharedTaken, + legacyShared, + ); app.renderCurrentSurface(); void app.catalog.loadVersion(); } else { diff --git a/src/styles.css b/src/styles.css index 48c2664..6465de2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -482,12 +482,6 @@ h1, h2, h3, h4, h5, h6 { margin-top: 7px; font-size: var(--text-label); color: var(--fg-faint); } .login-sso-note .mono { color: var(--fg-mute); } -.login-inline-oauth-unavailable { - padding: 10px 12px; - background: var(--warn-bg); border: 1px solid var(--warn-bd); - border-radius: var(--r-md); color: var(--warn-fg); - font-size: var(--text-body); line-height: var(--lh-body); -} /* Buttons (primary / ghost) */ .login-btn { diff --git a/src/ui/app.ts b/src/ui/app.ts index 07ce248..cf1c7c2 100644 --- a/src/ui/app.ts +++ b/src/ui/app.ts @@ -10,7 +10,7 @@ import { createState, activeTab, savedForTab, tabPanel, tabSaveDirty, variableDoc, normalizeRowLimit, detachWorkspaceBoundTabs, reconcileTabsWithSavedQueries, - adoptSavedIntoTab, reconcileLinkedTabsToLatest, + adoptSavedIntoTab, reconcileLinkedTabsToLatest, setTabSpecDraft, SAVED_VIEWS, } from '../state.js'; import type { QueryTab, AppState, SpecValidationService } from '../state.js'; import { @@ -31,6 +31,9 @@ import { import type { SpecValidatorEntry, QuerySpecValidationService } from '../core/spec-draft.js'; import type { SpecDiagnostic } from '../editor/spec-editor.types.js'; import { isQuerylessPanel } from '../core/panel-cfg.js'; +import { + cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery, +} from '../core/saved-query.js'; import * as ch from '../net/ch-client.js'; import { createNoopPort } from '../editor/editor-port.js'; import type { EditorPort } from '../editor/editor-port.types.js'; @@ -70,10 +73,17 @@ import type { InlineLoginHandle } from './login.js'; import { openShortcuts, resetShortcutChord } from './shortcuts.js'; import { startDrag } from './splitters.js'; import { flashToast } from './toast.js'; -import type { App, ActionsRegistry, KeyboardOwner, SchemaFocus, WorkspaceChangedMessage } from './app.types.js'; +import type { + App, ActionsRegistry, KeyboardOwner, OAuthDocumentRecoveryApplyResult, + SchemaFocus, WorkspaceChangedMessage, +} from './app.types.js'; import type { CreateAppEnv, BroadcastChannelPort } from '../env.types.js'; import { createQueryExecutionService } from '../application/query-execution-service.js'; import { createConnectionSession } from '../application/connection-session.js'; +import { + createOAuthDocumentRecoverySession, + type OAuthDocumentRecoveryRestoreResult, +} from '../application/oauth-document-recovery-session.js'; import { createAuthenticatedExecutionScope, type AuthenticatedExecutionScope, @@ -149,6 +159,12 @@ interface WindowExtras { * `registerSpecValidator` action) that other modules never call directly. */ type AppSpecValidators = QuerySpecValidationService; +const recoveryOwnsLegacyShare = ( + recovery: OAuthDocumentRecoveryApplyResult | null, +): boolean => recovery?.kind === 'restored' + || recovery?.kind === 'retry-deferred-retained' + || recovery?.kind === 'workspace-unavailable-retained'; + export function createApp(env: CreateAppEnv = {}): App { const doc = env.document || document; const win = (env.window || window) as Window & WindowExtras; @@ -399,6 +415,156 @@ export function createApp(env: CreateAppEnv = {}): App { }, }); app.queryDoc = queryDoc; + // The persisted OAuth checkpoint is deliberately below this shell: it can + // replace authored tab state, but does not know how the mounted document + // service rebuilds parsed Spec/diagnostic transients or owns the dirty-page + // guard. Keep the coordinator private; bootstrap receives only this + // transaction-shaped restore entry point. + const oauthDocumentRecovery = createOAuthDocumentRecoverySession({ + storage: ss, + now: wallNow, + state: app.state, + specValidators, + }); + const finalizeOAuthDocumentRecovery = ( + restored: OAuthDocumentRecoveryRestoreResult, + ): OAuthDocumentRecoveryApplyResult => { + if (restored.kind !== 'restored') return restored; + // Publication is the commit point: from here on bootstrap must render these + // tabs even if parser or storage finalization fails. Arm the ordinary dirty + // guard first so the authored document is protected on every exit path. + app.syncBeforeUnload(); + // The checkpoint intentionally carries raw authored text, never derived + // parser state. Rebuild every draft's diagnostics without rendering before + // bootstrap's first signed-in surface. A failure retains the checkpoint + // and surfaces a safe warning, but cannot make the published tabs + // unreachable or allow legacy shared content to replace them. + try { + queryDoc.revalidateSpecDrafts({ refreshUi: false }); + } catch { + flashToast( + 'Drafts were restored, but Spec validation is temporarily unavailable. The recovery copy was retained.', + { document: doc }, + ); + return { + kind: 'restored', + finalization: 'checkpoint-retained', + warning: 'spec-revalidation-failed', + }; + } + try { + oauthDocumentRecovery.consume(); + } catch { + flashToast( + 'Drafts were restored, but recovery cleanup could not finish. The recovery copy was retained.', + { document: doc }, + ); + return { + kind: 'restored', + finalization: 'checkpoint-retained', + warning: 'checkpoint-remove-failed', + }; + } + return { kind: 'restored', finalization: 'complete' }; + }; + let deferredRecoveryWarningShown = false; + const deferOAuthDocumentRecovery = (): OAuthDocumentRecoveryApplyResult => { + if (!deferredRecoveryWarningShown) { + deferredRecoveryWarningShown = true; + flashToast( + 'Unsaved drafts remain safely stored. Recovery will retry automatically.', + { document: doc }, + ); + } + return { kind: 'retry-deferred-retained' }; + }; + app.restoreOAuthDocumentRecovery = (callbackState: string): OAuthDocumentRecoveryApplyResult => { + // A fresh validated callback starts a new authority decision; a later + // deferred retry deserves its own single safe notice. + deferredRecoveryWarningShown = false; + try { + const restored = oauthDocumentRecovery.restore(callbackState, app.currentWorkspace); + if (restored.kind === 'retry-deferred-retained') { + return deferOAuthDocumentRecovery(); + } + return finalizeOAuthDocumentRecovery(restored); + } catch { + // The session normally converts storage failures into explicit retained + // outcomes. Keep this boundary defensive: an unexpected pre-publication + // failure must not abort the signed-in shell or expose backend details. + return deferOAuthDocumentRecovery(); + } + }; + app.retryPendingOAuthDocumentRecovery = (): OAuthDocumentRecoveryApplyResult => { + let pending: OAuthDocumentRecoveryRestoreResult; + try { + pending = oauthDocumentRecovery.retryPending(app.currentWorkspace); + } catch { + return deferOAuthDocumentRecovery(); + } + if (pending.kind === 'retry-deferred-retained') { + // Nothing was published: do not arm the dirty guard, revalidate, consume, + // or replace the current workspace. The retained recovery nevertheless + // owns callback precedence, so callers discard the legacy share handoff. + return deferOAuthDocumentRecovery(); + } + deferredRecoveryWarningShown = false; + return finalizeOAuthDocumentRecovery(pending); + }; + app.consumeLegacyShared = (allowRestore: boolean, consumedHandoff?: string | null): boolean => { + let encoded: string | null; + try { + encoded = consumedHandoff === undefined + ? ss.getItem('oauth_shared') + : consumedHandoff; + } catch { + return false; + } + if (encoded === null) return false; + // In-page Basic login owns the storage handoff here. Bootstrap passes its + // already-consumed value so the same parser/application path is reused. + if (consumedHandoff === undefined) { + try { + ss.removeItem('oauth_shared'); + } catch { + // Handoff cleanup is best-effort. Recovery precedence still suppresses + // the payload, and a storage backend failure must not abort rendering. + } + } + // The handoff is one-shot regardless of whether recovery suppresses it, + // its payload is malformed, or the current route has no Query surface. + if (!allowRestore || app.sqlRoute.surface !== 'workspace') return false; + + let shared; + try { + const raw = JSON.parse(encoded) as Record; + // Pre-#166 OAuth handoffs stored `{sql, chart}` directly; the normal + // upgrader preserves that compatibility while current v2 payloads pass + // through with their authored Spec intact. + shared = upgradeSavedQuery(raw.specVersion == null + ? { name: 'Shared query', ...raw } + : raw); + } catch { + return false; + } + const panel = queryPanel(shared); + if (!shared.sql && !panel) return false; + + const tab = app.state.tabs.value[0]; + tab.sqlDraft = shared.sql; + tab.name = queryName(shared); + tab.specVersion = shared.specVersion; + setTabSpecDraft(tab, cloneJson(shared.spec)); + const launchView = queryView(shared); + const normalized = launchView === 'chart' ? 'panel' : launchView; + if (SAVED_VIEWS.has(normalized ?? '')) { + app.state.resultView.value = normalized as App['state']['resultView']['value']; + } else if (!shared.sql && isQuerylessPanel(panel)) { + app.state.resultView.value = 'panel'; + } + win.history.replaceState(null, '', loc.pathname + routeSearch); + return true; + }; // The saved-query create/commit policy, history recording, and share-URL // building (#276 Phase 4C) now live in `application/saved-query-service.ts`, // constructible without App/AppState/DOM — this shell sequences Spec @@ -489,6 +655,10 @@ export function createApp(env: CreateAppEnv = {}): App { // constructible without App/AppState/DOM; this module wires it to the real // browser env and to `renderLoginApp` (the one piece that IS this shell's // job — the session only ever calls `onAuthLost`, never renders). + // Assigned below beside the single beforeunload listener. ConnectionSession + // invokes this only after createApp has completed, so this closure can keep + // its lifecycle wiring near the listener it controls. + let armOAuthRedirectUnloadBypass: () => () => void; const conn = createConnectionSession({ fetch: fetchFn, storage: ss, location: loc, crypto: cryptoObj, queryJson: ch.queryJson, @@ -498,6 +668,9 @@ export function createApp(env: CreateAppEnv = {}): App { closing?.close(lease); revealAuthenticationRequired(detail); }, + prepareOAuthRedirect: (state) => oauthDocumentRecovery.prepare(state), + clearOAuthDocumentRecovery: () => oauthDocumentRecovery.clear(), + armOAuthRedirectUnloadBypass: () => armOAuthRedirectUnloadBypass(), }); app.conn = conn; app.executionScope = () => activeExecutionScope; @@ -2347,10 +2520,27 @@ export function createApp(env: CreateAppEnv = {}): App { // triggers a browser-generated confirmation dialog") — its own default is // the empty string, so assigning that back would be a no-op for the legacy // UAs that key off it rather than `preventDefault()`. + // A successful OAuth checkpoint authorizes precisely one intentional + // navigation. The listener remains attached (so all ordinary unloads retain + // their warning); ownership tokens ensure an older failed redirect cannot + // disarm a newer arm. + let nextUnloadBypassGeneration = 0; + let armedUnloadBypassGeneration: number | null = null; const beforeUnload = (e: BeforeUnloadEvent): void => { + if (armedUnloadBypassGeneration !== null) { + armedUnloadBypassGeneration = null; + return; + } e.preventDefault(); e.returnValue = true; }; + armOAuthRedirectUnloadBypass = (): (() => void) => { + const generation = ++nextUnloadBypassGeneration; + armedUnloadBypassGeneration = generation; + return () => { + if (armedUnloadBypassGeneration === generation) armedUnloadBypassGeneration = null; + }; + }; let beforeUnloadInstalled = false; const canToggleBeforeUnload = typeof win.addEventListener === 'function' && typeof win.removeEventListener === 'function'; @@ -2395,15 +2585,18 @@ export function createApp(env: CreateAppEnv = {}): App { }; const resetCorruptWorkspace = async (id: string): Promise => { + const expectedGeneration = routeLoadGeneration; const deleted = await app.workspace.delete(id); if (!deleted.ok) return; const result = await resolveImplicitOrProvision(); - if (result.status === 'ok') { + if (result.status === 'ok' && routeLoadGeneration === expectedGeneration) { applyCommittedWorkspace(result.workspace); await recordOpened(result.workspace); + if (routeLoadGeneration !== expectedGeneration) return; app.sqlRoute = routeForWorkspace(app.sqlRoute, result.workspace.key); routeSearch = buildSqlRouteSearch(app.sqlRoute, routeSearch); win.history.replaceState(null, '', conn.basePath + routeSearch + (loc.hash || '')); + app.retryPendingOAuthDocumentRecovery(); app.renderCurrentSurface(); } }; @@ -2496,15 +2689,20 @@ export function createApp(env: CreateAppEnv = {}): App { app.closeShortcutDialog(); resetShortcutChord(app); const workspaceChanged = route.workspaceKey !== app.sqlRoute.workspaceKey; + const needsWorkspaceLoad = workspaceChanged || app.currentWorkspace === null; writeRoute(route, method); - if (workspaceChanged) { + if (needsWorkspaceLoad) { app.workspaceRouteStatus = 'loading'; app.currentWorkspace = null; renderWorkspaceLoading(); const expectedGeneration = routeLoadGeneration + 1; - await app.loadWorkspaceOnBoot(); + const workspace = await app.loadWorkspaceOnBoot(); if (routeLoadGeneration !== expectedGeneration) return; - } else adoptRouteMainSurface(); + if (workspace) app.retryPendingOAuthDocumentRecovery(); + } else { + adoptRouteMainSurface(); + if (app.currentWorkspace) app.retryPendingOAuthDocumentRecovery(); + } app.renderCurrentSurface(); }; @@ -2514,13 +2712,14 @@ export function createApp(env: CreateAppEnv = {}): App { const previousKey = app.sqlRoute.workspaceKey; routeSearch = loc.search; app.sqlRoute = parseSqlRoute(routeSearch); - if (app.sqlRoute.workspaceKey === previousKey) { + if (app.sqlRoute.workspaceKey === previousKey && app.currentWorkspace !== null) { // #425: Back/Forward between surfaces of the SAME workspace is a surface // transition, not a teardown — the shell and the query column stay mounted // so the editor state survives it. (It used to run `disposeCurrentSurface`, // whose blanket control-disable would now inert the still-mounted editor // toolbar, tabs, and sidebar inputs permanently.) adoptRouteMainSurface(); + if (app.currentWorkspace) app.retryPendingOAuthDocumentRecovery(); app.renderCurrentSurface(); return; } @@ -2528,8 +2727,9 @@ export function createApp(env: CreateAppEnv = {}): App { app.currentWorkspace = null; renderWorkspaceLoading(); const expectedGeneration = routeLoadGeneration + 1; - await app.loadWorkspaceOnBoot(); + const workspace = await app.loadWorkspaceOnBoot(); if (routeLoadGeneration !== expectedGeneration) return; + if (workspace) app.retryPendingOAuthDocumentRecovery(); app.renderCurrentSurface(); }; app.syncSqlRoute = (search) => { @@ -2819,7 +3019,13 @@ export function createApp(env: CreateAppEnv = {}): App { void catalog.loadVersion(); return; } - await app.loadWorkspaceOnBoot(); + const workspace = await app.loadWorkspaceOnBoot(); + const pendingRecovery = workspace + ? app.retryPendingOAuthDocumentRecovery() + : null; + app.consumeLegacyShared( + !recoveryOwnsLegacyShare(pendingRecovery), + ); app.renderCurrentSurface(); void app.catalog.loadVersion(); }, diff --git a/src/ui/app.types.ts b/src/ui/app.types.ts index 2cabeb9..2477e9a 100644 --- a/src/ui/app.types.ts +++ b/src/ui/app.types.ts @@ -37,6 +37,7 @@ import type { WorkbenchParameterSession } from '../application/workbench-paramet import type { ExportService } from '../application/export-service.js'; import type { QueryDocumentSession } from '../application/query-document-session.js'; import type { SavedQueryService } from '../application/saved-query-service.js'; +import type { OAuthDocumentRecoveryRestoreResult } from '../application/oauth-document-recovery-session.js'; export type { QueryTab as Tab, AppState as State } from '../state.js'; // #457: the `mutateWorkspace` contract types are DECLARED in `state.ts`, beside @@ -52,6 +53,21 @@ export type { type Json = Record; +/** Application-shell recovery outcome. `kind: 'restored'` means bootstrap must + * render the published tabs; `kind: 'retry-deferred-retained'` means the normal + * workspace remains visible while validated recovery authority is retained. + * Both suppress legacy shared content. A retained finalization warning means + * the authored document is live and guarded, but its checkpoint remains + * available because revalidation or storage cleanup could not complete. */ +export type OAuthDocumentRecoveryApplyResult = + | Exclude + | { kind: 'restored'; finalization: 'complete' } + | { + kind: 'restored'; + finalization: 'checkpoint-retained'; + warning: 'spec-revalidation-failed' | 'checkpoint-remove-failed'; + }; + /** The cross-tab invalidation signal (#343 §5) — a small "reload the record" * poke, never the workspace body. `sourceTabId` lets a tab ignore its OWN * broadcast; `workspaceId` scopes it to a specific aggregate. */ @@ -459,6 +475,26 @@ export interface App { * reactive effect (`workbench-shell.ts`) and `actions.rerenderTabs`; see * `createApp`'s own definition for why both are needed. */ syncBeforeUnload(): void; + /** Restore a callback-state-bound OAuth document checkpoint into the + * already-loaded authoritative workspace. The shell revalidates raw Spec + * drafts, reinstalls the ordinary dirty guard, then consumes only a fully + * restored checkpoint. Once tabs have been published, finalization failures + * remain a restored outcome so bootstrap cannot hide them behind a shared + * placeholder or abort first render. */ + restoreOAuthDocumentRecovery(callbackState: string): OAuthDocumentRecoveryApplyResult; + /** Retry only a recovery explicitly marked pending by a successful callback + * whose authoritative workspace was temporarily unavailable. Ordinary + * checkpoints without that marker remain inert. An unsafe authority-retire + * attempt stays unpublished, retains recovery data, and surfaces one safe + * retry notice through the application shell. */ + retryPendingOAuthDocumentRecovery(): OAuthDocumentRecoveryApplyResult; + /** Consume the one-shot legacy `oauth_shared` handoff. When `allowRestore` + * is false (a recovered OAuth document won precedence), discard it without + * applying; otherwise seed the loaded Query workspace before first render. + * Bootstrap may pass the already-consumed serialized value; in-page Basic + * login omits it and lets the app take its own sessionStorage handoff. + * Returns whether authored shared content was applied. */ + consumeLegacyShared(allowRestore: boolean, consumedHandoff?: string | null): boolean; /** #425 — which main work surface owns the right-hand work area, and, for a * Dashboard, WHICH stored Dashboard is selected in which presentation mode, * plus (#426) the member currently navigated to inside it and any focus diff --git a/src/ui/login.ts b/src/ui/login.ts index 2c072c7..c640e3e 100644 --- a/src/ui/login.ts +++ b/src/ui/login.ts @@ -126,6 +126,7 @@ function mountLoginControls( : (app.conn.hostHint || ''); let advOpen = !!hostHint; let ssoBtns: HTMLButtonElement[] = []; + const ssoBtnLabels = new WeakMap(); // A username is enough to connect — the password is optional, since passwordless // users are common on demo/playground clusters (e.g. ClickHouse `play`). An empty @@ -198,7 +199,6 @@ function mountLoginControls( // --- saved-connection picker (populated async; shown only when config lists hosts) --- let pickHosts: HostDescriptor[] = []; - let inlineOAuthConfigured = false; const hostPicker = h('select', { class: 'login-picker mono', onchange: onPickHost }); // Shown only for an `insecure` (accept-invalid-certificate) connection — the // browser can't be reached until its cert is trusted (see showCertWarn). @@ -277,23 +277,11 @@ function mountLoginControls( // targets that host's origin); don't also offer it as a serving-host SSO button — // that would query the serving origin (e.g. localhost), not the chosen cluster. const standalone = (idps || []).filter((i) => !pickHosts.some((hh) => hh.auth === 'oauth' && hh.idp === i.id)); - // Phase 2 keeps the document shell mounted during recovery, but does not - // yet have the redirect-resume checkpoint needed to make an OAuth round - // trip safe. Full-screen login retains the normal OAuth controls. - if (mode === 'inline') { - if (standalone.length || inlineOAuthConfigured) { - ssoSection.replaceChildren(h('div', { - class: 'login-inline-oauth-unavailable', - role: 'status', - 'aria-live': 'polite', - }, 'Single sign-on is temporarily unavailable while this session is paused. Your work remains open.')); - } - return; - } if (!standalone.length) return; const mk = (idpId: string, label: string): HTMLButtonElement => { const b = h('button', { class: 'login-btn btn-primary', onclick: () => doSso(idpId, b, label) }, Icon.shield(), h('span', null, label)); + ssoBtnLabels.set(b, label); ssoBtns.push(b); return b; }; @@ -309,10 +297,9 @@ function mountLoginControls( // Fill the picker from config.json's `hosts` (npm run local supplies them from // ~/.clickhouse-client/config.xml). Hidden when none are configured. function populateHosts(hosts: HostDescriptor[] | undefined): void { - // OAuth saved connections also redirect away from the mounted document. - // Only credential targets are safe to offer in Phase 2 inline recovery. - inlineOAuthConfigured = mode === 'inline' && (hosts || []).some((hh) => hh.auth === 'oauth'); - pickHosts = (hosts || []).filter((hh) => mode === 'full' || hh.auth === 'basic'); + // OAuth recovery now creates a durable checkpoint before navigation, so + // in-shell authentication has the same saved-host choices as full login. + pickHosts = hosts || []; if (!pickHosts.length) return; hostPicker.replaceChildren( h('option', { value: '' }, 'Choose a connection…'), @@ -440,6 +427,12 @@ function mountLoginControls( busy = null; connectBtn.replaceChildren(h('span', null, 'Connect'), Icon.arrow()); hostPicker.disabled = false; + for (const button of ssoBtns) { + const label = ssoBtnLabels.get(button); + if (label) button.replaceChildren(Icon.shield(), h('span', null, label)); + } + const certGo = certWarn.querySelector('.login-cert-go'); + if (certGo) certGo.disabled = false; update(); } diff --git a/src/ui/workbench/workbench-shell.ts b/src/ui/workbench/workbench-shell.ts index 5b569b5..19c2bad 100644 --- a/src/ui/workbench/workbench-shell.ts +++ b/src/ui/workbench/workbench-shell.ts @@ -239,7 +239,17 @@ export function mountWorkbenchShell(deps: WorkbenchShellDeps): () => void { disposers.push(effect(() => { state.tabs.value; state.activeTabId.value; - queryDoc.revalidateSpecDrafts({ refreshUi: false }); + // Revalidation only rebuilds derived Spec state; authored tabs may already + // have been published by OAuth recovery. A validator outage must not abort + // this effect before the tab strip/editors mount and make those drafts + // reachable. Do not retry here or expose the raw validator error: the + // recovery boundary has already retained its checkpoint and reported the + // safe user-facing warning. + try { + queryDoc.revalidateSpecDrafts({ refreshUi: false }); + } catch { + // Keep the last safe derived state and continue rendering authored data. + } renderTabs(app); // #466/#501-review: a new/closed/switched tab changes the `tabs` SIGNAL // itself, which this effect reacts to — so this is the one place that diff --git a/tests/e2e/oauth-document-recovery.spec.js b/tests/e2e/oauth-document-recovery.spec.js new file mode 100644 index 0000000..6be2975 --- /dev/null +++ b/tests/e2e/oauth-document-recovery.spec.js @@ -0,0 +1,162 @@ +import { test, expect } from '@playwright/test'; + +const fixturePath = '/tests/e2e/oauth-document-recovery/index.html'; +const fixtureChPath = '/tests/e2e/oauth-document-recovery/ch'; +const checkpointKey = 'oauth_document_recovery'; +const markerKey = 'oauth_document_recovery_validated_callback'; + +// This is intentionally Chromium-only: native beforeunload prompt delivery is +// browser-policy-sensitive, while the application-level recovery transaction is +// already covered across the unit suite. Here a real browser proves the one-shot +// OAuth navigation bypass does not weaken the next ordinary reload warning. +test.describe('OAuth document recovery redirect (#512)', () => { + test('restores dirty authored documents through a real 401 and OIDC redirect without restoring transients', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Chromium-only native beforeunload dialog semantics'); + const dialogs = []; + let acceptUnexpectedDialog = true; + page.on('dialog', async (dialog) => { + dialogs.push(dialog.type()); + // A prompt here during the OAuth redirect is the regression; accepting + // keeps the test able to report the complete observed dialog sequence. + if (acceptUnexpectedDialog) await dialog.accept(); + }); + + await page.goto(fixturePath); + await page.waitForFunction(() => window.__oauthRecoveryReady === true); + expect(await page.evaluate(() => window.__oauthRecoveryInitialDirtyGuard)).toBe(true); + const authLossResponse = page.waitForResponse((response) => ( + new URL(response.url()).pathname === fixtureChPath + && response.status() === 401 + )); + await page.evaluate(() => window.__oauthRecoveryTrigger401()); + await authLossResponse; + const sso = page.locator('.login-inline .login-sso .login-btn'); + await expect(sso).toHaveText('Continue with Fixture SSO'); + + // The authorize route really redirects away then returns `?code&state` to + // this page. Clear the old document's latch before clicking so this wait is + // necessarily satisfied by the callback document, not the initial boot. + await page.evaluate(() => { window.__oauthRecoveryReady = false; }); + // bootstrap synchronously removes callback query parameters before the + // document's `load` event, so observe the main-frame navigation itself + // rather than a load-state URL that is deliberately already cleaned. + const callback = page.waitForEvent('framenavigated', (frame) => ( + frame === page.mainFrame() && frame.url().includes('code=fixture-code') + )); + await sso.click(); + await callback; + await page.waitForFunction(() => window.__oauthRecoveryReady === true); + + expect(dialogs).not.toContain('beforeunload'); + const restored = await page.evaluate(() => window.__oauthRecoveryState()); + const firstRender = await page.evaluate(() => window.__oauthRecoveryFirstRender); + const renderCount = await page.evaluate(() => window.__oauthRecoveryRenderCount()); + expect(renderCount).toBe(1); + expect(firstRender).toEqual(restored); // bootstrap restored before first signed-in render + expect(restored).toHaveLength(2); + expect(restored[0]).toMatchObject({ + id: 't1', savedId: 'saved-query', sqlDraft: 'SELECT dirty saved query', + specText: '{ invalid JSON', dirtySql: true, dirtySpec: true, editorMode: 'spec', + specParsed: null, result: null, columns: [], + }); + expect(restored[0].diagnostics.length).toBeGreaterThan(0); // invalid raw Spec was revalidated + expect(restored[0].chSession).toBeUndefined(); + expect(restored[1]).toMatchObject({ + id: 't2', doc: { kind: 'dashboard-variable', dashboardId: 'dash-1', variableName: 'region' }, + sqlDraft: "SELECT 'dirty variable'", dirtySql: true, + }); + expect(await page.evaluate((key) => sessionStorage.getItem(key), checkpointKey)).toBeNull(); + + // The checkpoint bypass is one-shot. The restored dirty tabs must still + // protect an unrelated reload with the browser's native warning. + acceptUnexpectedDialog = false; + const reloadDialog = page.waitForEvent('dialog'); + const reload = page.reload(); + const dialog = await reloadDialog; + expect(dialog.type()).toBe('beforeunload'); + await dialog.accept(); + await reload; + }); + + test('retries a callback-marked checkpoint after the matching workspace becomes available on reload', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Chromium-only OAuth redirect recovery scenario'); + const dialogs = []; + page.on('dialog', async (dialog) => { + dialogs.push(dialog.type()); + await dialog.accept(); + }); + + await page.goto(`${fixturePath}?scenario=pending`); + await page.waitForFunction(() => window.__oauthRecoveryReady === true); + expect(await page.evaluate(() => window.__oauthRecoveryInitialDirtyGuard)).toBe(true); + + const authLossResponse = page.waitForResponse((response) => ( + new URL(response.url()).pathname === fixtureChPath && response.status() === 401 + )); + await page.evaluate(() => window.__oauthRecoveryTrigger401()); + await authLossResponse; + const sso = page.locator('.login-inline .login-sso .login-btn'); + await expect(sso).toHaveText('Continue with Fixture SSO'); + + await page.evaluate(() => { window.__oauthRecoveryReady = false; }); + const callback = page.waitForEvent('framenavigated', (frame) => ( + frame === page.mainFrame() && frame.url().includes('code=fixture-code') + )); + await sso.click(); + await callback; + await page.waitForFunction(() => window.__oauthRecoveryReady === true); + + expect(dialogs).not.toContain('beforeunload'); + expect(await page.evaluate(() => window.__oauthRecoveryWorkspaceKey())).toBeNull(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), checkpointKey)).not.toBeNull(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), markerKey)).not.toBeNull(); + expect(await page.evaluate(() => window.__oauthRecoveryState())) + .not.toEqual(expect.arrayContaining([ + expect.objectContaining({ sqlDraft: 'SELECT dirty saved query' }), + ])); + + // Keep the same authenticated tab, but reload it on the now-authoritative + // route. There is no callback this time: only the persisted validated marker + // may authorize the pending recovery before the first render. + await page.evaluate((path) => { + window.__oauthRecoveryReady = false; + history.replaceState(null, '', `${path}?scenario=pending&ws=recovery-workspace`); + }, fixturePath); + await page.reload(); + await page.waitForFunction(() => window.__oauthRecoveryReady === true); + + expect(await page.evaluate(() => window.__oauthRecoveryWorkspaceKey())).toBe('recovery-workspace'); + const restored = await page.evaluate(() => window.__oauthRecoveryState()); + expect(await page.evaluate(() => window.__oauthRecoveryFirstRender)).toEqual(restored); + expect(await page.evaluate(() => window.__oauthRecoveryRenderCount())).toBe(1); + expect(restored).toEqual(expect.arrayContaining([ + expect.objectContaining({ + id: 't1', sqlDraft: 'SELECT dirty saved query', specText: '{ invalid JSON', + dirtySql: true, dirtySpec: true, + }), + expect.objectContaining({ + id: 't2', doc: { + kind: 'dashboard-variable', dashboardId: 'dash-1', variableName: 'region', + }, + sqlDraft: "SELECT 'dirty variable'", dirtySql: true, + }), + ])); + expect(await page.evaluate((key) => sessionStorage.getItem(key), checkpointKey)).toBeNull(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), markerKey)).toBeNull(); + }); + + test('leaves a valid checkpoint inert on token boot when no callback marker exists', async ({ page, browserName }) => { + test.skip(browserName !== 'chromium', 'Chromium-only recovery fixture'); + await page.goto(`${fixturePath}?scenario=inert`); + await page.waitForFunction(() => window.__oauthRecoveryReady === true); + + const state = await page.evaluate(() => window.__oauthRecoveryState()); + expect(state).not.toEqual(expect.arrayContaining([ + expect.objectContaining({ sqlDraft: 'SELECT inert checkpoint must not restore' }), + ])); + expect(await page.evaluate(() => window.__oauthRecoveryFirstRender)).toEqual(state); + expect(await page.evaluate(() => window.__oauthRecoveryRenderCount())).toBe(1); + expect(await page.evaluate((key) => sessionStorage.getItem(key), checkpointKey)).not.toBeNull(); + expect(await page.evaluate((key) => sessionStorage.getItem(key), markerKey)).toBeNull(); + }); +}); diff --git a/tests/e2e/oauth-document-recovery/index.html b/tests/e2e/oauth-document-recovery/index.html new file mode 100644 index 0000000..6f2d2d0 --- /dev/null +++ b/tests/e2e/oauth-document-recovery/index.html @@ -0,0 +1,228 @@ + + + + + OAuth document recovery (#512) + + + +
+ + + + + diff --git a/tests/helpers/fake-app.ts b/tests/helpers/fake-app.ts index 3297ffb..1553aa6 100644 --- a/tests/helpers/fake-app.ts +++ b/tests/helpers/fake-app.ts @@ -476,6 +476,11 @@ const appDefaults: App = { rewriteWorkspaceRoute: () => {}, renderCurrentSurface: () => {}, syncBeforeUnload: () => {}, + // Recovery itself is covered by its application/session tests. UI fixtures + // need the same benign no-checkpoint result as an ordinary app boot. + restoreOAuthDocumentRecovery: () => ({ kind: 'absent' }), + retryPendingOAuthDocumentRecovery: () => ({ kind: 'absent' }), + consumeLegacyShared: () => false, reloadDashboardRoute: () => {}, loadWorkspaceOnBoot: async () => null, // Inert placeholders — `base` below overrides both with real, state-backed diff --git a/tests/unit/app.test.ts b/tests/unit/app.test.ts index 7a9d754..7186f6c 100644 --- a/tests/unit/app.test.ts +++ b/tests/unit/app.test.ts @@ -3,6 +3,7 @@ import type { Mock } from 'vitest'; import type { Signal } from '@preact/signals-core'; import dagre from '@dagrejs/dagre'; import { createApp } from '../../src/ui/app.js'; +import { bootstrap } from '../../src/main.js'; import { createCodeMirrorEditor } from '../../src/editor/codemirror-adapter.js'; import { createSpecEditor } from '../../src/editor/spec-editor.js'; import type { SpecEditorApp } from '../../src/editor/spec-editor.js'; @@ -20,6 +21,12 @@ import type { DashboardFocusOutcome } from '../../src/ui/shortcuts.js'; import { queryDescription } from '../../src/core/saved-query.js'; import { libraryQueries } from '../../src/dashboard/model/query-ownership.js'; import { createSpecValidatorRegistry } from '../../src/core/spec-draft.js'; +import { + OAUTH_DOCUMENT_RECOVERY_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + OAUTH_DOCUMENT_RECOVERY_VERSION, + encodeOAuthDocumentRecovery, +} from '../../src/core/oauth-document-recovery.js'; import { savedQuery } from '../helpers/saved-query.js'; import { fakeIndexedDbFactory } from '../helpers/fake-idb.js'; import { fakeBroadcastBus } from '../helpers/fake-broadcast.js'; @@ -443,6 +450,540 @@ afterEach(() => { }); describe('createApp basics', () => { + it('restores a matching OAuth document checkpoint into the loaded workspace, revalidates raw Specs, arms dirty unload, and consumes only after success', () => { + const now = 1_700_000_000_000; + const store = memSession({ + [OAUTH_DOCUMENT_RECOVERY_KEY]: encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [ + { + id: 't1', doc: { kind: 'query' }, name: 'Invalid draft', sqlDraft: 'SELECT draft', + specText: '{"name":', specVersion: 1, editorMode: 'sql', dirtySql: true, + dirtySpec: true, savedId: null, + }, + { + id: 't2', doc: { kind: 'dashboard-variable', dashboardId: 'd1', variableName: 'region' }, + name: 'Region', sqlDraft: 'SELECT region', specText: 'verbatim variable draft', + specVersion: 1, editorMode: 'sql', dirtySql: false, dirtySpec: true, savedId: null, + }, + ], + activeTabId: 't2', nextTabId: 3, + }), + }); + const addEventListener = vi.spyOn(window, 'addEventListener'); + const app = createApp(env({ sessionStorage: store, wallNow: () => now })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', queries: [], dashboards: [], + } as StoredWorkspaceV5); + + expect(app.restoreOAuthDocumentRecovery('callback-state')).toEqual({ + kind: 'restored', finalization: 'complete', + }); + expect(app.state.tabs.value.map((tab) => tab.id)).toEqual(['t1', 't2']); + expect(app.state.activeTabId.value).toBe('t2'); + expect(app.state.nextTabId).toBe(3); + expect(app.state.tabs.value[0]).toMatchObject({ sqlDraft: 'SELECT draft', specText: '{"name":', dirtySql: true, dirtySpec: true }); + expect(app.state.tabs.value[0].specParsed).toBeNull(); + expect(app.state.tabs.value[0].specDiagnostics.length).toBeGreaterThan(0); + expect(app.state.tabs.value[1].doc).toEqual({ kind: 'dashboard-variable', dashboardId: 'd1', variableName: 'region' }); + for (const tab of app.state.tabs.value) { + expect(tab.result).toBeNull(); + expect(tab.chSession).toBeUndefined(); + expect(tab.lastSuccessfulResultColumns).toEqual([]); + } + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + + for (const tab of app.state.tabs.value) { tab.dirtySql = false; tab.dirtySpec = false; } + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + + it('retains a restored checkpoint if post-restore Spec revalidation fails', () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Draft', sqlDraft: 'SELECT 1', specText: '{}', + specVersion: 1, editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const addEventListener = vi.spyOn(window, 'addEventListener'); + const app = createApp(env({ sessionStorage: store, wallNow: () => now })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', queries: [], dashboards: [], + } as StoredWorkspaceV5); + vi.spyOn(app.queryDoc, 'revalidateSpecDrafts').mockImplementation(() => { throw new Error('validator unavailable'); }); + + expect(app.restoreOAuthDocumentRecovery('callback-state')).toEqual({ + kind: 'restored', + finalization: 'checkpoint-retained', + warning: 'spec-revalidation-failed', + }); + expect(app.activeTab()).toMatchObject({ sqlDraft: 'SELECT 1', dirtySql: true }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(qs(document, '.share-toast').textContent).toContain('Spec validation'); + expect(addEventListener).toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(() => app.renderCurrentSurface()).not.toThrow(); + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(app.activeTab().sqlDraft).toBe('SELECT 1'); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + + it('leaves the restored document dirty and guarded when checkpoint consumption cannot remove storage', () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Draft', sqlDraft: 'SELECT retained', specText: '{}', + specVersion: 1, editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const removeItem = store.removeItem; + store.removeItem = (key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_KEY) throw new Error('storage removal failed'); + removeItem(key); + }; + const addEventListener = vi.spyOn(window, 'addEventListener'); + const app = createApp(env({ sessionStorage: store, wallNow: () => now })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', queries: [], dashboards: [], + } as StoredWorkspaceV5); + + expect(app.restoreOAuthDocumentRecovery('callback-state')).toEqual({ + kind: 'restored', + finalization: 'checkpoint-retained', + warning: 'checkpoint-remove-failed', + }); + expect(app.activeTab()).toMatchObject({ sqlDraft: 'SELECT retained', dirtySql: true }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(qs(document, '.share-toast').textContent).toContain('recovery cleanup'); + const beforeUnload = addEventListener.mock.calls.find(([type]) => type === 'beforeunload')?.[1] as EventListener; + const ordinaryUnload = new Event('beforeunload', { cancelable: true }); + beforeUnload(ordinaryUnload); + expect(ordinaryUnload.defaultPrevented).toBe(true); + expect(() => app.renderCurrentSurface()).not.toThrow(); + expect(qs(app.root, '.workbench')).not.toBeNull(); + expect(app.activeTab().sqlDraft).toBe('SELECT retained'); + + store.removeItem = removeItem; + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + + it('retries a callback-marked recovery after a same-tab route loads its authoritative workspace', async () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Pending route draft', + sqlDraft: 'SELECT pending route', specText: '{}', specVersion: 1, + editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const location = { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '?ws=recovery', hash: '', href: 'https://ch.example/sql?ws=recovery', + } as Location; + const app = createApp(env({ sessionStorage: store, wallNow: () => now, location })); + + expect(app.restoreOAuthDocumentRecovery('callback-state')) + .toEqual({ kind: 'workspace-unavailable-retained' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + expect(app.sqlRoute.workspaceKey).toBe('recovery'); + expect(app.currentWorkspace).toBeNull(); + + const workspace: StoredWorkspaceV5 = { + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + }; + app.workspace.loadByKey = vi.fn(async () => ({ status: 'ok' as const, workspace })); + app.workspace.markOpened = vi.fn(async () => ({ ok: true as const, workspace })); + let renderedSql = ''; + app.renderCurrentSurface = vi.fn(() => { renderedSql = app.activeTab().sqlDraft; }); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'recovery' }, 'push'); + + expect(renderedSql).toBe('SELECT pending route'); + expect(app.currentWorkspace).toBe(workspace); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + expect(vi.mocked(app.workspace.loadByKey).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.renderCurrentSurface).mock.invocationCallOrder[0]); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + }); + + it('does not retry an ordinary recovery checkpoint without validated callback authority', () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Inert draft', + sqlDraft: 'SELECT must stay inert', specText: '{}', specVersion: 1, + editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const app = createApp(env({ sessionStorage: store, wallNow: () => now })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + } as StoredWorkspaceV5); + + expect(app.retryPendingOAuthDocumentRecovery()).toEqual({ kind: 'absent' }); + expect(app.activeTab().sqlDraft).toBe(''); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + }); + + it('a fresh successful callback supersedes older pending authority before a later retry', () => { + const now = 1_700_000_000_000; + const snapshot = (oauthState: string, sqlDraft: string) => encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState, + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Draft', sqlDraft, + specText: '{}', specVersion: 1, editorMode: 'sql', + dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ + [OAUTH_DOCUMENT_RECOVERY_KEY]: snapshot('older-state', 'SELECT older'), + }); + const app = createApp(env({ sessionStorage: store, wallNow: () => now })); + + expect(app.restoreOAuthDocumentRecovery('older-state')) + .toEqual({ kind: 'workspace-unavailable-retained' }); + store.setItem( + OAUTH_DOCUMENT_RECOVERY_KEY, + snapshot('fresh-state', 'SELECT fresh callback'), + ); + expect(app.restoreOAuthDocumentRecovery('fresh-state')) + .toEqual({ kind: 'workspace-unavailable-retained' }); + expect(JSON.parse(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) ?? '{}')) + .toMatchObject({ oauthState: 'fresh-state' }); + + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + } as StoredWorkspaceV5); + expect(app.retryPendingOAuthDocumentRecovery()) + .toEqual({ kind: 'restored', finalization: 'complete' }); + expect(app.activeTab().sqlDraft).toBe('SELECT fresh callback'); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + }); + + it('defers an unsafe pending retry without publishing, guarding, or exposing raw storage errors', () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Deferred draft', + sqlDraft: 'SELECT deferred', specText: '{}', specVersion: 1, + editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const callbackApp = createApp(env({ sessionStorage: store, wallNow: () => now })); + expect(callbackApp.restoreOAuthDocumentRecovery('callback-state')) + .toEqual({ kind: 'workspace-unavailable-retained' }); + + const getItem = store.getItem; + store.getItem = (key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('raw marker backend failure'); + } + return getItem(key); + }; + const app = createApp(env({ sessionStorage: store, wallNow: () => now })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + } as StoredWorkspaceV5); + const addEventListener = vi.spyOn(window, 'addEventListener'); + + expect(app.retryPendingOAuthDocumentRecovery()) + .toEqual({ kind: 'retry-deferred-retained' }); + expect(app.retryPendingOAuthDocumentRecovery()) + .toEqual({ kind: 'retry-deferred-retained' }); + expect(app.activeTab()).toMatchObject({ sqlDraft: '', dirtySql: false, dirtySpec: false }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(addEventListener).not.toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(qs(document, '.share-toast').textContent) + .toBe('Unsaved drafts remain safely stored. Recovery will retry automatically.'); + expect(qs(document, '.share-toast').textContent).not.toContain('raw marker backend failure'); + expect(() => app.renderCurrentSurface()).not.toThrow(); + expect(qs(app.root, '.workbench')).not.toBeNull(); + + store.getItem = getItem; + addEventListener.mockRestore(); + }); + + it('contains unexpected prepublication restore exceptions behind the safe deferred outcome', () => { + const app = createApp(env({ sessionStorage: memSession({}) })); + const addEventListener = vi.spyOn(window, 'addEventListener'); + + Object.defineProperty(app, 'currentWorkspace', { + configurable: true, + get: () => { throw new Error('raw fresh restore failure'); }, + }); + expect(app.restoreOAuthDocumentRecovery('callback-state')) + .toEqual({ kind: 'retry-deferred-retained' }); + + Object.defineProperty(app, 'currentWorkspace', { + configurable: true, + get: () => { throw new Error('raw pending retry failure'); }, + }); + expect(app.retryPendingOAuthDocumentRecovery()) + .toEqual({ kind: 'retry-deferred-retained' }); + + Object.defineProperty(app, 'currentWorkspace', { + configurable: true, writable: true, value: null, + }); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + } as StoredWorkspaceV5); + expect(addEventListener).not.toHaveBeenCalledWith('beforeunload', expect.any(Function)); + expect(qs(document, '.share-toast').textContent) + .toBe('Unsaved drafts remain safely stored. Recovery will retry automatically.'); + expect(qs(document, '.share-toast').textContent).not.toContain('raw'); + expect(() => app.renderCurrentSurface()).not.toThrow(); + expect(qs(app.root, '.workbench')).not.toBeNull(); + + addEventListener.mockRestore(); + }); + + it('preserves a newer dirty in-memory edit across automatic route retries, then restores once clean', async () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Recovered draft', + sqlDraft: 'SELECT recovered', specText: '{}', specVersion: 1, + editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const callbackApp = createApp(env({ sessionStorage: store, wallNow: () => now })); + expect(callbackApp.restoreOAuthDocumentRecovery('callback-state')) + .toEqual({ kind: 'workspace-unavailable-retained' }); + + const location = { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '?ws=recovery', hash: '', href: 'https://ch.example/sql?ws=recovery', + } as Location; + const app = createApp(env({ sessionStorage: store, wallNow: () => now, location })); + const workspace: StoredWorkspaceV5 = { + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + }; + app.applyCommittedWorkspace(workspace); + + const removeItem = store.removeItem; + let failMarkerRetirementOnce = true; + store.removeItem = (key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY + && failMarkerRetirementOnce) { + failMarkerRetirementOnce = false; + throw new Error('temporary marker failure'); + } + removeItem(key); + }; + expect(app.retryPendingOAuthDocumentRecovery()) + .toEqual({ kind: 'retry-deferred-retained' }); + expect(app.activeTab().sqlDraft).toBe(''); + + app.activeTab().sqlDraft = 'SELECT newer in-memory edit'; + app.activeTab().dirtySql = true; + await app.navigateSqlRoute({ + surface: 'dashboard', workspaceKey: 'recovery', mode: 'view', + }, 'push'); + expect(app.activeTab().sqlDraft).toBe('SELECT newer in-memory edit'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + + location.search = '?ws=recovery'; + await app.handleSqlPopState(); + expect(app.activeTab().sqlDraft).toBe('SELECT newer in-memory edit'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + + // Saving/cleaning the newer edit makes publication safe on the next + // automatic retry; the retained callback recovery can now take over. + app.activeTab().dirtySql = false; + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'recovery' }, 'replace'); + expect(app.activeTab()).toMatchObject({ + name: 'Recovered draft', sqlDraft: 'SELECT recovered', dirtySql: true, + }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + }); + + it('retains fresh callback authority after a one-shot checkpoint read failure and restores on route retry', async () => { + const now = 1_700_000_000_000; + const checkpoint = encodeOAuthDocumentRecovery({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: now, + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: 'callback-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Read retry draft', + sqlDraft: 'SELECT after retry', specText: '{}', specVersion: 1, + editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, + }], + activeTabId: 't1', nextTabId: 2, + }); + const store = memSession({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const getItem = store.getItem; + let failCheckpointReadOnce = true; + store.getItem = (key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_KEY && failCheckpointReadOnce) { + failCheckpointReadOnce = false; + throw new Error('raw checkpoint read failure'); + } + return getItem(key); + }; + const location = { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '?ws=recovery', hash: '', href: 'https://ch.example/sql?ws=recovery', + } as Location; + const app = createApp(env({ sessionStorage: store, wallNow: () => now, location })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', + queries: [], dashboards: [], + } as StoredWorkspaceV5); + + expect(app.restoreOAuthDocumentRecovery('callback-state')) + .toEqual({ kind: 'retry-deferred-retained' }); + expect(qs(document, '.share-toast').textContent) + .toBe('Unsaved drafts remain safely stored. Recovery will retry automatically.'); + expect(qs(document, '.share-toast').textContent).not.toContain('raw checkpoint read failure'); + expect(() => app.renderCurrentSurface()).not.toThrow(); + expect(app.activeTab().sqlDraft).toBe(''); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'recovery' }, 'replace'); + expect(app.activeTab()).toMatchObject({ + name: 'Read retry draft', sqlDraft: 'SELECT after retry', dirtySql: true, + }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + }); + + it('consumes a suppressed legacy share once without replacing the current document', () => { + const handoff = JSON.stringify({ + sql: 'SELECT shared', + specVersion: 1, + spec: { name: 'Shared query', favorite: false }, + }); + const store = memSession({ oauth_shared: handoff }); + const app = createApp(env({ sessionStorage: store })); + + expect(app.consumeLegacyShared(false)).toBe(false); + expect(store.getItem('oauth_shared')).toBeNull(); + expect(app.activeTab().sqlDraft).toBe(''); + expect(app.consumeLegacyShared(true)).toBe(false); + + const dashboard = createApp(env({ + location: { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '?surface=dashboard', hash: '', href: 'https://ch.example/sql?surface=dashboard', + } as Location, + })); + expect(dashboard.consumeLegacyShared(true, handoff)).toBe(false); + expect(dashboard.activeTab().sqlDraft).toBe(''); + }); + + it('leaves the current document untouched when the legacy handoff cannot be read', () => { + const store = memSession({ + oauth_shared: JSON.stringify({ + sql: 'SELECT unavailable handoff', + specVersion: 1, + spec: { name: 'Unavailable handoff', favorite: false }, + }), + }); + const app = createApp(env({ sessionStorage: store })); + const getItem = store.getItem; + store.getItem = (key) => { + if (key === 'oauth_shared') throw new Error('session storage read unavailable'); + return getItem(key); + }; + + expect(app.consumeLegacyShared(true)).toBe(false); + expect(app.activeTab()).toMatchObject({ sqlDraft: '', name: 'Untitled' }); + expect(store._map.get('oauth_shared')).toBeTruthy(); + }); + + it('fails closed for malformed/contentless shared handoffs and preserves queryless Panel compatibility', () => { + const app = createApp(env()); + + expect(app.consumeLegacyShared(true, '{not json')).toBe(false); + expect(app.consumeLegacyShared(true, JSON.stringify({ + sql: '', specVersion: 1, spec: { name: 'Empty', favorite: false }, + }))).toBe(false); + expect(app.consumeLegacyShared(true, JSON.stringify({ + sql: '', + specVersion: 1, + spec: { + name: 'Text panel', favorite: false, + panel: { cfg: { type: 'text', content: '# Restored' } }, + }, + }))).toBe(true); + expect(app.activeTab().name).toBe('Text panel'); + expect(app.state.resultView.value).toBe('panel'); + }); + + it('normalizes legacy chart view while consuming an already-taken bootstrap handoff', () => { + const app = createApp(env()); + expect(app.consumeLegacyShared(true, JSON.stringify({ + sql: 'SELECT 1', + specVersion: 1, + spec: { + name: 'Legacy chart view', favorite: false, view: 'chart', + panel: { cfg: { type: 'pie', x: 0, y: [1], series: null } }, + }, + }))).toBe(true); + expect(app.state.resultView.value).toBe('panel'); + expect(app.activeTab().name).toBe('Legacy chart view'); + }); + it('env.specValidators accepts an already-built validator service as-is (not re-wrapped)', () => { const service = createSpecValidatorRegistry(); const app = createApp(env({ specValidators: service })); @@ -3612,6 +4153,123 @@ describe('openCreateInNewTab (#180)', () => { }); describe('auth flows', () => { + it('checkpoints dirty authored tabs before OAuth navigation and bypasses exactly one dirty unload event', async () => { + const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' } as Location; + const store = memSession({}); + const addEventListener = vi.spyOn(window, 'addEventListener'); + const app = createApp(env({ + location: loc, sessionStorage: store, wallNow: () => 1_700_000_000_000, + fetch: makeFetch([ + [(u) => /config\.json/.test(u), resp({ json: { issuer: 'https://accounts.google.com', client_id: 'cid' } })], + [(u) => /openid-configuration/.test(u), resp({ json: { authorization_endpoint: 'https://accounts.google.com/auth', token_endpoint: 'https://t' } })], + ]), + })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', queries: [], dashboards: [], + } as StoredWorkspaceV5); + const tab = app.activeTab(); + tab.sqlDraft = 'SELECT unsaved'; tab.specText = '{raw'; tab.dirtySql = true; tab.dirtySpec = true; + app.syncBeforeUnload(); + const beforeUnload = addEventListener.mock.calls.find(([type]) => type === 'beforeunload')?.[1] as EventListener; + + await app.actions.login(); + + const checkpoint = JSON.parse(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY) ?? 'null'); + expect(checkpoint).toMatchObject({ + workspaceId: 'w-recovery', workspaceKey: 'recovery', oauthState: store.getItem('oauth_state'), + tabs: [expect.objectContaining({ sqlDraft: 'SELECT unsaved', specText: '{raw', dirtySql: true, dirtySpec: true })], + }); + expect(loc.href).toContain('https://accounts.google.com/auth?'); + const intendedRedirect = new Event('beforeunload', { cancelable: true }); + beforeUnload(intendedRedirect); + expect(intendedRedirect.defaultPrevented).toBe(false); + const ordinaryUnload = new Event('beforeunload', { cancelable: true }); + beforeUnload(ordinaryUnload); + expect(ordinaryUnload.defaultPrevented).toBe(true); + + tab.dirtySql = false; tab.dirtySpec = false; + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + + it('does not navigate or bypass the dirty guard when the OAuth recovery write fails', async () => { + const href = 'https://ch/sql'; + const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href } as Location; + const store = memSession({}); + const setItem = store.setItem; + store.setItem = (key, value) => { + if (key === OAUTH_DOCUMENT_RECOVERY_KEY) throw new Error('storage full'); + setItem(key, value); + }; + const addEventListener = vi.spyOn(window, 'addEventListener'); + const app = createApp(env({ + location: loc, sessionStorage: store, + fetch: makeFetch([ + [(u) => /config\.json/.test(u), resp({ json: { issuer: 'https://accounts.google.com', client_id: 'cid' } })], + [(u) => /openid-configuration/.test(u), resp({ json: { authorization_endpoint: 'https://accounts.google.com/auth', token_endpoint: 'https://t' } })], + ]), + })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', queries: [], dashboards: [], + } as StoredWorkspaceV5); + app.activeTab().dirtySql = true; + app.syncBeforeUnload(); + const beforeUnload = addEventListener.mock.calls.find(([type]) => type === 'beforeunload')?.[1] as EventListener; + + await expect(app.actions.login()).rejects.toThrow('storage full'); + expect(loc.href).toBe(href); + const ordinaryUnload = new Event('beforeunload', { cancelable: true }); + beforeUnload(ordinaryUnload); + expect(ordinaryUnload.defaultPrevented).toBe(true); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + + it('does not let a stale redirect disarm clear the newer app-owned unload bypass', async () => { + let href = 'https://ch/sql'; + let app!: App; + let newer: Promise | null = null; + const loc = { + host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', + get href() { return href; }, + set href(value: string) { + href = value; + if (newer === null) newer = app.actions.login(); + }, + } as Location; + const addEventListener = vi.spyOn(window, 'addEventListener'); + app = createApp(env({ + location: loc, sessionStorage: memSession({}), + fetch: makeFetch([ + [(u) => /config\.json/.test(u), resp({ json: { issuer: 'https://accounts.google.com', client_id: 'cid' } })], + [(u) => /openid-configuration/.test(u), resp({ json: { authorization_endpoint: 'https://accounts.google.com/auth', token_endpoint: 'https://t' } })], + ]), + })); + app.applyCommittedWorkspace({ + storageVersion: 5, id: 'w-recovery', key: 'recovery', name: 'Recovery', queries: [], dashboards: [], + } as StoredWorkspaceV5); + app.activeTab().dirtySql = true; + app.syncBeforeUnload(); + const beforeUnload = addEventListener.mock.calls.find(([type]) => type === 'beforeunload')?.[1] as EventListener; + + await expect(app.actions.login()).rejects.toThrow('Authentication attempt superseded'); + expect(newer).not.toBeNull(); + await expect(newer!).resolves.toBeUndefined(); + + const newerRedirect = new Event('beforeunload', { cancelable: true }); + beforeUnload(newerRedirect); + expect(newerRedirect.defaultPrevented).toBe(false); + const ordinaryUnload = new Event('beforeunload', { cancelable: true }); + beforeUnload(ordinaryUnload); + expect(ordinaryUnload.defaultPrevented).toBe(true); + + app.activeTab().dirtySql = false; + app.syncBeforeUnload(); + addEventListener.mockRestore(); + }); + it('login builds the redirect URL and stashes pkce/state', async () => { const loc = { host: 'ch', origin: 'https://ch', pathname: '/sql', search: '', hash: '', href: 'https://ch/sql' } as Location; const e = env({ @@ -3771,6 +4429,130 @@ describe('credentials (basic) sign-in', () => { const probe = asMock(e.fetch!).mock.calls.find(([, init]) => init && init.body === 'SELECT 1')!; expect(probe[1].headers.Authorization).toBe('Basic ' + creds); }); + it('preserves a logged-out hash share through successful in-page Basic login and consumes it after workspace load', async () => { + const hash = '#' + btoa(unescape(encodeURIComponent(JSON.stringify({ + __asb: 2, + query: { + sql: 'SELECT shared after login', + specVersion: 1, + spec: { name: 'Basic share', favorite: false, view: 'json' }, + }, + })))); + const location = { + host: 'ch.example', origin: 'https://ch.example', pathname: '/sql', + search: '', hash, href: 'https://ch.example/sql' + hash, + } as Location; + const store = memSession({}); + const e = env({ + location, + sessionStorage: store, + fetch: makeFetch([ + [(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [{ '1': 1 }] } })], + ]), + }); + const app = createApp(e); + const loadWorkspace = vi.spyOn(app, 'loadWorkspaceOnBoot'); + const retryPending = vi.spyOn(app, 'retryPendingOAuthDocumentRecovery'); + const consumeShared = vi.spyOn(app, 'consumeLegacyShared'); + const renderSurface = vi.spyOn(app, 'renderCurrentSurface'); + + await bootstrap(app, { + location, + sessionStorage: store, + history: window.history, + fetch: e.fetch!, + }); + expect(app.conn.isSignedIn()).toBe(false); + expect(store.getItem('oauth_shared')).not.toBeNull(); + + await app.actions.connect({ username: 'demo', password: 'demo', host: '' }); + + expect(app.activeTab()).toMatchObject({ + sqlDraft: 'SELECT shared after login', + name: 'Basic share', + }); + expect(app.state.resultView.value).toBe('json'); + expect(store.getItem('oauth_shared')).toBeNull(); + expect(loadWorkspace.mock.invocationCallOrder[0]) + .toBeLessThan(retryPending.mock.invocationCallOrder[0]); + expect(retryPending.mock.invocationCallOrder[0]) + .toBeLessThan(consumeShared.mock.invocationCallOrder[0]); + expect(consumeShared.mock.invocationCallOrder[0]) + .toBeLessThan(renderSurface.mock.invocationCallOrder[0]); + }); + it('lets a pending recovery win over the legacy share during defensive Basic login', async () => { + const store = memSession({ + oauth_shared: JSON.stringify({ + sql: 'SELECT shared must lose', + specVersion: 1, + spec: { name: 'Shared query', favorite: false }, + }), + }); + const e = env({ + sessionStorage: store, + fetch: makeFetch([ + [(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [{ '1': 1 }] } })], + ]), + }); + const app = createApp(e); + const loadWorkspace = vi.spyOn(app, 'loadWorkspaceOnBoot'); + app.retryPendingOAuthDocumentRecovery = vi.fn(() => { + app.activeTab().sqlDraft = 'SELECT pending Basic recovery'; + return { kind: 'restored', finalization: 'complete' } as const; + }); + const consumeShared = vi.spyOn(app, 'consumeLegacyShared'); + const renderSurface = vi.spyOn(app, 'renderCurrentSurface'); + + await app.actions.connect({ username: 'demo', password: 'demo', host: '' }); + + expect(app.activeTab().sqlDraft).toBe('SELECT pending Basic recovery'); + expect(store.getItem('oauth_shared')).toBeNull(); + expect(consumeShared).toHaveBeenCalledWith(false); + expect(loadWorkspace.mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.retryPendingOAuthDocumentRecovery).mock.invocationCallOrder[0]); + expect(vi.mocked(app.retryPendingOAuthDocumentRecovery).mock.invocationCallOrder[0]) + .toBeLessThan(consumeShared.mock.invocationCallOrder[0]); + expect(consumeShared.mock.invocationCallOrder[0]) + .toBeLessThan(renderSurface.mock.invocationCallOrder[0]); + }); + it.each([ + { kind: 'retry-deferred-retained' } as const, + { kind: 'workspace-unavailable-retained' } as const, + ])('keeps Basic login usable but discards the shared handoff when recovery authority is $kind', async (recovery) => { + const store = memSession({ + oauth_shared: JSON.stringify({ + sql: 'SELECT deferred Basic share must never appear', + specVersion: 1, + spec: { name: 'Deferred fallback', favorite: false }, + }), + }); + const e = env({ + sessionStorage: store, + fetch: makeFetch([ + [(u, sql) => /SELECT 1/.test(sql), resp({ json: { data: [{ '1': 1 }] } })], + ]), + }); + const app = createApp(e); + app.retryPendingOAuthDocumentRecovery = vi.fn(() => recovery); + const removeItem = store.removeItem; + store.removeItem = vi.fn((key: string) => { + if (key === 'oauth_shared') throw new Error('raw handoff cleanup failure'); + removeItem(key); + }); + const consumeShared = vi.spyOn(app, 'consumeLegacyShared'); + const renderSurface = vi.spyOn(app, 'renderCurrentSurface'); + + await expect(app.actions.connect({ + username: 'demo', password: 'demo', host: '', + })).resolves.toBeUndefined(); + + expect(app.activeTab().sqlDraft).toBe(''); + expect(store.getItem('oauth_shared')).not.toBeNull(); + expect(store.removeItem).toHaveBeenCalledWith('oauth_shared'); + expect(consumeShared).toHaveBeenCalledWith(false); + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); + expect(renderSurface).toHaveBeenCalledOnce(); + }); it('connect() targets a custom host via resolveTarget', async () => { const e = env({ sessionStorage: memSession({}), @@ -4211,6 +4993,8 @@ describe('share + star + columns', () => { ); const deleteSpy = vi.spyOn(app.workspace, 'delete') .mockResolvedValueOnce({ ok: true, deleted: true }); + const retryPending = vi.fn(() => ({ kind: 'retry-deferred-retained' } as const)); + app.retryPendingOAuthDocumentRecovery = retryPending; const workspace = await app.loadWorkspaceOnBoot(); expect(workspace).toBeNull(); @@ -4234,6 +5018,7 @@ describe('share + star + columns', () => { }); expect(deleteSpy).toHaveBeenCalledWith('corrupt-workspace'); + expect(retryPending).toHaveBeenCalledOnce(); const rebuilt = await loadActiveWorkspace(app); expect(rebuilt).not.toBeNull(); expect(app.state.workspaceId).toBe(rebuilt!.id); @@ -4264,6 +5049,50 @@ describe('share + star + columns', () => { expect(app.workspace.delete).toHaveBeenCalledWith('corrupt-workspace'); expect(app.state.workspaceId).toBe(beforeId); }); + it('does not finish a corrupt-workspace reset over a newer route while recording its replacement', async () => { + const app = createApp(env()); + const replacement: StoredWorkspaceV5 = { + storageVersion: 5, id: 'replacement', key: 'replacement', name: 'Replacement', + queries: [], dashboards: [], + }; + const newer: StoredWorkspaceV5 = { + storageVersion: 5, id: 'newer', key: 'newer', name: 'Newer', + queries: [], dashboards: [], + }; + app.workspace.resolveImplicit = vi.fn() + .mockResolvedValueOnce({ + status: 'corrupt' as const, + id: 'corrupt-workspace', + key: 'corrupt_workspace', + diagnostics: [{ + path: [], severity: 'error' as const, + code: 'workspace-version-unsupported', message: 'Unsupported version', + }], + }) + .mockResolvedValueOnce({ status: 'ok' as const, workspace: replacement }); + app.workspace.delete = vi.fn(async () => ({ ok: true as const, deleted: true })); + app.workspace.loadByKey = vi.fn(async () => ({ status: 'ok' as const, workspace: newer })); + let releaseReplacement!: () => void; + app.workspace.markOpened = vi.fn((key: string): Promise => + key === replacement.key + ? new Promise((resolve) => { + releaseReplacement = () => resolve({ ok: true as const }); + }) + : Promise.resolve({ ok: true as const })); + app.renderCurrentSurface = vi.fn(); + + await app.loadWorkspaceOnBoot(); + qs(document, '.share-toast-action').click(); + await vi.waitFor(() => expect(app.workspace.markOpened).toHaveBeenCalledWith(replacement.key)); + + await app.navigateSqlRoute({ surface: 'workspace', workspaceKey: newer.key }, 'push'); + releaseReplacement(); + await flush(); + + expect(app.sqlRoute.workspaceKey).toBe(newer.key); + expect(app.currentWorkspace).toBe(newer); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); it('#300: the empty and ok load-result cases behave exactly as before (no toast, migrate-then-project runs as usual)', async () => { const app = createApp(env()); // `env()`'s own #287 default fake IndexedDB: a working store with nothing @@ -6518,6 +7347,9 @@ describe('unified /sql routing', () => { }; app.workspace.loadByKey = vi.fn(async () => ({ status: 'ok' as const, workspace: second })); app.workspace.markOpened = vi.fn(async () => ({ ok: true as const, workspace: second })); + app.retryPendingOAuthDocumentRecovery = vi.fn( + () => ({ kind: 'retry-deferred-retained' } as const), + ); app.renderCurrentSurface = vi.fn(); location.search = '?ws=second&surface=dashboard&mode=view'; await app.handleSqlPopState(); @@ -6525,7 +7357,10 @@ describe('unified /sql routing', () => { surface: 'dashboard', workspaceKey: 'second', mode: 'view', }); expect(app.currentWorkspace).toBe(second); + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + expect(vi.mocked(app.retryPendingOAuthDocumentRecovery).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.renderCurrentSurface).mock.invocationCallOrder[0]); }); it('reloadDashboardRoute also handles a missing current projection', () => { @@ -6807,6 +7642,9 @@ describe('unified /sql routing', () => { }, })); app.renderCurrentSurface = vi.fn(); + app.retryPendingOAuthDocumentRecovery = vi.fn( + () => ({ kind: 'retry-deferred-retained' } as const), + ); const toB = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'b' }, 'push'); const toC = app.navigateSqlRoute({ surface: 'workspace', workspaceKey: 'c' }, 'push'); const c: StoredWorkspaceV5 = { @@ -6822,6 +7660,7 @@ describe('unified /sql routing', () => { expect(app.sqlRoute.workspaceKey).toBe('c'); expect(app.currentWorkspace).toBe(c); expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); }); it('discards a route whose last-used write finishes after a newer navigation', async () => { @@ -6927,11 +7766,15 @@ describe('unified /sql routing', () => { app.applyCommittedWorkspace(a); app.renderApp(); const loadByKey = vi.spyOn(app.workspace, 'loadByKey'); + app.retryPendingOAuthDocumentRecovery = vi.fn( + () => ({ kind: 'retry-deferred-retained' } as const), + ); location.search = '?ws=a&surface=dashboard&mode=view'; await app.handleSqlPopState(); expect(loadByKey).not.toHaveBeenCalled(); + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); expect(app.sqlRoute).toEqual({ surface: 'dashboard', workspaceKey: 'a', mode: 'view', }); @@ -6939,6 +7782,30 @@ describe('unified /sql routing', () => { expect(qs(app.root, '.workspace-loading')).toBeNull(); }); + it('same-key popstate reloads and retries pending recovery when no workspace projection is available', async () => { + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '?ws=a', + hash: '', host: 'ch.example', href: 'https://ch.example/sql?ws=a', + } as Location; + const app = createApp(env({ location })); + const workspace: StoredWorkspaceV5 = { + storageVersion: 5, id: 'a', key: 'a', name: 'A', queries: [], dashboards: [], + }; + app.currentWorkspace = null; + app.workspace.loadByKey = vi.fn(async () => ({ status: 'ok' as const, workspace })); + app.workspace.markOpened = vi.fn(async () => ({ ok: true as const, workspace })); + app.retryPendingOAuthDocumentRecovery = vi.fn(() => ({ kind: 'absent' } as const)); + app.renderCurrentSurface = vi.fn(); + + await app.handleSqlPopState(); + + expect(app.workspace.loadByKey).toHaveBeenCalledWith('a'); + expect(app.currentWorkspace).toBe(workspace); + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); + expect(vi.mocked(app.retryPendingOAuthDocumentRecovery).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.renderCurrentSurface).mock.invocationCallOrder[0]); + }); + it('closes Dashboard shortcut help before a Dashboard-to-Workbench popstate', async () => { const location = { origin: 'https://ch.example', pathname: '/sql', diff --git a/tests/unit/connection-session.test.ts b/tests/unit/connection-session.test.ts index 6a421c6..dbc8d8c 100644 --- a/tests/unit/connection-session.test.ts +++ b/tests/unit/connection-session.test.ts @@ -79,6 +79,9 @@ interface SetupOpts { routes?: RouteFn[]; queryJson?: QueryJsonFn; onAuthLost?: ConnectionSessionDeps['onAuthLost']; + prepareOAuthRedirect?: ConnectionSessionDeps['prepareOAuthRedirect']; + armOAuthRedirectUnloadBypass?: ConnectionSessionDeps['armOAuthRedirectUnloadBypass']; + clearOAuthDocumentRecovery?: ConnectionSessionDeps['clearOAuthDocumentRecovery']; } function setup(opts: SetupOpts = {}) { const fetchMock = makeFetch(opts.routes || []); @@ -95,6 +98,9 @@ function setup(opts: SetupOpts = {}) { crypto: webcrypto, queryJson: opts.queryJson || fakeQueryJson(async () => ({ data: [{ 1: 1 }] })), onAuthLost, + prepareOAuthRedirect: opts.prepareOAuthRedirect, + armOAuthRedirectUnloadBypass: opts.armOAuthRedirectUnloadBypass, + clearOAuthDocumentRecovery: opts.clearOAuthDocumentRecovery, }; return { deps, storage, location, fetchMock, onAuthLost, session: createConnectionSession(deps) }; } @@ -529,8 +535,604 @@ describe('beginOAuth', () => { }); }); + it('prepares after the complete PKCE attempt is stored, but does not arm for a clean session', async () => { + const arm = vi.fn(() => vi.fn()); + const prepare = vi.fn((state: string) => { + const storedState = storage.getItem('oauth_state'); + expect(storedState).toBe(state); + expect(storage.getItem('oauth_verifier')).toBeTruthy(); + expect(JSON.parse(storage.getItem('oauth_return_route')!)).toEqual({ state, search: '' }); + return false; + }); + const { session, storage, location } = setup({ + prepareOAuthRedirect: prepare, + armOAuthRedirectUnloadBypass: arm, + }); + + await session.beginOAuth('g'); + + expect(prepare).toHaveBeenCalledWith(storage.getItem('oauth_state')); + expect(arm).not.toHaveBeenCalled(); + expect(location.href).toContain('https://issuer.example/authorize'); + }); + + it('rolls back instead of navigating when durable recovery has no unload-bypass capability', async () => { + const storage = memStorage({ + oauth_verifier: 'previous-verifier', + oauth_state: 'previous-state', + oauth_return_route: 'previous-route', + }); + const { session, location } = setup({ + storage, + prepareOAuthRedirect: () => true, + }); + + await expect(session.beginOAuth('g')).rejects.toThrow( + 'OAuth recovery redirect requires an unload bypass', + ); + + expect(location.href).toBe('https://ch.example/sql'); + expect(storage.getItem('oauth_verifier')).toBe('previous-verifier'); + expect(storage.getItem('oauth_state')).toBe('previous-state'); + expect(storage.getItem('oauth_return_route')).toBe('previous-route'); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 1 }); + }); + + it('arms the one-shot unload bypass immediately before assigning the OAuth redirect', async () => { + const events: string[] = []; + let href = 'https://ch.example/sql'; + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '', + get href() { return href; }, + set href(value: string) { events.push('href'); href = value; }, + }; + const arm = vi.fn(() => { + events.push('arm'); + return () => events.push('disarm'); + }); + const { session } = setup({ + location, + prepareOAuthRedirect: () => { events.push('prepare'); return true; }, + armOAuthRedirectUnloadBypass: arm, + }); + + await session.beginOAuth('g'); + + expect(events).toEqual(['prepare', 'arm', 'href']); + expect(arm).toHaveBeenCalledTimes(1); + }); + + it('stops writing OAuth keys when a reentrant storage setter signs out', async () => { + const backing = memStorage(); + let session!: ReturnType; + let signedOut = false; + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + backing.setItem(key, value); + if (key === 'oauth_verifier' && !signedOut) { + signedOut = true; + session.signOut(); + } + }, + removeItem: backing.removeItem, + }; + const prepare = vi.fn(() => true); + const arm = vi.fn(() => vi.fn()); + const clearOAuthDocumentRecovery = vi.fn(); + const configured = setup({ + storage, + prepareOAuthRedirect: prepare, + armOAuthRedirectUnloadBypass: arm, + clearOAuthDocumentRecovery, + }); + session = configured.session; + + await expect(session.beginOAuth('g')).rejects.toThrow('Authentication attempt superseded'); + + expect(configured.location.href).toBe('https://ch.example/sql'); + expect(backing.getItem('oauth_verifier')).toBeNull(); + expect(backing.getItem('oauth_state')).toBeNull(); + expect(backing.getItem('oauth_return_route')).toBeNull(); + expect(backing.getItem('oauth_idp')).toBeNull(); + expect(prepare).not.toHaveBeenCalled(); + expect(arm).not.toHaveBeenCalled(); + expect(clearOAuthDocumentRecovery).toHaveBeenCalledTimes(1); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 2 }); + }); + + it('disarms and does not navigate when the arm callback signs out reentrantly', async () => { + let session!: ReturnType; + const disarm = vi.fn(); + const configured = setup({ + prepareOAuthRedirect: () => true, + armOAuthRedirectUnloadBypass: () => { + session.signOut(); + return disarm; + }, + }); + session = configured.session; + + await expect(session.beginOAuth('g')).rejects.toThrow('Authentication attempt superseded'); + + expect(configured.location.href).toBe('https://ch.example/sql'); + expect(disarm).toHaveBeenCalledTimes(1); + expect(configured.storage.getItem('oauth_verifier')).toBeNull(); + expect(configured.storage.getItem('oauth_state')).toBeNull(); + expect(configured.storage.getItem('oauth_return_route')).toBeNull(); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 2 }); + }); + + it('rolls back all attempt keys and restores auth-required lifecycle when preparation fails', async () => { + const storage = memStorage({ oauth_id_token: validToken }); + const arm = vi.fn(() => vi.fn()); + const { session, location } = setup({ + storage, + prepareOAuthRedirect: () => { throw new Error('snapshot write failed'); }, + armOAuthRedirectUnloadBypass: arm, + }); + session.chCtx.onSignedOut('session expired'); + storage.setItem('oauth_verifier', 'previous-verifier'); + storage.setItem('oauth_state', 'previous-state'); + storage.setItem('oauth_return_route', 'previous-route'); + + await expect(session.beginOAuth('g')).rejects.toThrow('snapshot write failed'); + + expect(location.href).toBe('https://ch.example/sql'); + expect(arm).not.toHaveBeenCalled(); + expect(storage.getItem('oauth_verifier')).toBe('previous-verifier'); + expect(storage.getItem('oauth_state')).toBe('previous-state'); + expect(storage.getItem('oauth_return_route')).toBe('previous-route'); + expect(session.connection.value).toEqual({ kind: 'auth-required', epoch: 2, detail: 'session expired' }); + }); + + it('rolls back a partial OAuth-attempt storage write without navigating or arming', async () => { + const backing = memStorage({ + oauth_idp: 'g', oauth_verifier: 'old-verifier', oauth_state: 'old-state', oauth_return_route: 'old-route', + }); + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + if (key === 'oauth_state' && value !== 'old-state') throw new Error('storage full'); + backing.setItem(key, value); + }, + removeItem: backing.removeItem, + }; + const arm = vi.fn(() => vi.fn()); + const { session, location } = setup({ storage, armOAuthRedirectUnloadBypass: arm }); + + await expect(session.beginOAuth()).rejects.toThrow('storage full'); + + expect(location.href).toBe('https://ch.example/sql'); + expect(arm).not.toHaveBeenCalled(); + expect(backing.getItem('oauth_verifier')).toBe('old-verifier'); + expect(backing.getItem('oauth_state')).toBe('old-state'); + expect(backing.getItem('oauth_return_route')).toBe('old-route'); + }); + + it('stops rollback after its first restore mutation reentrantly signs out', async () => { + const backing = memStorage({ + oauth_verifier: 'old-verifier', + oauth_state: 'old-state', + oauth_return_route: 'old-route', + }); + let session!: ReturnType; + let signedOut = false; + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + backing.setItem(key, value); + if (key === 'oauth_verifier' && value === 'old-verifier' && !signedOut) { + signedOut = true; + session.signOut(); + } + }, + removeItem: backing.removeItem, + }; + const clearOAuthDocumentRecovery = vi.fn(); + const configured = setup({ + storage, + prepareOAuthRedirect: () => { throw new Error('snapshot failed'); }, + clearOAuthDocumentRecovery, + }); + session = configured.session; + + await expect(session.beginOAuth('g')).rejects.toThrow('snapshot failed'); + + expect(configured.location.href).toBe('https://ch.example/sql'); + expect(backing.getItem('oauth_verifier')).toBeNull(); + expect(backing.getItem('oauth_state')).toBeNull(); + expect(backing.getItem('oauth_return_route')).toBeNull(); + expect(clearOAuthDocumentRecovery).toHaveBeenCalledTimes(1); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 2 }); + }); + + it('stops rollback when a restore mutation itself throws', async () => { + const backing = memStorage({ + oauth_verifier: 'old-verifier', + oauth_state: 'old-state', + oauth_return_route: 'old-route', + }); + const rollbackValues: Record = { + oauth_verifier: 'old-verifier', + oauth_state: 'old-state', + oauth_return_route: 'old-route', + }; + const restoreAttempts: string[] = []; + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + if (value === rollbackValues[key]) { + restoreAttempts.push(key); + if (key === 'oauth_verifier') throw new Error('restore blocked'); + } + backing.setItem(key, value); + }, + removeItem: backing.removeItem, + }; + const { session } = setup({ + storage, + prepareOAuthRedirect: () => { throw new Error('snapshot failed'); }, + }); + + await expect(session.beginOAuth('g')).rejects.toThrow('snapshot failed'); + + expect(restoreAttempts).toEqual(['oauth_verifier']); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 1 }); + }); + + it('does not continue stale rollback after its first restore starts a newer attempt', async () => { + const backing = memStorage({ + oauth_verifier: 'old-verifier', + oauth_state: 'old-state', + oauth_return_route: 'old-route', + }); + const oldRollbackWrites: string[] = []; + let session!: ReturnType; + let newer: Promise | undefined; + let startedNewer = false; + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + if (value === 'old-verifier' || value === 'old-state' || value === 'old-route') { + oldRollbackWrites.push(`${key}:${value}`); + } + backing.setItem(key, value); + if (key === 'oauth_verifier' && value === 'old-verifier' && !startedNewer) { + startedNewer = true; + newer = session.beginOAuth('basicidp'); + } + }, + removeItem: backing.removeItem, + }; + let prepareCalls = 0; + const configured = setup({ + storage, + prepareOAuthRedirect: () => { + prepareCalls += 1; + if (prepareCalls === 1) throw new Error('older snapshot failed'); + return false; + }, + }); + session = configured.session; + + const older = session.beginOAuth('g'); + await expect(older).rejects.toThrow('older snapshot failed'); + expect(newer).toBeDefined(); + await expect(newer!).resolves.toBeUndefined(); + + expect(oldRollbackWrites).toEqual(['oauth_verifier:old-verifier']); + const state = backing.getItem('oauth_state'); + expect(JSON.parse(backing.getItem('oauth_return_route')!)).toEqual({ state, search: '' }); + expect(configured.location.href).toContain('https://issuer2.example/authorize'); + expect(session.connection.value).toEqual({ kind: 'reauthenticating', epoch: 2 }); + }); + + it('preserves the redirect error when storage cannot verify rollback ownership', async () => { + const backing = memStorage(); + let readsFail = false; + const storage: SessionStorageLike = { + getItem: (key) => { + if (readsFail) throw new Error(`cannot read ${key}`); + return backing.getItem(key); + }, + setItem: backing.setItem, + removeItem: backing.removeItem, + }; + const { session, location } = setup({ + storage, + prepareOAuthRedirect: () => { + readsFail = true; + throw new Error('snapshot failed'); + }, + }); + + await expect(session.beginOAuth('g')).rejects.toThrow('snapshot failed'); + expect(location.href).toBe('https://ch.example/sql'); + }); + + it('disarms and rolls back the OAuth attempt when assigning href fails', async () => { + const events: string[] = []; + const storage = memStorage({ + oauth_verifier: 'old-verifier', oauth_state: 'old-state', oauth_return_route: 'old-route', + }); + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '', + get href() { return 'https://ch.example/sql'; }, + set href(_value: string) { events.push('href'); throw new Error('redirect blocked'); }, + }; + const { session } = setup({ + storage, + location, + prepareOAuthRedirect: () => true, + armOAuthRedirectUnloadBypass: () => { + events.push('arm'); + return () => events.push('disarm'); + }, + }); + + await expect(session.beginOAuth('g')).rejects.toThrow('redirect blocked'); + + expect(events).toEqual(['arm', 'href', 'disarm']); + expect(storage.getItem('oauth_verifier')).toBe('old-verifier'); + expect(storage.getItem('oauth_state')).toBe('old-state'); + expect(storage.getItem('oauth_return_route')).toBe('old-route'); + }); + + it('replaces every OAuth attempt key on retry', async () => { + const { session, storage, location } = setup({ + storage: memStorage({ + oauth_verifier: 'stale-verifier', oauth_state: 'stale-state', oauth_return_route: 'stale-route', + }), + prepareOAuthRedirect: () => false, + }); + + await session.beginOAuth('g'); + const first = { + verifier: storage.getItem('oauth_verifier'), + state: storage.getItem('oauth_state'), + route: storage.getItem('oauth_return_route'), + }; + location.search = '?ws=next'; + await session.beginOAuth('g'); + + expect(storage.getItem('oauth_verifier')).not.toBe(first.verifier); + expect(storage.getItem('oauth_state')).not.toBe(first.state); + expect(storage.getItem('oauth_return_route')).not.toBe(first.route); + expect(JSON.parse(storage.getItem('oauth_return_route')!)).toEqual({ + state: storage.getItem('oauth_state'), search: '?ws=next', + }); + }); + + it('does not let an older config-delayed attempt roll back or disarm a newer redirect', async () => { + const oldDiscovery = deferred(); + const prepare = vi.fn(() => true); + const disarmNewer = vi.fn(); + const arm = vi.fn(() => disarmNewer); + const { session, storage, location, fetchMock } = setup({ + routes: [(url) => ( + url.includes('issuer.example/.well-known/openid-configuration') + ? oldDiscovery.promise + : null + )], + prepareOAuthRedirect: prepare, + armOAuthRedirectUnloadBypass: arm, + }); + const older = session.beginOAuth('g'); + await vi.waitFor(() => expect(fetchMock.calls.some( + (url) => url.includes('issuer.example/.well-known/openid-configuration'), + )).toBe(true)); + + location.search = '?ws=newer'; + await session.beginOAuth('basicidp'); + const newerAttempt = { + verifier: storage.getItem('oauth_verifier'), + state: storage.getItem('oauth_state'), + route: storage.getItem('oauth_return_route'), + href: location.href, + lifecycle: session.connection.value, + }; + + oldDiscovery.resolve(jsonResponse(200, { + authorization_endpoint: 'https://issuer.example/authorize', + token_endpoint: 'https://issuer.example/token', + })); + await expect(older).rejects.toThrow('Authentication attempt superseded'); + + expect(storage.getItem('oauth_verifier')).toBe(newerAttempt.verifier); + expect(storage.getItem('oauth_state')).toBe(newerAttempt.state); + expect(storage.getItem('oauth_return_route')).toBe(newerAttempt.route); + expect(location.href).toBe(newerAttempt.href); + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepare).toHaveBeenCalledWith(newerAttempt.state); + expect(arm).toHaveBeenCalledTimes(1); + expect(disarmNewer).not.toHaveBeenCalled(); + expect(session.connection.value).toBe(newerAttempt.lifecycle); + expect(session.connection.value).toEqual({ kind: 'reauthenticating', epoch: 2 }); + }); + + it('does not let a stale attempt mutate storage after the newer attempt rolls itself back', async () => { + const oldDiscovery = deferred(); + const backing = memStorage({ + oauth_verifier: 'original-verifier', + oauth_state: 'original-state', + oauth_return_route: 'original-route', + }); + const mutations: string[] = []; + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + if (key.startsWith('oauth_') && key !== 'oauth_idp') mutations.push(`set:${key}`); + backing.setItem(key, value); + }, + removeItem: (key) => { + if (key.startsWith('oauth_') && key !== 'oauth_idp') mutations.push(`remove:${key}`); + backing.removeItem(key); + }, + }; + const arm = vi.fn(() => vi.fn()); + const { session, fetchMock } = setup({ + storage, + routes: [(url) => ( + url.includes('issuer.example/.well-known/openid-configuration') + ? oldDiscovery.promise + : null + )], + prepareOAuthRedirect: () => { throw new Error('newer snapshot failed'); }, + armOAuthRedirectUnloadBypass: arm, + }); + const older = session.beginOAuth('g'); + await vi.waitFor(() => expect(fetchMock.calls.some( + (url) => url.includes('issuer.example/.well-known/openid-configuration'), + )).toBe(true)); + + await expect(session.beginOAuth('basicidp')).rejects.toThrow('newer snapshot failed'); + const mutationsAfterNewerRollback = mutations.length; + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 2 }); + + oldDiscovery.resolve(jsonResponse(200, { + authorization_endpoint: 'https://issuer.example/authorize', + token_endpoint: 'https://issuer.example/token', + })); + await expect(older).rejects.toThrow('Authentication attempt superseded'); + + expect(mutations).toHaveLength(mutationsAfterNewerRollback); + expect(backing.getItem('oauth_verifier')).toBe('original-verifier'); + expect(backing.getItem('oauth_state')).toBe('original-state'); + expect(backing.getItem('oauth_return_route')).toBe('original-route'); + expect(arm).not.toHaveBeenCalled(); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 2 }); + }); + + it('preserves the original auth-required prior when an overlapping retry fails', async () => { + const oldDiscovery = deferred(); + const { session, fetchMock } = setup({ + storage: memStorage({ oauth_id_token: validToken }), + routes: [(url) => ( + url.includes('issuer.example/.well-known/openid-configuration') + ? oldDiscovery.promise + : null + )], + prepareOAuthRedirect: () => { throw new Error('newer snapshot failed'); }, + }); + session.chCtx.onSignedOut('original authentication detail'); + expect(session.connection.value).toEqual({ + kind: 'auth-required', epoch: 1, detail: 'original authentication detail', + }); + + const older = session.beginOAuth('g'); + await vi.waitFor(() => expect(fetchMock.calls.some( + (url) => url.includes('issuer.example/.well-known/openid-configuration'), + )).toBe(true)); + await expect(session.beginOAuth('basicidp')).rejects.toThrow('newer snapshot failed'); + + expect(session.connection.value).toEqual({ + kind: 'auth-required', epoch: 3, detail: 'original authentication detail', + }); + oldDiscovery.resolve(jsonResponse(200, { + authorization_endpoint: 'https://issuer.example/authorize', + token_endpoint: 'https://issuer.example/token', + })); + await expect(older).rejects.toThrow('Authentication attempt superseded'); + expect(session.connection.value).toEqual({ + kind: 'auth-required', epoch: 3, detail: 'original authentication detail', + }); + }); + + it('keeps a reentrant newer attempt authoritative when an older storage write resumes', async () => { + const backing = memStorage({ + oauth_verifier: 'original-verifier', + oauth_state: 'original-state', + oauth_return_route: 'original-route', + }); + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '?ws=older', href: 'https://ch.example/sql', + }; + let session!: ReturnType; + let newer: Promise | undefined; + let startedNewer = false; + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: (key, value) => { + backing.setItem(key, value); + if (key === 'oauth_state' && !startedNewer) { + startedNewer = true; + location.search = '?ws=newer'; + newer = session.beginOAuth('basicidp'); + } + }, + removeItem: backing.removeItem, + }; + const prepare = vi.fn(() => true); + const disarmNewer = vi.fn(); + const arm = vi.fn(() => disarmNewer); + ({ session } = setup({ + storage, + location, + prepareOAuthRedirect: prepare, + armOAuthRedirectUnloadBypass: arm, + })); + + const older = session.beginOAuth('g'); + await expect(older).rejects.toThrow('Authentication attempt superseded'); + expect(newer).toBeDefined(); + await expect(newer!).resolves.toBeUndefined(); + + const state = backing.getItem('oauth_state'); + expect(JSON.parse(backing.getItem('oauth_return_route')!)).toEqual({ + state, search: '?ws=newer', + }); + expect(backing.getItem('oauth_verifier')).not.toBe('original-verifier'); + expect(state).not.toBe('original-state'); + expect(prepare).toHaveBeenCalledTimes(1); + expect(prepare).toHaveBeenCalledWith(state); + expect(arm).toHaveBeenCalledTimes(1); + expect(disarmNewer).not.toHaveBeenCalled(); + expect(session.connection.value).toEqual({ kind: 'reauthenticating', epoch: 2 }); + }); + + it('disarms a stale bypass when a returning href setter starts a newer attempt', async () => { + let href = 'https://ch.example/sql'; + let session!: ReturnType; + let newer: Promise | undefined; + let startedNewer = false; + const location = { + origin: 'https://ch.example', pathname: '/sql', search: '', + get href() { return href; }, + set href(value: string) { + href = value; + if (!startedNewer) { + startedNewer = true; + newer = session.beginOAuth('basicidp'); + } + }, + }; + const disarmOlder = vi.fn(); + const disarmNewer = vi.fn(); + const arm = vi.fn() + .mockImplementationOnce(() => disarmOlder) + .mockImplementationOnce(() => disarmNewer); + ({ session } = setup({ + location, + prepareOAuthRedirect: () => true, + armOAuthRedirectUnloadBypass: arm, + })); + + const older = session.beginOAuth('g'); + await expect(older).rejects.toThrow('Authentication attempt superseded'); + expect(disarmOlder).toHaveBeenCalledTimes(1); + expect(newer).toBeDefined(); + await expect(newer!).resolves.toBeUndefined(); + + expect(arm).toHaveBeenCalledTimes(2); + expect(disarmNewer).not.toHaveBeenCalled(); + expect(location.href).toContain('https://issuer2.example/authorize'); + expect(session.connection.value).toEqual({ kind: 'reauthenticating', epoch: 2 }); + }); + it('restores the prior lifecycle when redirect preparation fails', async () => { const { session } = setup({ + storage: memStorage({ oauth_id_token: validToken }), routes: [(url) => (url.endsWith('/config.json') ? jsonResponse(500, {}) : null)], }); await expect(session.beginOAuth('g')).rejects.toThrow(); @@ -726,6 +1328,49 @@ describe('signOut', () => { expect(session.chCtx.authConfirmed).toBe(false); expect(onAuthLost).not.toHaveBeenCalled(); }); + + it('clears document recovery only for explicit sign-out, not involuntary auth loss', () => { + const clearOAuthDocumentRecovery = vi.fn(); + const { session } = setup({ + storage: memStorage({ oauth_id_token: validToken }), + clearOAuthDocumentRecovery, + }); + + session.chCtx.onSignedOut('expired'); + expect(clearOAuthDocumentRecovery).not.toHaveBeenCalled(); + session.signOut(); + expect(clearOAuthDocumentRecovery).toHaveBeenCalledTimes(1); + }); + + it('still clears document recovery and stays signed out when auth storage cleanup throws', () => { + const backing = memStorage({ oauth_id_token: validToken }); + const storage: SessionStorageLike = { + getItem: backing.getItem, + setItem: backing.setItem, + removeItem: (key) => { + if (key === 'oauth_id_token') throw new Error('auth storage cleanup failed'); + backing.removeItem(key); + }, + }; + const clearOAuthDocumentRecovery = vi.fn(); + const { session } = setup({ storage, clearOAuthDocumentRecovery }); + + expect(() => session.signOut()).toThrow('auth storage cleanup failed'); + + expect(clearOAuthDocumentRecovery).toHaveBeenCalledTimes(1); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 1 }); + }); + + it('surfaces a recovery cleanup failure after credentials clear successfully', () => { + const { session } = setup({ + storage: memStorage({ oauth_id_token: validToken }), + clearOAuthDocumentRecovery: () => { throw new Error('recovery cleanup failed'); }, + }); + + expect(() => session.signOut()).toThrow('recovery cleanup failed'); + expect(session.token()).toBeNull(); + expect(session.connection.value).toEqual({ kind: 'signed-out', epoch: 1 }); + }); }); describe('chCtx.onSignedOut', () => { diff --git a/tests/unit/login.test.ts b/tests/unit/login.test.ts index b78bb5c..b9e0616 100644 --- a/tests/unit/login.test.ts +++ b/tests/unit/login.test.ts @@ -574,8 +574,8 @@ describe('mountInlineLogin', () => { expect(host.querySelector('.login-screen')).toBeNull(); expect(host.querySelector('.login-inline')).not.toBeNull(); expect(qsa(host, '.login-input')).toHaveLength(qsa(full.root, '.login-input').length); - expect(host.querySelector('.login-sso .login-btn')).toBeNull(); - expect(qs(host, '.login-inline-oauth-unavailable').textContent).toContain('Single sign-on'); + expect(qsa(host, '.login-sso .login-btn').map((button) => button.textContent)) + .toEqual(qsa(full.root, '.login-sso .login-btn').map((button) => button.textContent)); expect([...qs(host, '.login-picker').options].map((o) => o.textContent)) .toEqual([...qs(full.root, '.login-picker').options].map((o) => o.textContent)); expect(qs(host, '.login-brand-name').textContent).toBe(qs(full.root, '.login-brand-name').textContent); @@ -614,7 +614,7 @@ describe('mountInlineLogin', () => { expect(host.querySelector('.login-error')).toBeNull(); }); - it('keeps inline OAuth unavailable without replacing the surrounding root', async () => { + it('keeps the surrounding root while exposing an actionable inline SSO provider', async () => { const showLogin = vi.fn(); const login = vi.fn(async () => { throw new Error('redirect blocked'); }); const app = appWith({ @@ -629,9 +629,12 @@ describe('mountInlineLogin', () => { await tick(); expect(app.root.firstElementChild).toBe(sentinel); expect(showLogin).not.toHaveBeenCalled(); - expect(host.querySelector('.login-sso .login-btn')).toBeNull(); - expect(qs(host, '.login-inline-oauth-unavailable').getAttribute('role')).toBe('status'); - expect(login).not.toHaveBeenCalled(); + const sso = qs(host, '.login-sso .login-btn'); + expect(sso.textContent).toBe('Continue with Google'); + click(sso); + await tick(); + expect(login).toHaveBeenCalledWith('g'); + expect(app.root.firstElementChild).toBe(sentinel); }); it('prefills and reuses the exact prior Basic target during inline recovery', async () => { @@ -756,7 +759,100 @@ describe('mountInlineLogin', () => { expect(button.disabled).toBe(false); }); - it('does not expose OAuth navigation inline before the recovery checkpoint exists', async () => { + it('does not let a stale SSO failure overwrite or leave a newer recovery presentation redirecting', async () => { + let rejectFirst: ((reason?: unknown) => void) | undefined; + const login = vi.fn(() => new Promise((_resolve, reject) => { rejectFirst = reject; })); + const app = appWith({ + actions: { login }, + loadIdps: async () => ({ idps, basicLogin: true }), + }); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + await tick(); + const button = qs(host, '.login-sso .login-btn'); + + click(button); + expect(button.disabled).toBe(true); + expect(button.textContent).toBe('Redirecting…'); + handle.hide(); + handle.show('Credentials expired again'); + + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Continue with Google'); + expect(qs(host, '.login-error').textContent).toBe('Credentials expired again'); + + rejectFirst?.(new Error('old redirect failed')); + await tick(); + + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Continue with Google'); + expect(qs(host, '.login-error').textContent).toBe('Credentials expired again'); + }); + + it('keeps the current inline SSO presentation retryable after a redirect failure', async () => { + const login = vi.fn(async () => { throw new Error('redirect blocked'); }); + const app = appWith({ + actions: { login }, + loadIdps: async () => ({ idps: [{ id: 'g', label: 'Google' }], basicLogin: false }), + }); + const host = document.createElement('div'); + document.body.append(host); + const handle = mountInlineLogin(app, host); + await tick(); + const button = qs(host, '.login-sso .login-btn'); + + click(button); + await tick(); + + expect(login).toHaveBeenCalledOnce(); + expect(button.disabled).toBe(false); + expect(button.textContent).toBe('Continue with Google'); + expect(qs(host, '.login-error').textContent).toBe('redirect blocked'); + handle.focus(); + expect(document.activeElement).toBe(button); + + click(button); + await tick(); + expect(login).toHaveBeenCalledTimes(2); + handle.dispose(); + }); + + it('re-enables a pending certificate-gated OAuth action for a new inline recovery cycle', async () => { + const login = vi.fn(() => new Promise(() => {})); + const app = appWith({ + actions: { login }, + loadIdps: async () => ({ + idps, + basicLogin: false, + hosts: [{ + label: 'audit-oauth', + url: 'https://support-a.tenant-a.dev.altinity.cloud', + auth: 'oauth', + user: '', + password: '', + idp: 'g', + insecure: true, + }], + }), + }); + const host = document.createElement('div'); + const handle = mountInlineLogin(app, host); + await tick(); + selectHost(host, '0'); + const go = qs(host, '.login-cert-go'); + + click(go); + expect(go.disabled).toBe(true); + expect(login).toHaveBeenCalledOnce(); + + handle.hide(); + handle.show('Credentials expired again'); + expect(go.disabled).toBe(false); + expect(qs(host, '.login-error').textContent).toBe('Credentials expired again'); + handle.dispose(); + }); + + it('offers saved OAuth hosts inline now that recovery is checkpointed before navigation', async () => { const login = vi.fn(async () => {}); const app = appWith({ actions: { login }, @@ -773,12 +869,13 @@ describe('mountInlineLogin', () => { await tick(); expect(host.querySelector('.login-sso button')).toBeNull(); - expect(qs(host, '.login-picker-field').style.display).toBe('none'); + expect(qs(host, '.login-picker-field').style.display).toBe(''); expect(host.querySelector('.login-creds')).toBeNull(); - const message = qs(host, '.login-inline-oauth-unavailable'); - expect(message.getAttribute('role')).toBe('status'); - expect(message.textContent).toContain('Single sign-on'); - expect(login).not.toHaveBeenCalled(); + expect([...qs(host, '.login-picker').options].map((option) => option.textContent)) + .toEqual(['Choose a connection…', 'oauth-only (OAuth)']); + selectHost(host, '0'); + await tick(); + expect(login).toHaveBeenCalledWith('g', 'https://db.example'); }); it('hides, shows, and focuses the existing mount without rebuilding it', () => { diff --git a/tests/unit/main.test.ts b/tests/unit/main.test.ts index ff67bce..44d5f92 100644 --- a/tests/unit/main.test.ts +++ b/tests/unit/main.test.ts @@ -1,10 +1,20 @@ import { describe, it, expect, vi } from 'vitest'; import { bootstrap } from '../../src/main.js'; import type { BootstrapApp } from '../../src/main.js'; -import { newTabObj, tabPanel } from '../../src/state.js'; +import { newTabObj, SAVED_VIEWS, setTabSpecDraft, tabPanel } from '../../src/state.js'; import { signal } from '@preact/signals-core'; +import { + cloneJson, queryName, queryPanel, queryView, upgradeSavedQuery, +} from '../../src/core/saved-query.js'; +import { isQuerylessPanel } from '../../src/core/panel-cfg.js'; import type { BootstrapEnv } from '../../src/env.types.js'; import type { ResolvedIdpConfig } from '../../src/net/oauth-config.js'; +import type { State } from '../../src/ui/app.types.js'; +import { + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + encodeOAuthDocumentRecoveryValidatedCallback, +} from '../../src/core/oauth-document-recovery.js'; // Node's own global (no `@types/node` in this project — see dashboard.test.ts's // own note on the same constraint); this suite runs under Vitest/Node, where @@ -25,7 +35,10 @@ const valid = jwt({ email: 'me@x.com', exp: Math.floor(Date.now() / 1000) + 3600 const asLocation = (v: object): Location => v as Location; const asFetch = (v: object): typeof fetch => v as typeof fetch; -type FakeApp = BootstrapApp & { token: string | null }; +type FakeApp = BootstrapApp & { + token: string | null; + state: Pick; +}; // `conn` overrides are merged onto the default stub (not a full-object // replace) so a test can override e.g. just `isSignedIn` without losing the @@ -59,11 +72,49 @@ function fakeApp(over: Partial> & { conn?: Partial null), + // The real application owns recovery validation/consumption. Most bootstrap + // paths have no successful OAuth callback, and therefore never call this; + // the default result keeps successful-callback tests on the legacy-share + // fallback path unless they explicitly exercise recovery. + restoreOAuthDocumentRecovery: vi.fn(() => ({ kind: 'absent' })), + retryPendingOAuthDocumentRecovery: vi.fn(() => ({ kind: 'absent' })), + // Bootstrap owns handoff consumption; this fixture mirrors the real app's + // pure application step so the long-standing share compatibility cases + // remain bootstrap integration coverage rather than mock-only call checks. + consumeLegacyShared: vi.fn((allowRestore: boolean, encoded: string | null) => { + if (!allowRestore || encoded === null) return false; + let shared; + try { + const raw = JSON.parse(encoded) as Record; + shared = upgradeSavedQuery(raw.specVersion == null + ? { name: 'Shared query', ...raw } + : raw); + } catch { + return false; + } + const panel = queryPanel(shared); + if (!shared.sql && !panel) return false; + const tab = self.state.tabs.value[0]; + tab.sqlDraft = shared.sql; + tab.name = queryName(shared); + tab.specVersion = shared.specVersion; + setTabSpecDraft(tab, cloneJson(shared.spec)); + const launchView = queryView(shared); + const normalized = launchView === 'chart' ? 'panel' : launchView; + if (SAVED_VIEWS.has(normalized ?? '')) { + self.state.resultView.value = normalized as State['resultView']['value']; + } else if (!shared.sql && isQuerylessPanel(panel)) { + self.state.resultView.value = 'panel'; + } + return true; + }), ...rest, } as FakeApp; return self; } +const signedInApp = (): FakeApp => fakeApp({ token: valid, conn: { isSignedIn: () => true } }); + // `over` only ever supplies `location`/`fetch`/`opener` at real call sites below; // each is merged explicitly (not spread) so `history.replaceState` keeps its // concrete `Mock` type for direct `.mock.calls` inspection (one test below). @@ -154,6 +205,126 @@ describe('bootstrap', () => { expect(out.signedIn).toBe(true); }); + it('restores a marked pending recovery on token reload before render and suppresses shared content', async () => { + let renderedSql = ''; + const app = fakeApp({ + token: valid, + conn: { isSignedIn: () => true }, + loadWorkspaceOnBoot: vi.fn(async () => ({ key: 'recovery' })), + renderCurrentSurface: vi.fn(() => { renderedSql = app.state.tabs.value[0].sqlDraft; }), + }); + app.retryPendingOAuthDocumentRecovery = vi.fn(() => { + app.state.tabs.value[0].sqlDraft = 'SELECT pending recovery'; + return { + kind: 'restored', + finalization: 'checkpoint-retained', + warning: 'checkpoint-remove-failed', + } as const; + }); + const env = fakeEnv(); + env.sessionStorage.setItem('oauth_document_recovery', 'marked checkpoint fixture'); + env.sessionStorage.setItem( + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + encodeOAuthDocumentRecoveryValidatedCallback({ + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState: 'pending-state', + validatedAt: Date.now(), + }), + ); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT shared must lose', + specVersion: 1, + spec: { name: 'Shared query', favorite: false }, + })); + + await bootstrap(app, env); + + expect(renderedSql).toBe('SELECT pending recovery'); + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); + expect(app.restoreOAuthDocumentRecovery).not.toHaveBeenCalled(); + expect(app.consumeLegacyShared).toHaveBeenCalledWith(false, expect.any(String)); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); + expect(vi.mocked(app.loadWorkspaceOnBoot).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.retryPendingOAuthDocumentRecovery).mock.invocationCallOrder[0]); + expect(vi.mocked(app.retryPendingOAuthDocumentRecovery).mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.renderCurrentSurface).mock.invocationCallOrder[0]); + }); + + it('does not restore an ordinary checkpoint without the pending marker on token reload', async () => { + const app = fakeApp({ + token: valid, + conn: { isSignedIn: () => true }, + loadWorkspaceOnBoot: vi.fn(async () => ({ key: 'recovery' })), + }); + app.retryPendingOAuthDocumentRecovery = vi.fn(() => ({ kind: 'absent' } as const)); + const env = fakeEnv(); + env.sessionStorage.setItem('oauth_document_recovery', 'unmarked checkpoint fixture'); + + await bootstrap(app, env); + + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); + expect(app.restoreOAuthDocumentRecovery).not.toHaveBeenCalled(); + expect(app.state.tabs.value[0].sqlDraft).toBe(''); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('discards the shared handoff when pending recovery is deferred before publication', async () => { + let renderedSql = ''; + const app = fakeApp({ + token: valid, + conn: { isSignedIn: () => true }, + loadWorkspaceOnBoot: vi.fn(async () => ({ key: 'recovery' })), + renderCurrentSurface: vi.fn(() => { renderedSql = app.state.tabs.value[0].sqlDraft; }), + }); + app.retryPendingOAuthDocumentRecovery = vi.fn( + () => ({ kind: 'retry-deferred-retained' } as const), + ); + const env = fakeEnv(); + env.sessionStorage.setItem('oauth_document_recovery', 'retained checkpoint'); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT shared must never appear', + specVersion: 1, + spec: { name: 'Shared fallback', favorite: false }, + })); + + await expect(bootstrap(app, env)).resolves.toMatchObject({ signedIn: true }); + + expect(app.retryPendingOAuthDocumentRecovery).toHaveBeenCalledOnce(); + expect(app.consumeLegacyShared).toHaveBeenCalledWith(false, expect.any(String)); + expect(renderedSql).toBe(''); + expect(env.sessionStorage.getItem('oauth_document_recovery')).toBe('retained checkpoint'); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it('still renders deferred recovery authority when shared handoff cleanup fails', async () => { + const app = fakeApp({ + token: valid, + conn: { isSignedIn: () => true }, + loadWorkspaceOnBoot: vi.fn(async () => ({ key: 'recovery' })), + }); + app.retryPendingOAuthDocumentRecovery = vi.fn( + () => ({ kind: 'retry-deferred-retained' } as const), + ); + const env = fakeEnv(); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT retained handoff must not render', + specVersion: 1, + spec: { name: 'Suppressed share', favorite: false }, + })); + const removeItem = env.sessionStorage.removeItem; + env.sessionStorage.removeItem = vi.fn((key: string) => { + if (key === 'oauth_shared') throw new Error('raw cleanup failure'); + removeItem.call(env.sessionStorage, key); + }); + + await expect(bootstrap(app, env)).resolves.toMatchObject({ signedIn: true }); + + expect(app.consumeLegacyShared).toHaveBeenCalledWith(false, expect.any(String)); + expect(app.state.tabs.value[0].sqlDraft).toBe(''); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + it('exchanges the OAuth code on a valid callback', async () => { const app = fakeApp(); const env = fakeEnv({ @@ -164,10 +335,188 @@ describe('bootstrap', () => { env.sessionStorage.setItem('oauth_verifier', 'v'); await bootstrap(app, env); expect(app.conn.setTokens).toHaveBeenCalledWith(valid, undefined); + expect(app.restoreOAuthDocumentRecovery).toHaveBeenCalledWith('st'); + expect(app.retryPendingOAuthDocumentRecovery).not.toHaveBeenCalled(); expect(env.history.replaceState).toHaveBeenCalled(); expect(app.renderCurrentSurface).toHaveBeenCalled(); }); + it('loads the workspace, restores a successful callback recovery, then renders it before any shared placeholder', async () => { + let renderedSql = ''; + const restore = vi.fn(() => { + const tab = app.state.tabs.value[0]; + tab.sqlDraft = 'SELECT recovered'; + tab.name = 'Recovered draft'; + return { kind: 'restored', finalization: 'complete' } as const; + }); + const app = fakeApp({ + renderCurrentSurface: vi.fn(() => { renderedSql = app.state.tabs.value[0].sqlDraft; }), + }); + app.restoreOAuthDocumentRecovery = restore; + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?code=abc&state=st', origin: 'https://ch', pathname: '/sql', + search: '?code=abc&state=st', hash: '', + }), + fetch: asFetch(vi.fn(async () => ({ ok: true, json: async () => ({ id_token: valid }), text: async () => '' }))), + }); + env.sessionStorage.setItem('oauth_state', 'st'); + env.sessionStorage.setItem('oauth_verifier', 'v'); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT shared', specVersion: 1, spec: { name: 'Shared query', favorite: false }, + })); + + await bootstrap(app, env); + + expect(restore).toHaveBeenCalledWith('st'); + expect(app.state.tabs.value[0].sqlDraft).toBe('SELECT recovered'); + expect(renderedSql).toBe('SELECT recovered'); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); + expect(vi.mocked(app.loadWorkspaceOnBoot).mock.invocationCallOrder[0]) + .toBeLessThan(restore.mock.invocationCallOrder[0]); + expect(restore.mock.invocationCallOrder[0]) + .toBeLessThan(vi.mocked(app.renderCurrentSurface).mock.invocationCallOrder[0]); + }); + + it.each([ + 'spec-revalidation-failed', + 'checkpoint-remove-failed', + ] as const)('renders published recovery when %s finalization fails and never falls back to shared content', async (warning) => { + let renderedSql = ''; + const app = fakeApp({ + renderCurrentSurface: vi.fn(() => { renderedSql = app.state.tabs.value[0].sqlDraft; }), + }); + app.restoreOAuthDocumentRecovery = vi.fn(() => { + app.state.tabs.value[0].sqlDraft = 'SELECT recovered despite warning'; + return { kind: 'restored', finalization: 'checkpoint-retained', warning } as const; + }); + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?code=abc&state=st', origin: 'https://ch', pathname: '/sql', + search: '?code=abc&state=st', hash: '', + }), + fetch: asFetch(vi.fn(async () => ({ + ok: true, json: async () => ({ id_token: valid }), text: async () => '', + }))), + }); + env.sessionStorage.setItem('oauth_state', 'st'); + env.sessionStorage.setItem('oauth_verifier', 'v'); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT must not replace recovery', + specVersion: 1, + spec: { name: 'Shared query', favorite: false }, + })); + + await expect(bootstrap(app, env)).resolves.toMatchObject({ signedIn: true }); + + expect(renderedSql).toBe('SELECT recovered despite warning'); + expect(app.consumeLegacyShared).toHaveBeenCalledWith(false, expect.any(String)); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); + }); + + it('renders recovered tabs when storage-wide removal also rejects legacy handoff cleanup', async () => { + let renderedSql = ''; + const app = fakeApp({ + renderCurrentSurface: vi.fn(() => { renderedSql = app.state.tabs.value[0].sqlDraft; }), + }); + app.restoreOAuthDocumentRecovery = vi.fn(() => { + app.state.tabs.value[0].sqlDraft = 'SELECT retained recovery'; + return { + kind: 'restored', + finalization: 'checkpoint-retained', + warning: 'checkpoint-remove-failed', + } as const; + }); + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?code=abc&state=st', origin: 'https://ch', pathname: '/sql', + search: '?code=abc&state=st', hash: '', + }), + fetch: asFetch(vi.fn(async () => ({ + ok: true, json: async () => ({ id_token: valid }), text: async () => '', + }))), + }); + env.sessionStorage.setItem('oauth_state', 'st'); + env.sessionStorage.setItem('oauth_verifier', 'v'); + env.sessionStorage.setItem('oauth_document_recovery', 'retained-checkpoint'); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT must remain suppressed', + specVersion: 1, + spec: { name: 'Shared query', favorite: false }, + })); + env.sessionStorage.removeItem = vi.fn(() => { + throw new Error('storage removal unavailable'); + }); + + await expect(bootstrap(app, env)).resolves.toMatchObject({ signedIn: true }); + + expect(renderedSql).toBe('SELECT retained recovery'); + expect(app.consumeLegacyShared).toHaveBeenCalledWith(false, expect.any(String)); + expect(env.sessionStorage.getItem('oauth_document_recovery')).toBe('retained-checkpoint'); + expect(env.sessionStorage.getItem('oauth_shared')).not.toBeNull(); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + + it.each([ + { kind: 'absent' } as const, + { kind: 'invalid-cleared', reason: 'expired' } as const, + { kind: 'workspace-mismatch-cleared' } as const, + { kind: 'callback-mismatch' } as const, + ])('falls back to the legacy shared seed when recovery is $kind', async (result) => { + const restore = vi.fn(() => result); + const app = fakeApp(); + app.restoreOAuthDocumentRecovery = restore; + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?code=abc&state=st', origin: 'https://ch', pathname: '/sql', + search: '?code=abc&state=st', hash: '', + }), + fetch: asFetch(vi.fn(async () => ({ ok: true, json: async () => ({ id_token: valid }), text: async () => '' }))), + }); + env.sessionStorage.setItem('oauth_state', 'st'); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT shared', specVersion: 1, spec: { name: 'Shared query', favorite: false }, + })); + + await bootstrap(app, env); + + expect(restore).toHaveBeenCalledWith('st'); + expect(app.state.tabs.value[0].sqlDraft).toBe('SELECT shared'); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); + }); + + it('suppresses the shared handoff when a fresh callback retains recovery for an unavailable workspace', async () => { + const app = fakeApp({ + loadWorkspaceOnBoot: vi.fn(async () => null), + }); + app.restoreOAuthDocumentRecovery = vi.fn( + () => ({ kind: 'workspace-unavailable-retained' } as const), + ); + const env = fakeEnv({ + location: asLocation({ + href: 'https://ch/sql?code=abc&state=st', origin: 'https://ch', pathname: '/sql', + search: '?code=abc&state=st', hash: '', + }), + fetch: asFetch(vi.fn(async () => ({ + ok: true, json: async () => ({ id_token: valid }), text: async () => '', + }))), + }); + env.sessionStorage.setItem('oauth_state', 'st'); + env.sessionStorage.setItem('oauth_shared', JSON.stringify({ + sql: 'SELECT shared must not appear', + specVersion: 1, + spec: { name: 'Suppressed share', favorite: false }, + })); + + await expect(bootstrap(app, env)).resolves.toMatchObject({ signedIn: true }); + + expect(app.restoreOAuthDocumentRecovery).toHaveBeenCalledWith('st'); + expect(app.consumeLegacyShared).toHaveBeenCalledWith(false, expect.any(String)); + expect(app.state.tabs.value[0].sqlDraft).toBe(''); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); + expect(app.renderCurrentSurface).toHaveBeenCalledOnce(); + }); + it('restores the state-bound pre-login route before resolving a workspace', async () => { const app = fakeApp(); const env = fakeEnv({ @@ -220,6 +569,7 @@ describe('bootstrap', () => { env.sessionStorage.setItem('oauth_state', 'expected'); await bootstrap(app, env); expect(app.showLogin).toHaveBeenCalledWith('OAuth state mismatch — please try again.'); + expect(app.restoreOAuthDocumentRecovery).not.toHaveBeenCalled(); }); it('surfaces an IdP error callback with its description', async () => { @@ -229,6 +579,7 @@ describe('bootstrap', () => { }); await bootstrap(app, env); expect(app.showLogin).toHaveBeenCalledWith('Sign-in failed: User denied'); + expect(app.restoreOAuthDocumentRecovery).not.toHaveBeenCalled(); expect(env.history.replaceState).toHaveBeenCalled(); expect(app.renderCurrentSurface).not.toHaveBeenCalled(); }); @@ -251,6 +602,7 @@ describe('bootstrap', () => { env.sessionStorage.setItem('oauth_state', 'st'); await bootstrap(app, env); expect(app.showLogin).toHaveBeenCalledWith(expect.stringContaining('OAuth token exchange failed')); + expect(app.restoreOAuthDocumentRecovery).not.toHaveBeenCalled(); }); it('errors when the token response has no bearer', async () => { @@ -275,7 +627,7 @@ describe('bootstrap', () => { }); it('seeds the first tab from a legacy (SQL-only) share-link hash', async () => { - const app = fakeApp(); + const app = signedInApp(); const sql = 'SELECT 1'; const hash = '#' + btoa(unescape(encodeURIComponent(sql))); const env = fakeEnv({ location: asLocation({ href: 'https://ch/sql' + hash, origin: 'https://ch', pathname: '/sql', search: '', hash }) }); @@ -283,13 +635,11 @@ describe('bootstrap', () => { expect(app.state.tabs.value[0].sqlDraft).toBe('SELECT 1'); expect(app.state.tabs.value[0].name).toBe('Shared query'); expect(tabPanel(app.state.tabs.value[0])).toBeNull(); - expect(JSON.parse(env.sessionStorage.getItem('oauth_shared') ?? 'null')).toEqual({ - sql: 'SELECT 1', specVersion: 1, spec: { name: 'Shared query', favorite: false }, - }); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); }); it('seeds SQL + chart config from a tagged share-link hash', async () => { - const app = fakeApp(); + const app = signedInApp(); const chart = { cfg: { type: 'pie', x: 0, y: [1], series: null }, key: 'a:String|b:UInt64' }; const hash = '#' + btoa(unescape(encodeURIComponent(JSON.stringify({ __asb: 1, sql: 'SELECT a, b FROM t', chart })))); const env = fakeEnv({ location: asLocation({ href: 'https://ch/sql' + hash, origin: 'https://ch', pathname: '/sql', search: '', hash }) }); @@ -300,7 +650,7 @@ describe('bootstrap', () => { }); it('seeds a text panel from a share link with EMPTY SQL (#166 — the gate is sql || panel)', async () => { - const app = fakeApp(); + const app = signedInApp(); const panel = { cfg: { type: 'text', content: '# Note' } }; const hash = '#' + btoa(unescape(encodeURIComponent(JSON.stringify({ __asb: 1, sql: '', panel })))); const env = fakeEnv({ location: asLocation({ href: 'https://ch/sql' + hash, origin: 'https://ch', pathname: '/sql', search: '', hash }) }); @@ -309,10 +659,7 @@ describe('bootstrap', () => { expect(app.state.tabs.value[0].sqlDraft).toBe(''); expect(tabPanel(app.state.tabs.value[0])).toEqual(panel); expect(app.state.resultView.value).toBe('panel'); - expect(JSON.parse(env.sessionStorage.getItem('oauth_shared') ?? 'null')).toEqual({ - sql: '', specVersion: 1, - spec: { name: 'Shared query', favorite: false, panel }, - }); + expect(env.sessionStorage.getItem('oauth_shared')).toBeNull(); }); // v2 share hash: { __asb: 2, query: { sql, specVersion, spec } } (src/core/share.js). @@ -329,7 +676,7 @@ describe('bootstrap', () => { // view is the only thing that selects the drawer — even alongside a non-panel // role, and even when that role would once have overridden it. it('restores the persisted view of a shared query that also carries a non-panel role', async () => { - const app = fakeApp(); + const app = signedInApp(); const panelCfg = { cfg: { type: 'kpi' } }; const env = v2Env({ sql: 'SELECT 1', @@ -343,7 +690,7 @@ describe('bootstrap', () => { }); it('leaves the default view alone for a share carrying a non-panel role and NO persisted view', async () => { - const app = fakeApp(); + const app = signedInApp(); const env = v2Env({ sql: 'SELECT 1', spec: { name: 'Shared query', favorite: false, dashboard: { role: 'setup' } } }); await bootstrap(app, env); expect(app.state.tabs.value[0].sqlDraft).toBe('SELECT 1'); @@ -351,7 +698,7 @@ describe('bootstrap', () => { }); it('restores a SQL-bearing shared Panel query\'s persisted view:"panel" (no role)', async () => { - const app = fakeApp(); + const app = signedInApp(); const panelCfg = { cfg: { type: 'kpi' } }; const env = v2Env({ sql: 'SELECT 1', spec: { name: 'Shared query', favorite: false, view: 'panel', panel: panelCfg } }); await bootstrap(app, env); @@ -359,14 +706,14 @@ describe('bootstrap', () => { }); it.each(['table', 'json'])('restores a SQL-bearing shared query\'s persisted %s preference', async (view) => { - const app = fakeApp(); + const app = signedInApp(); const env = v2Env({ sql: 'SELECT 1', spec: { name: 'Shared query', favorite: false, view } }); await bootstrap(app, env); expect(app.state.resultView.value).toBe(view); }); it('leaves the default result view alone for a share with no role and no persisted view', async () => { - const app = fakeApp(); + const app = signedInApp(); const env = v2Env({ sql: 'SELECT 1' }); await bootstrap(app, env); expect(app.state.resultView.value).toBe('table'); // fakeApp()'s untouched default @@ -383,7 +730,7 @@ describe('bootstrap', () => { }); it('maps a legacy persisted view:"chart" through the Panel compatibility path in a share', async () => { - const app = fakeApp(); + const app = signedInApp(); const panelCfg = { cfg: { type: 'pie', x: 0, y: [1], series: null } }; const env = v2Env({ sql: 'SELECT 1', spec: { name: 'Shared query', favorite: false, view: 'chart', panel: panelCfg } }); await bootstrap(app, env); @@ -393,7 +740,7 @@ describe('bootstrap', () => { it('ignores an out-of-enum spec.view from a crafted share, keeping the default (#266)', async () => { // The v2 tagged decode passes `spec.view` through verbatim, so a share link // can carry any string; it must not reach the resultView signal. - const app = fakeApp(); + const app = signedInApp(); const env = v2Env({ sql: 'SELECT 1', spec: { name: 'Shared query', favorite: false, view: 'javascript:alert(1)' } }); await bootstrap(app, env); expect(app.state.tabs.value[0].sqlDraft).toBe('SELECT 1'); // the share still seeds diff --git a/tests/unit/oauth-document-recovery-session.test.ts b/tests/unit/oauth-document-recovery-session.test.ts new file mode 100644 index 0000000..5f52870 --- /dev/null +++ b/tests/unit/oauth-document-recovery-session.test.ts @@ -0,0 +1,748 @@ +import { describe, expect, it, vi } from 'vitest'; +import { signal } from '@preact/signals-core'; +import { + OAUTH_DOCUMENT_RECOVERY_KEY, + OAUTH_DOCUMENT_RECOVERY_TTL_MS, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + OAUTH_DOCUMENT_RECOVERY_VERSION, + encodeOAuthDocumentRecovery, + encodeOAuthDocumentRecoveryValidatedCallback, + type OAuthDocumentRecoverySnapshot, +} from '../../src/core/oauth-document-recovery.js'; +import { + createOAuthDocumentRecoverySession, + type OAuthDocumentRecoverySessionDeps, + type OAuthDocumentRecoveryState, + type OAuthDocumentRecoveryStorage, +} from '../../src/application/oauth-document-recovery-session.js'; +import { newTabObj, type QueryTab, type SpecValidationService } from '../../src/state.js'; +import type { SavedQueryV2, StoredWorkspaceV5 } from '../../src/generated/json-schema.types.js'; + +const NOW = 1_700_000_000_000; +const validators: SpecValidationService = { validate: () => [] }; + +function storage(initial: Record = {}): OAuthDocumentRecoveryStorage & { values: Map } { + const values = new Map(Object.entries(initial)); + return { + values, + getItem: (key) => values.get(key) ?? null, + setItem: (key, value) => { values.set(key, value); }, + removeItem: (key) => { values.delete(key); }, + }; +} + +function state(tabs: QueryTab[] = [newTabObj('t1')]): OAuthDocumentRecoveryState { + return { + tabs: signal(tabs), activeTabId: signal(tabs[0]?.id ?? 't1'), nextTabId: 2, + workspaceId: 'w1', workspaceKey: 'team', + }; +} + +function workspace(queries: SavedQueryV2[] = []): StoredWorkspaceV5 { + return { storageVersion: 5, id: 'w1', key: 'team', name: 'Team', queries, dashboards: [] } as StoredWorkspaceV5; +} + +function deps(over: Partial = {}): OAuthDocumentRecoverySessionDeps { + return { + storage: storage(), now: () => NOW, state: state(), specValidators: validators, ...over, + }; +} + +function snapshot(over: Partial = {}): OAuthDocumentRecoverySnapshot { + return { + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt: NOW, + workspaceId: 'w1', workspaceKey: 'team', oauthState: 'state-1', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Recovered', sqlDraft: 'SELECT 1', + specText: '{"name": ', specVersion: 1, editorMode: 'sql', dirtySql: true, + dirtySpec: true, savedId: 'q1', lastCommittedQueryToken: 'old', externalState: 'conflict', + }], + activeTabId: 't1', nextTabId: 2, + ...over, + }; +} + +function saveSnapshot(store: OAuthDocumentRecoveryStorage, value = snapshot()): void { + store.setItem(OAUTH_DOCUMENT_RECOVERY_KEY, encodeOAuthDocumentRecovery(value)); +} + +function saveMarker( + store: OAuthDocumentRecoveryStorage, + oauthState = 'state-1', + validatedAt = NOW, +): void { + store.setItem( + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + encodeOAuthDocumentRecoveryValidatedCallback({ + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState, + validatedAt, + }), + ); +} + +const saved = (id: string, sql: string, name = id): SavedQueryV2 => ( + { id, sql, specVersion: 1, spec: { name, favorite: false } } as SavedQueryV2 +); + +describe('OAuthDocumentRecoverySession.prepare', () => { + it('captures every authored tab in order when a query tab is save-dirty', () => { + const query = newTabObj('t1'); + query.name = 'Draft'; query.sqlDraft = 'SELECT draft'; query.specText = '{bad json'; + query.dirtySql = true; query.savedId = 'q1'; query.lastCommittedQueryToken = 'token'; + query.externalState = 'deleted'; + query.result = { rows: ['not recoverable'] }; query.chSession = 'ch-session'; + query.lastSuccessfulResultColumns = [{ name: 'x', type: 'UInt8' }]; + const variable = newTabObj('t2'); + variable.doc = { kind: 'dashboard-variable', dashboardId: 'dash', variableName: 'region' }; + variable.name = 'Region'; variable.sqlDraft = 'SELECT region'; variable.specText = 'verbatim'; + variable.dirtySpec = true; variable.editorMode = 'sql'; + const s = state([query, variable]); s.activeTabId.value = 't2'; s.nextTabId = 3; + const store = storage(); + + expect(createOAuthDocumentRecoverySession(deps({ state: s, storage: store })).prepare('attempt')).toBe(true); + const recovered = JSON.parse(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY) ?? '') as OAuthDocumentRecoverySnapshot; + expect(recovered).toMatchObject({ oauthState: 'attempt', createdAt: NOW, activeTabId: 't2', nextTabId: 3 }); + expect(recovered.tabs).toEqual([ + expect.objectContaining({ id: 't1', sqlDraft: 'SELECT draft', specText: '{bad json', savedId: 'q1', lastCommittedQueryToken: 'token' }), + expect.objectContaining({ id: 't2', doc: { kind: 'dashboard-variable', dashboardId: 'dash', variableName: 'region' }, dirtySpec: true }), + ]); + expect(recovered.tabs[0]).not.toHaveProperty('result'); + expect(recovered.tabs[0]).not.toHaveProperty('chSession'); + }); + + it('does not checkpoint a variable dirty only in Spec, but retains it when another tab needs saving', () => { + const variable = newTabObj('t2'); + variable.doc = { kind: 'dashboard-variable', dashboardId: 'dash', variableName: 'region' }; + variable.specText = 'must survive'; variable.dirtySpec = true; + const cleanStore = storage(); + expect(createOAuthDocumentRecoverySession(deps({ state: state([variable]), storage: cleanStore })).prepare('s')).toBe(false); + expect(cleanStore.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + + const query = newTabObj('t1'); query.dirtySql = true; + const fullStore = storage(); + const fullState = state([query, variable]); fullState.nextTabId = 3; + expect(createOAuthDocumentRecoverySession(deps({ state: fullState, storage: fullStore })).prepare('s')).toBe(true); + const recovered = JSON.parse(fullStore.getItem(OAUTH_DOCUMENT_RECOVERY_KEY) ?? '') as OAuthDocumentRecoverySnapshot; + expect(recovered.tabs[1]).toMatchObject({ specText: 'must survive', dirtySpec: true }); + }); + + it('does not write when the session is clean and no retained payload exists', () => { + const store = storage(); + const setItem = vi.spyOn(store, 'setItem'); + expect(createOAuthDocumentRecoverySession(deps({ storage: store })).prepare('attempt')).toBe(false); + expect(setItem).not.toHaveBeenCalled(); + }); + + it('rebinds a valid retained payload only to the retry state and time', () => { + const original = snapshot({ createdAt: NOW - 10, tabs: [ + { ...snapshot().tabs[0], id: 't1' }, + ] }); + const store = storage(); saveSnapshot(store, original); + const session = createOAuthDocumentRecoverySession(deps({ storage: store, now: () => NOW + 100 })); + + expect(session.prepare('retry')).toBe(true); + expect(JSON.parse(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY) ?? '')).toEqual({ + ...original, oauthState: 'retry', createdAt: NOW + 100, + }); + }); + + it('invalidates prior callback authority only after a fresh or rebound checkpoint write succeeds', () => { + const dirty = newTabObj('t1'); dirty.dirtySql = true; + const freshStore = storage(); saveMarker(freshStore, 'old'); + const fresh = createOAuthDocumentRecoverySession(deps({ + storage: freshStore, + state: state([dirty]), + })); + expect(fresh.prepare('fresh')).toBe(true); + expect(freshStore.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + + const reboundStore = storage(); saveSnapshot(reboundStore); saveMarker(reboundStore); + const rebound = createOAuthDocumentRecoverySession(deps({ storage: reboundStore })); + expect(rebound.prepare('retry')).toBe(true); + expect(reboundStore.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + + const failedStore = storage(); saveSnapshot(failedStore); saveMarker(failedStore); + const priorCheckpoint = failedStore.getItem(OAUTH_DOCUMENT_RECOVERY_KEY); + failedStore.setItem = vi.fn(() => { throw new Error('storage full'); }); + const failed = createOAuthDocumentRecoverySession(deps({ storage: failedStore })); + expect(() => failed.prepare('never-authorized')).toThrow('storage full'); + expect(failedStore.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(priorCheckpoint); + expect(failedStore.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + }); + + it('surfaces marker invalidation failure only after the rebound checkpoint is durable', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const removeItem = store.removeItem; + store.removeItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('marker cleanup failed'); + } + removeItem(key); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + + expect(() => session.prepare('new-state')).toThrow('marker cleanup failed'); + expect(JSON.parse(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY) ?? '')).toMatchObject({ + oauthState: 'new-state', + }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + }); + + it('clears an invalid retained payload rather than rebinding it', () => { + const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: '{bad' }); + expect(createOAuthDocumentRecoverySession(deps({ storage: store })).prepare('retry')).toBe(false); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + }); + + it('lets a failed write throw and leaves a prior payload untouched', () => { + const old = encodeOAuthDocumentRecovery(snapshot({ oauthState: 'old' })); + const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: old }); + store.setItem = vi.fn(() => { throw new Error('storage full'); }); + const tab = newTabObj('t1'); tab.dirtySql = true; + const session = createOAuthDocumentRecoverySession(deps({ storage: store, state: state([tab]) })); + + expect(() => session.prepare('new')).toThrow('storage full'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(old); + }); +}); + +describe('OAuthDocumentRecoverySession.restore', () => { + it('keeps an absent session untouched and preserves a state-mismatched retry payload', () => { + const s = state(); const store = storage(); const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store })); + expect(session.restore('state-1', workspace())).toEqual({ kind: 'absent' }); + const before = s.tabs.value; + saveSnapshot(store); + expect(session.restore('old-state', workspace())).toEqual({ kind: 'callback-mismatch' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + expect(s.tabs.value).toBe(before); + }); + + it('clears invalid or workspace-mismatched payloads without publishing any state', () => { + const s = state(); const initialTabs = s.tabs.value; const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: '{bad' }); + const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store })); + expect(session.restore('state-1', null)).toEqual({ kind: 'invalid-cleared', reason: 'malformed' }); + expect(s.tabs.value).toBe(initialTabs); + saveSnapshot(store); + expect(session.restore('state-1', { ...workspace(), id: 'other' })).toEqual({ kind: 'workspace-mismatch-cleared' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(s.tabs.value).toBe(initialTabs); + saveSnapshot(store); + expect(session.restore('state-1', { ...workspace(), key: 'other' })).toEqual({ kind: 'workspace-mismatch-cleared' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + }); + + it('retains a valid checkpoint and callback authority while unavailable, then retries in-session', () => { + const checkpoint = encodeOAuthDocumentRecovery(snapshot()); + const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const s = state(); + const before = s.tabs.value; + const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store })); + + expect(session.restore('state-1', null)).toEqual({ kind: 'workspace-unavailable-retained' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(JSON.parse( + store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) ?? '', + )).toEqual({ + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState: 'state-1', + validatedAt: NOW, + }); + expect(s.tabs.value).toBe(before); + + expect(session.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(before); + expect(s.tabs.value[0]).toMatchObject({ name: 'Recovered', sqlDraft: 'SELECT 1' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it('persists unavailable callback authority best-effort while retaining same-session retry', () => { + const checkpoint = encodeOAuthDocumentRecovery(snapshot()); + const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const setItem = store.setItem; + store.setItem = vi.fn((key, value) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('marker storage unavailable'); + } + setItem(key, value); + }); + const s = state(); + const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store })); + + expect(session.restore('state-1', null)).toEqual({ kind: 'workspace-unavailable-retained' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + expect(session.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(s.tabs.value[0].sqlDraft).toBe('SELECT 1'); + }); + + it('reloads pending callback authority from its marker, never from a checkpoint alone', () => { + const checkpoint = encodeOAuthDocumentRecovery(snapshot()); + const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const first = createOAuthDocumentRecoverySession(deps({ storage: store })); + expect(first.restore('state-1', null)).toEqual({ kind: 'workspace-unavailable-retained' }); + + const reloadedState = state(); + const reloaded = createOAuthDocumentRecoverySession(deps({ + storage: store, + state: reloadedState, + })); + expect(reloaded.retryPending(null)).toEqual({ kind: 'workspace-unavailable-retained' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + expect(reloaded.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(reloadedState.tabs.value[0].sqlDraft).toBe('SELECT 1'); + + saveSnapshot(store); + const noMarkerState = state(); + const noMarker = createOAuthDocumentRecoverySession(deps({ + storage: store, + state: noMarkerState, + })); + const before = noMarkerState.tabs.value; + expect(noMarker.retryPending(workspace())).toEqual({ kind: 'absent' }); + expect(noMarkerState.tabs.value).toBe(before); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + }); + + it('retires persisted authority before publication and defers safely when removal fails', () => { + const checkpoint = encodeOAuthDocumentRecovery(snapshot()); + const store = storage({ [OAUTH_DOCUMENT_RECOVERY_KEY]: checkpoint }); + const s = state(); + const before = s.tabs.value; + const session = createOAuthDocumentRecoverySession(deps({ storage: store, state: s })); + expect(session.restore('state-1', null)).toEqual({ kind: 'workspace-unavailable-retained' }); + const removeItem = store.removeItem; + store.removeItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('marker removal unavailable'); + } + removeItem(key); + }); + + expect(session.retryPending(workspace())).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(before); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + + store.removeItem = removeItem; + expect(session.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(before); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it('verifies marker absence and defers when a removal reports success without retiring it', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const s = state(); + const before = s.tabs.value; + store.removeItem = vi.fn(); + const session = createOAuthDocumentRecoverySession(deps({ storage: store, state: s })); + + expect(session.retryPending(workspace())).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(before); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + }); + + it('cannot resurrect a published retry in this document or a reloaded session', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const publishedState = state(); + const published = createOAuthDocumentRecoverySession(deps({ + storage: store, + state: publishedState, + })); + + expect(published.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(publishedState.tabs.value[0].sqlDraft).toBe('SELECT 1'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + // Simulate shell finalization retaining the checkpoint: no consume(). + const publishedTabs = publishedState.tabs.value; + expect(published.retryPending(workspace())).toEqual({ kind: 'absent' }); + expect(publishedState.tabs.value).toBe(publishedTabs); + + const reloadedState = state(); + const reloadedTabs = reloadedState.tabs.value; + const reloaded = createOAuthDocumentRecoverySession(deps({ + storage: store, + state: reloadedState, + })); + expect(reloaded.retryPending(workspace())).toEqual({ kind: 'absent' }); + expect(reloadedState.tabs.value).toBe(reloadedTabs); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + }); + + it('fails closed on pending-marker reads and remains retryable after storage recovers', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const s = state(); + const before = s.tabs.value; + const getItem = store.getItem; + store.getItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('marker read unavailable'); + } + return getItem(key); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store, state: s })); + + expect(session.retryPending(workspace())).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(before); + expect(store.values.get(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeDefined(); + expect(store.values.get(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeDefined(); + + store.getItem = getItem; + expect(session.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(before); + }); + + it('restores in-memory authority when its pre-publication marker read fails', () => { + const store = storage(); saveSnapshot(store); + const s = state(); + const before = s.tabs.value; + const session = createOAuthDocumentRecoverySession(deps({ storage: store, state: s })); + expect(session.restore('state-1', null)).toEqual({ kind: 'workspace-unavailable-retained' }); + const getItem = store.getItem; + store.getItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('verification unavailable'); + } + return getItem(key); + }); + + expect(session.retryPending(workspace())).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(before); + store.getItem = getItem; + expect(session.retryPending(workspace())).toEqual({ kind: 'restored' }); + }); + + it('prunes malformed or expired markers without consuming their checkpoint', () => { + for (const marker of [ + '{bad', + encodeOAuthDocumentRecoveryValidatedCallback({ + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState: 'state-1', + validatedAt: NOW - OAUTH_DOCUMENT_RECOVERY_TTL_MS - 1, + }), + ]) { + const store = storage({ + [OAUTH_DOCUMENT_RECOVERY_KEY]: encodeOAuthDocumentRecovery(snapshot()), + [OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY]: marker, + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + expect(session.retryPending(workspace())).toEqual({ kind: 'absent' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + } + }); + + it('expires same-session in-memory authority and prunes its persisted marker', () => { + let current = NOW; + const store = storage(); saveSnapshot(store); + const session = createOAuthDocumentRecoverySession(deps({ + storage: store, + now: () => current, + })); + expect(session.restore('state-1', null)).toEqual({ kind: 'workspace-unavailable-retained' }); + + current += OAUTH_DOCUMENT_RECOVERY_TTL_MS + 1; + expect(session.retryPending(workspace())).toEqual({ kind: 'absent' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + }); + + it('clears a state-mismatched marker only, and clears both records on proven workspace mismatch', () => { + const stateMismatch = storage(); saveSnapshot(stateMismatch); saveMarker(stateMismatch, 'other'); + const staleMarker = createOAuthDocumentRecoverySession(deps({ storage: stateMismatch })); + expect(staleMarker.retryPending(workspace())).toEqual({ kind: 'callback-mismatch' }); + expect(stateMismatch.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + expect(stateMismatch.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + + const workspaceMismatch = storage(); saveSnapshot(workspaceMismatch); saveMarker(workspaceMismatch); + const wrongWorkspace = createOAuthDocumentRecoverySession(deps({ storage: workspaceMismatch })); + expect(wrongWorkspace.retryPending({ ...workspace(), id: 'other' })) + .toEqual({ kind: 'workspace-mismatch-cleared' }); + expect(workspaceMismatch.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(workspaceMismatch.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it('clears unsupported and expired payloads before any state publication', () => { + const s = state(); const before = s.tabs.value; const store = storage(); + const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store })); + store.setItem(OAUTH_DOCUMENT_RECOVERY_KEY, JSON.stringify({ ...snapshot(), version: 99 })); + expect(session.restore('state-1', workspace())).toEqual({ kind: 'invalid-cleared', reason: 'unsupported' }); + expect(s.tabs.value).toBe(before); + saveSnapshot(store, snapshot({ createdAt: NOW - (15 * 60 * 1000) - 1 })); + expect(session.restore('state-1', workspace())).toEqual({ kind: 'invalid-cleared', reason: 'expired' }); + expect(s.tabs.value).toBe(before); + }); + + it('retains fresh validated authority when reconciliation persistently fails, then retries', () => { + const store = storage(); + saveSnapshot(store, snapshot({ tabs: [{ + ...snapshot().tabs[0], dirtySql: false, dirtySpec: false, savedId: 'q1', lastCommittedQueryToken: 'old', + }] })); + const s = state(); const beforeTabs = s.tabs.value; const beforeActive = s.activeTabId.value; const beforeNext = s.nextTabId; + let validatorAvailable = false; + const throwingValidators: SpecValidationService = { + validate: () => { + if (!validatorAvailable) throw new Error('validator failed'); + return []; + }, + }; + const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store, specValidators: throwingValidators })); + const latest = workspace([saved('q1', 'SELECT latest')]); + + expect(session.restore('state-1', latest)).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(beforeTabs); + expect(s.activeTabId.value).toBe(beforeActive); + expect(s.nextTabId).toBe(beforeNext); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + expect(session.retryPending(latest)).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(beforeTabs); + + validatorAvailable = true; + expect(session.retryPending(latest)).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(beforeTabs); + expect(s.tabs.value[0].sqlDraft).toBe('SELECT latest'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it('keeps persisted pending authority byte-for-byte when reconciliation fails before publication', () => { + const store = storage(); + saveSnapshot(store, snapshot({ tabs: [{ + ...snapshot().tabs[0], dirtySql: false, dirtySpec: false, + savedId: 'q1', lastCommittedQueryToken: 'old', + }] })); + saveMarker(store); + const marker = store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + const s = state(); + const beforeTabs = s.tabs.value; + let validatorAvailable = false; + const throwingValidators: SpecValidationService = { + validate: () => { + if (!validatorAvailable) throw new Error('persistent validator failure'); + return []; + }, + }; + const session = createOAuthDocumentRecoverySession(deps({ + state: s, + storage: store, + specValidators: throwingValidators, + })); + const latest = workspace([saved('q1', 'SELECT latest')]); + + expect(session.retryPending(latest)).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(beforeTabs); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBe(marker); + + validatorAvailable = true; + expect(session.retryPending(latest)).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(beforeTabs); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it.each([ + ['query SQL', (tab: QueryTab) => { tab.dirtySql = true; }], + ['query Spec', (tab: QueryTab) => { tab.dirtySpec = true; }], + ['dashboard-variable SQL', (tab: QueryTab) => { + tab.doc = { kind: 'dashboard-variable', dashboardId: 'd1', variableName: 'region' }; + tab.dirtySql = true; + }], + ])('defers a pending retry while live %s work is save-dirty', (_label, makeDirty) => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const checkpoint = store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY); + const marker = store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY); + const live = newTabObj('t1'); + live.sqlDraft = 'SELECT live'; + makeDirty(live); + const s = state([live]); + const beforeTabs = s.tabs.value; + const validate = vi.fn(() => []); + const session = createOAuthDocumentRecoverySession(deps({ + storage: store, + state: s, + specValidators: { validate }, + })); + + expect(session.retryPending(workspace([saved('q1', 'SELECT latest')]))).toEqual({ + kind: 'retry-deferred-retained', + }); + expect(validate).not.toHaveBeenCalled(); + expect(s.tabs.value).toBe(beforeTabs); + expect(s.tabs.value[0].sqlDraft).toBe('SELECT live'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBe(marker); + + live.dirtySql = false; + live.dirtySpec = false; + expect(session.retryPending(workspace([saved('q1', 'SELECT latest')]))).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(beforeTabs); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it('retains fresh callback authority when checkpoint storage access fails, then retries', () => { + const store = storage(); saveSnapshot(store); + const checkpoint = store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY); + const s = state(); + const beforeTabs = s.tabs.value; + const getItem = store.getItem; + store.getItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_KEY) throw new Error('checkpoint read unavailable'); + return getItem(key); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store, state: s })); + + expect(session.restore('state-1', workspace())).toEqual({ kind: 'retry-deferred-retained' }); + expect(s.tabs.value).toBe(beforeTabs); + expect(store.values.get(OAUTH_DOCUMENT_RECOVERY_KEY)).toBe(checkpoint); + expect(store.values.get(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeDefined(); + + store.getItem = getItem; + expect(session.retryPending(workspace())).toEqual({ kind: 'restored' }); + expect(s.tabs.value).not.toBe(beforeTabs); + expect(s.tabs.value[0].sqlDraft).toBe('SELECT 1'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + }); + + it('restores ordering/navigation and authored fields while leaving execution transients at defaults', () => { + const recovered = snapshot({ + tabs: [ + { ...snapshot().tabs[0], id: 't1', savedId: null, externalState: null }, + { id: 't2', doc: { kind: 'dashboard-variable', dashboardId: 'd1', variableName: 'zone' }, name: 'Zone', sqlDraft: 'SELECT zone', specText: '{invalid', specVersion: 1, editorMode: 'sql', dirtySql: true, dirtySpec: true, savedId: null }, + ], activeTabId: 't2', nextTabId: 3, + }); + const store = storage(); saveSnapshot(store, recovered); + const s = state(); + const session = createOAuthDocumentRecoverySession(deps({ state: s, storage: store })); + + expect(session.restore('state-1', workspace())).toEqual({ kind: 'restored' }); + expect(s.tabs.value.map((tab) => tab.id)).toEqual(['t1', 't2']); + expect(s.activeTabId.value).toBe('t2'); expect(s.nextTabId).toBe(3); + expect(s.tabs.value[1]).toMatchObject({ doc: { kind: 'dashboard-variable', dashboardId: 'd1', variableName: 'zone' }, specText: '{invalid', dirtySql: true, dirtySpec: true }); + for (const tab of s.tabs.value) { + expect(tab.result).toBeNull(); + expect(tab.chSession).toBeUndefined(); + expect(tab.lastSuccessfulResultColumns).toEqual([]); + expect(tab.specDiagnostics).toEqual([]); + } + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + session.consume(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + session.clear(); + }); + + it('consume removes checkpoint before marker and retains authority when checkpoint removal fails', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const calls: string[] = []; + const removeItem = store.removeItem; + store.removeItem = vi.fn((key) => { + calls.push(key); + if (key === OAUTH_DOCUMENT_RECOVERY_KEY) throw new Error('checkpoint removal failed'); + removeItem(key); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + + expect(() => session.consume()).toThrow('checkpoint removal failed'); + expect(calls).toEqual([OAUTH_DOCUMENT_RECOVERY_KEY]); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).not.toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + }); + + it('prunes an orphan callback marker without treating it as a recoverable document', () => { + const store = storage(); saveMarker(store); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + + expect(session.retryPending(workspace())).toEqual({ kind: 'absent' }); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + }); + + it('consume may leave only a harmless marker when its second removal fails', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const removeItem = store.removeItem; + store.removeItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('marker removal failed'); + } + removeItem(key); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + + expect(() => session.consume()).toThrow('marker removal failed'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + }); + + it('clear attempts both removals and preserves the checkpoint error as primary', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const calls: string[] = []; + store.removeItem = vi.fn((key) => { + calls.push(key); + throw new Error(key === OAUTH_DOCUMENT_RECOVERY_KEY + ? 'checkpoint clear failed' + : 'marker clear failed'); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + + expect(() => session.clear()).toThrow('checkpoint clear failed'); + expect(calls).toEqual([ + OAUTH_DOCUMENT_RECOVERY_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + ]); + }); + + it('clear reports a marker removal failure after checkpoint cleanup succeeds', () => { + const store = storage(); saveSnapshot(store); saveMarker(store); + const removeItem = store.removeItem; + store.removeItem = vi.fn((key) => { + if (key === OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) { + throw new Error('marker clear failed'); + } + removeItem(key); + }); + const session = createOAuthDocumentRecoverySession(deps({ storage: store })); + + expect(() => session.clear()).toThrow('marker clear failed'); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_KEY)).toBeNull(); + expect(store.getItem(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY)).not.toBeNull(); + }); + + it('uses linked-tab reconciliation for dirty conflict/orphan and clean adopt/detach', () => { + const cases: Array<{ + name: string; tab: OAuthDocumentRecoverySnapshot['tabs'][number]; latest: SavedQueryV2[]; check(tab: QueryTab): void; + }> = [ + { + name: 'dirty conflict', + tab: { ...snapshot().tabs[0], sqlDraft: 'SELECT mine', dirtySql: true, dirtySpec: false, savedId: 'q1', lastCommittedQueryToken: 'old' }, + latest: [saved('q1', 'SELECT theirs')], + check: (tab) => expect(tab.externalState).toBe('conflict'), + }, + { + name: 'dirty orphan', + tab: { ...snapshot().tabs[0], dirtySql: true, dirtySpec: false, savedId: 'q1', lastCommittedQueryToken: 'old' }, + latest: [], + check: (tab) => { expect(tab.savedId).toBeNull(); expect(tab.externalState).toBe('deleted'); }, + }, + { + name: 'clean adopt', + tab: { ...snapshot().tabs[0], dirtySql: false, dirtySpec: false, savedId: 'q1', lastCommittedQueryToken: 'old' }, + latest: [saved('q1', 'SELECT latest', 'Latest')], + check: (tab) => { expect(tab.sqlDraft).toBe('SELECT latest'); expect(tab.dirtySql).toBe(false); }, + }, + { + name: 'clean detach', + tab: { ...snapshot().tabs[0], dirtySql: false, dirtySpec: false, savedId: 'q1', lastCommittedQueryToken: 'old', editorMode: 'spec' }, + latest: [], + check: (tab) => { expect(tab.savedId).toBeNull(); expect(tab.editorMode).toBe('sql'); }, + }, + ]; + for (const item of cases) { + const store = storage(); saveSnapshot(store, snapshot({ tabs: [item.tab] })); + const s = state(); + expect(createOAuthDocumentRecoverySession(deps({ state: s, storage: store })).restore('state-1', workspace(item.latest))).toEqual({ kind: 'restored' }); + item.check(s.tabs.value[0]); + } + }); +}); diff --git a/tests/unit/oauth-document-recovery.test.ts b/tests/unit/oauth-document-recovery.test.ts new file mode 100644 index 0000000..6a867db --- /dev/null +++ b/tests/unit/oauth-document-recovery.test.ts @@ -0,0 +1,239 @@ +import { describe, expect, it } from 'vitest'; +import { + decodeOAuthDocumentRecovery, + decodeOAuthDocumentRecoveryValidatedCallback, + encodeOAuthDocumentRecovery, + encodeOAuthDocumentRecoveryValidatedCallback, + minimumNextTabId, + OAUTH_DOCUMENT_RECOVERY_KEY, + OAUTH_DOCUMENT_RECOVERY_TTL_MS, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_TTL_MS, + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + OAUTH_DOCUMENT_RECOVERY_VERSION, + rebindOAuthDocumentRecovery, +} from '../../src/core/oauth-document-recovery.js'; +import type { + OAuthDocumentRecoverySnapshot, + OAuthDocumentRecoveryValidatedCallback, +} from '../../src/core/oauth-document-recovery.js'; + +const createdAt = 1_700_000_000_000; + +const snapshot = (over: Partial = {}): OAuthDocumentRecoverySnapshot => ({ + version: OAUTH_DOCUMENT_RECOVERY_VERSION, + createdAt, + workspaceId: 'workspace-id', + workspaceKey: 'workspace-key', + oauthState: 'oauth-state', + tabs: [{ + id: 't1', doc: { kind: 'query' }, name: 'Draft', sqlDraft: 'SELECT 1', + specText: '{"name":', specVersion: 1, editorMode: 'spec', dirtySql: true, + dirtySpec: true, savedId: 'saved-1', lastCommittedQueryToken: '', externalState: 'conflict', + }], + activeTabId: 't1', + nextTabId: 2, + ...over, +}); + +const decode = (value: unknown, now = createdAt): ReturnType => + decodeOAuthDocumentRecovery(typeof value === 'string' || value == null ? value : JSON.stringify(value), now); + +const malformed = (mutate: (value: Record) => void): void => { + const value = JSON.parse(encodeOAuthDocumentRecovery(snapshot())) as Record; + mutate(value); + expect(decode(value)).toEqual({ kind: 'invalid', reason: 'malformed' }); +}; + +const validatedCallback = ( + over: Partial = {}, +): OAuthDocumentRecoveryValidatedCallback => ({ + version: OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION, + oauthState: 'oauth-state', + validatedAt: createdAt, + ...over, +}); + +describe('OAuth document recovery codec', () => { + it('exports the stable storage key/version and round-trips exact authored state including invalid raw Spec', () => { + expect(OAUTH_DOCUMENT_RECOVERY_KEY).toBe('oauth_document_recovery'); + expect(OAUTH_DOCUMENT_RECOVERY_VERSION).toBe(1); + const result = decodeOAuthDocumentRecovery(encodeOAuthDocumentRecovery(snapshot()), createdAt); + expect(result).toEqual({ kind: 'valid', value: snapshot() }); + expect(result.kind === 'valid' && result.value.tabs[0].specText).toBe('{"name":'); + }); + + it('preserves dashboard-variable bindings and accepts their SQL-only editor policy', () => { + const value = snapshot({ + tabs: [{ + id: 't2', doc: { kind: 'dashboard-variable', dashboardId: 'dash', variableName: 'country' }, + name: 'Variable: country', sqlDraft: 'SELECT country', specText: 'not json', specVersion: 9, + editorMode: 'sql', dirtySql: true, dirtySpec: false, savedId: null, externalState: null, + }], + activeTabId: 't2', nextTabId: 3, + }); + expect(decodeOAuthDocumentRecovery(encodeOAuthDocumentRecovery(value), createdAt)).toEqual({ kind: 'valid', value }); + const wrongMode = JSON.parse(encodeOAuthDocumentRecovery(value)); + wrongMode.tabs[0].editorMode = 'spec'; + expect(decode(wrongMode)).toEqual({ kind: 'invalid', reason: 'malformed' }); + }); + + it('serializes only documented fields and omits optional fields when absent', () => { + const value = snapshot({ + tabs: [{ + ...snapshot().tabs[0], lastCommittedQueryToken: undefined, externalState: undefined, + result: { rows: ['never'] }, chSession: 'never', lastSuccessfulResultColumns: [{ name: 'never' }], + specParsed: { never: true }, specDiagnostics: ['never'], running: true, + } as unknown as OAuthDocumentRecoverySnapshot['tabs'][number]], + transient: true, + } as unknown as Partial); + const raw = JSON.parse(encodeOAuthDocumentRecovery(value)); + expect(Object.keys(raw)).toEqual([ + 'version', 'createdAt', 'workspaceId', 'workspaceKey', 'oauthState', 'tabs', 'activeTabId', 'nextTabId', + ]); + expect(raw.tabs[0]).toEqual({ + id: 't1', doc: { kind: 'query' }, name: 'Draft', sqlDraft: 'SELECT 1', specText: '{"name":', + specVersion: 1, editorMode: 'spec', dirtySql: true, dirtySpec: true, savedId: 'saved-1', + }); + }); + + it('distinguishes missing, malformed, unsupported, exact-expiry, expired, and future payloads', () => { + expect(decodeOAuthDocumentRecovery(null, createdAt)).toEqual({ kind: 'missing' }); + expect(decodeOAuthDocumentRecovery(undefined, createdAt)).toEqual({ kind: 'missing' }); + expect(decodeOAuthDocumentRecovery('', createdAt)).toEqual({ kind: 'invalid', reason: 'malformed' }); + expect(decode({ ...snapshot(), version: 2 })).toEqual({ kind: 'invalid', reason: 'unsupported' }); + const encoded = encodeOAuthDocumentRecovery(snapshot()); + expect(decodeOAuthDocumentRecovery(encoded, createdAt + OAUTH_DOCUMENT_RECOVERY_TTL_MS)).toMatchObject({ kind: 'valid' }); + expect(decodeOAuthDocumentRecovery(encoded, createdAt + OAUTH_DOCUMENT_RECOVERY_TTL_MS + 1)) + .toEqual({ kind: 'invalid', reason: 'expired' }); + expect(decodeOAuthDocumentRecovery(encoded, createdAt - 1)).toEqual({ kind: 'invalid', reason: 'expired' }); + expect(decodeOAuthDocumentRecovery(encoded, Number.NaN)).toEqual({ kind: 'invalid', reason: 'malformed' }); + }); + + it('rejects every root validator failure', () => { + malformed((value) => { delete value.workspaceId; }); + malformed((value) => { value.extra = true; }); + malformed((value) => { value.version = '1'; }); + malformed((value) => { value.createdAt = 1.5; }); + malformed((value) => { value.workspaceId = ' '; }); + malformed((value) => { value.workspaceKey = ''; }); + malformed((value) => { value.oauthState = '\t'; }); + malformed((value) => { value.tabs = []; }); + malformed((value) => { value.activeTabId = 't01'; }); + malformed((value) => { value.nextTabId = 1.5; }); + malformed((value) => { value.nextTabId = 0; }); + }); + + it('rejects every tab/document validator failure and allocation inconsistency', () => { + const tab = (mutate: (value: Record) => void) => malformed((value) => mutate(value.tabs as Record)); + tab((tabs) => { (tabs[0] as Record).extra = true; }); + tab((tabs) => { delete (tabs[0] as Record).id; }); + tab((tabs) => { (tabs[0] as Record).id = 'q1'; }); + tab((tabs) => { (tabs[0] as Record).doc = []; }); + tab((tabs) => { (tabs[0] as Record).doc = { kind: 'query', extra: true }; }); + tab((tabs) => { (tabs[0] as Record).doc = { kind: 'dashboard-variable', dashboardId: '', variableName: 'x' }; }); + tab((tabs) => { (tabs[0] as Record).name = 1; }); + tab((tabs) => { (tabs[0] as Record).sqlDraft = null; }); + tab((tabs) => { (tabs[0] as Record).specText = false; }); + tab((tabs) => { (tabs[0] as Record).specVersion = 0; }); + tab((tabs) => { (tabs[0] as Record).editorMode = 'result'; }); + tab((tabs) => { (tabs[0] as Record).dirtySql = 1; }); + tab((tabs) => { (tabs[0] as Record).dirtySpec = null; }); + tab((tabs) => { (tabs[0] as Record).savedId = ''; }); + tab((tabs) => { (tabs[0] as Record).lastCommittedQueryToken = 1; }); + tab((tabs) => { (tabs[0] as Record).externalState = 'stale'; }); + malformed((value) => { + const tabs = value.tabs as unknown[]; + value.tabs = [tabs[0], { ...(tabs[0] as object) }]; + }); + malformed((value) => { value.activeTabId = 't2'; }); + malformed((value) => { value.nextTabId = 1; }); + }); + + it('computes a noncolliding next id and rejects unsafe or duplicate allocation inputs', () => { + expect(minimumNextTabId([{ id: 't1' }, { id: 't9' }, { id: 't2' }])).toBe(10); + expect(minimumNextTabId([])).toBe(1); + expect(() => minimumNextTabId([{ id: 't01' }])).toThrow('Invalid recovery tab id'); + expect(() => minimumNextTabId([{ id: 't1' }, { id: 't1' }])).toThrow('Invalid recovery tab id'); + expect(() => minimumNextTabId([{ id: 't9007199254740991' }])).toThrow('unsafe'); + expect(decode({ ...snapshot(), nextTabId: 2, tabs: [{ ...snapshot().tabs[0], id: 't9007199254740992' }] })) + .toEqual({ kind: 'invalid', reason: 'malformed' }); + }); + + it('rebinds only the OAuth attempt state and timestamp without mutating authored payload', () => { + const original = snapshot(); + const rebound = rebindOAuthDocumentRecovery(original, 'retry-state', createdAt + 12); + expect(rebound).toEqual({ ...original, oauthState: 'retry-state', createdAt: createdAt + 12 }); + expect(original).toEqual(snapshot()); + expect(() => rebindOAuthDocumentRecovery(original, ' ', createdAt)).toThrow('Invalid OAuth recovery rebind'); + expect(() => rebindOAuthDocumentRecovery(original, 'ok', 1.5)).toThrow('Invalid OAuth recovery rebind'); + }); + + it('rejects malformed values passed directly to strict encode and rebind', () => { + expect(() => encodeOAuthDocumentRecovery({ ...snapshot(), nextTabId: 1 })).toThrow('Invalid OAuth document recovery snapshot'); + expect(() => rebindOAuthDocumentRecovery({ ...snapshot(), workspaceId: '' }, 'next', createdAt)).toThrow('malformed'); + }); +}); + +describe('OAuth document recovery validated-callback codec', () => { + it('exports a separate stable key/version/TTL and round-trips only callback proof fields', () => { + expect(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_KEY) + .toBe('oauth_document_recovery_validated_callback'); + expect(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_VERSION).toBe(1); + expect(OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_TTL_MS) + .toBeLessThanOrEqual(OAUTH_DOCUMENT_RECOVERY_TTL_MS); + const input = { + ...validatedCallback(), + sql: 'never', + workspaceId: 'never', + token: 'never', + code: 'never', + } as OAuthDocumentRecoveryValidatedCallback; + const encoded = encodeOAuthDocumentRecoveryValidatedCallback(input); + expect(JSON.parse(encoded)).toEqual(validatedCallback()); + expect(decodeOAuthDocumentRecoveryValidatedCallback(encoded, createdAt)) + .toEqual({ kind: 'valid', value: validatedCallback() }); + }); + + it('strictly rejects malformed, unsupported, extra, expired, and future markers', () => { + const decodeMarker = (value: unknown, now = createdAt) => + decodeOAuthDocumentRecoveryValidatedCallback( + typeof value === 'string' || value == null ? value : JSON.stringify(value), + now, + ); + expect(decodeMarker(null)).toEqual({ kind: 'missing' }); + expect(decodeMarker(undefined)).toEqual({ kind: 'missing' }); + expect(decodeMarker('{bad')).toEqual({ kind: 'invalid', reason: 'malformed' }); + expect(decodeMarker({ ...validatedCallback(), version: 2 })) + .toEqual({ kind: 'invalid', reason: 'unsupported' }); + for (const value of [ + { oauthState: 'oauth-state', validatedAt: createdAt }, + { ...validatedCallback(), extra: true }, + { ...validatedCallback(), version: '1' }, + { ...validatedCallback(), oauthState: ' ' }, + { ...validatedCallback(), validatedAt: 1.5 }, + ]) { + expect(decodeMarker(value)).toEqual({ kind: 'invalid', reason: 'malformed' }); + } + const encoded = encodeOAuthDocumentRecoveryValidatedCallback(validatedCallback()); + expect(decodeMarker(encoded, createdAt + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_TTL_MS)) + .toMatchObject({ kind: 'valid' }); + expect(decodeMarker( + encoded, + createdAt + OAUTH_DOCUMENT_RECOVERY_VALIDATED_CALLBACK_TTL_MS + 1, + )).toEqual({ kind: 'invalid', reason: 'expired' }); + expect(decodeMarker(encoded, createdAt - 1)).toEqual({ kind: 'invalid', reason: 'expired' }); + expect(decodeMarker(encoded, Number.NaN)).toEqual({ kind: 'invalid', reason: 'malformed' }); + }); + + it('rejects malformed values passed directly to marker encode', () => { + expect(() => encodeOAuthDocumentRecoveryValidatedCallback({ + ...validatedCallback(), + oauthState: '', + })).toThrow('Invalid OAuth document recovery validated callback'); + expect(() => encodeOAuthDocumentRecoveryValidatedCallback({ + ...validatedCallback(), + validatedAt: 1.5, + })).toThrow('malformed'); + }); +}); diff --git a/tests/unit/workbench-shell.test.ts b/tests/unit/workbench-shell.test.ts new file mode 100644 index 0000000..bd264ce --- /dev/null +++ b/tests/unit/workbench-shell.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { mountWorkbenchShell } from '../../src/ui/workbench/workbench-shell.js'; +import { startDrag } from '../../src/ui/splitters.js'; +import { makeApp } from '../helpers/fake-app.js'; + +afterEach(() => { + document.body.replaceChildren(); +}); + +describe('mountWorkbenchShell', () => { + it('keeps authored tabs mounted when Spec revalidation fails persistently', () => { + const revalidateSpecDrafts = vi.fn(() => { + throw new Error('raw validator implementation detail'); + }); + const app = makeApp({ queryDoc: { revalidateSpecDrafts } }); + const tab = app.state.tabs.value[0]; + tab.name = 'Recovered draft'; + tab.sqlDraft = 'SELECT authored'; + tab.dirtySql = true; + const sqlSync = vi.spyOn(app.sqlEditor, 'syncFromState'); + const specSync = vi.spyOn(app.specEditor, 'syncFromState'); + const queryHost = document.createElement('div'); + document.body.appendChild(queryHost); + + const dispose = mountWorkbenchShell({ + app, + document, + state: app.state, + actions: app.actions, + sqlEditor: app.sqlEditor, + specEditor: app.specEditor, + workbench: app.workbench, + queryDoc: app.queryDoc, + prefs: app.prefs, + queryHost, + activeTab: app.activeTab, + updateSaveBtn: app.updateSaveBtn, + specBlocked: app.specBlocked, + renderVarStrip: app.renderVarStrip, + setRunBtn: app.setRunBtn, + setExportBtn: app.setExportBtn, + startDrag, + }); + + expect(revalidateSpecDrafts).toHaveBeenCalledOnce(); + expect(sqlSync).toHaveBeenCalledOnce(); + expect(specSync).toHaveBeenCalledOnce(); + expect(queryHost.querySelector('.workbench')).not.toBeNull(); + expect(queryHost.querySelector('.qtabs-inner')?.textContent).toContain('Recovered draft'); + expect(app.activeTab()).toMatchObject({ + name: 'Recovered draft', + sqlDraft: 'SELECT authored', + dirtySql: true, + }); + expect(queryHost.textContent).not.toContain('raw validator implementation detail'); + + // A later tab-signal repaint remains usable under the same persistent + // outage and performs the normal editor synchronization exactly once. + app.state.tabs.value = [...app.state.tabs.value]; + expect(revalidateSpecDrafts).toHaveBeenCalledTimes(2); + expect(sqlSync).toHaveBeenCalledTimes(2); + expect(specSync).toHaveBeenCalledTimes(2); + expect(queryHost.querySelector('.workbench')).not.toBeNull(); + + dispose(); + }); +});