diff --git a/app.js b/app.js index e4a5f13..2024649 100644 --- a/app.js +++ b/app.js @@ -48,7 +48,7 @@ import { explainPlusAliasRule, getMagicLinkResultMessage, isKnownProviderPlusAlias, - PLUS_ALIAS_REJECTED_CODE, + isMagicLinkSuccess, trimEmail, } from "./src/authMagicLink.js"; import { @@ -1071,6 +1071,149 @@ let confirmProjectToken = 0; // | 'unauthorized' | 'network-error' let ffConnectionState = "not-configured"; +// Monotonic generation of the configured FlutterFlow endpoint. Every project +// fetch and connection check captures it when it starts and discards its +// outcome if the generation moved on, so a late response from a previous +// endpoint can never overwrite the current endpoint's status or project list. +let ffEndpointGeneration = 0; + +// Monotonic sequences of started projects requests. The dropdown fetch and the +// connection check are independent consumers of the same endpoint, so each kind +// keeps its own sequence: a check starting while a fetch is pending must not +// discard the fetch's response (which would strand the dropdown on +// "Loading projects..." forever), and a newer fetch must not discard a pending +// check. Within one kind only the newest request may apply its outcome, so a +// late response for a key that has since been replaced — or one superseded by a +// newer same-kind request — can never overwrite the new key's project list or +// status, even when the endpoint did not change. +let ffProjectFetchSeq = 0; +let ffConnectionCheckSeq = 0; + +// Issue order across BOTH request kinds. The dropdown fetch and the +// connection check are superseded independently within their kind, but they +// write shared state — the account connection status and the stored +// project's confirmation. Ordering is split by what a request writes: an +// applied CONNECTION outcome (the account status card) may only be written by +// a request that actually produces one, so a list response that updates the +// deploy dialog never leaves a pending check unable to settle the card; an +// applied CONFIRMATION (the stored target's binding) is ordered separately so +// the newest list response always wins the stored selection. A request may +// write a domain only while no newer request already applied that domain. +let ffRequestOrderSeq = 0; +let ffAppliedOutcomeOrder = 0; +let ffAppliedConfirmationOrder = 0; + +// Identity binding for project selections. A selection is only usable as a +// deploy target while it is confirmed for the key + endpoint generation it was +// made under. This binding is the one thing every read of a selection checks, +// so a key replacement or endpoint change makes every previously confirmed +// selection unusable until a completed listProjects response re-confirms it or +// it is cleared. See isProjectSelectionConfirmed. +let projectSelectionIdentity = null; // { projectId, key, generation } | null + +// Identity of the list currently populating the API-key editor's project +// dropdown: the key + endpoint generation whose completed fetch produced the +// options on screen. Starting a fetch clears it (the options are replaced by +// the loading placeholder); only an applied response sets it. +let projectListIdentity = null; // { key, generation } | null + +// Identity of the list currently populating the deploy confirmation modal's +// project dropdown. A target may only be read from that dropdown while this +// identity still matches the current key + endpoint generation. +let confirmProjectListIdentity = null; // { key, generation } | null + +function beginProjectFetch() { + ffProjectFetchSeq += 1; + return ffProjectFetchSeq; +} + +function beginConnectionCheck() { + ffConnectionCheckSeq += 1; + return ffConnectionCheckSeq; +} + +/** + * Binds a project selection to the identity that produced it. The selection is + * an option of a completed fetch's list, so the binding copies that list's + * identity (the key it was fetched with and the endpoint generation it ran in). + * Called from the dropdown's change handler (a user choice) and when a + * completed response lists the stored selection (a re-confirmation). + */ +function bindProjectSelection(projectId, key, generation) { + projectSelectionIdentity = projectId ? { projectId, key, generation } : null; +} + +/** + * The single gate every read of a project selection goes through. A selection + * may be used as the deploy target only while the binding names the same + * project and was confirmed under the key + endpoint generation being checked. + * A key replacement or endpoint change moves one of them, so the selection + * stops matching and cannot be read as a target until a completed fetch + * re-confirms it. The key is a parameter so the save path can check the key it + * is saving before that key has been loaded into memory. + */ +function isProjectSelectionConfirmed(projectId, key = flutterflowApiKey) { + if (!projectId) return false; + const identity = projectSelectionIdentity; + if (!identity) return false; + return ( + identity.projectId === projectId && + identity.key === key && + identity.generation === ffEndpointGeneration + ); +} + +/** + * Reconciles the stored selection against a completed listProjects response + * for the current key + endpoint. A response that lists the stored project + * re-confirms it; one that does not strips its confirmation, so no later read + * can deploy to a project this key's completed response did not offer. + * Storage is left alone — the account card keeps mirroring the stored + * configuration; only the deploy gate changes. An in-flight explicit choice + * for a different project is never overwritten. + */ +function reconcileStoredProjectSelection(projects, key, generation) { + if (!flutterflowProjectId) return; + if ( + projectSelectionIdentity && + projectSelectionIdentity.projectId !== flutterflowProjectId + ) { + return; + } + const listed = (projects || []).some( + (project) => (project.id || project.projectId) === flutterflowProjectId, + ); + if (listed) { + bindProjectSelection(flutterflowProjectId, key, generation); + } else if (projectSelectionIdentity) { + bindProjectSelection("", null, null); + } +} + +/** + * Whether a projects request's outcome still describes the current connection + * identity and may be applied. Three things can move under an in-flight + * request: the endpoint (ffEndpointGeneration), the configured key (a + * replacement or a clear), and the newest request of the same kind (a newer + * fetch supersedes every older fetch; a newer check every older check). A + * response that lost any of them must not populate the current key's project + * list or connection status. A request made with the currently configured key + * stays valid even when it was issued before that key was saved — that is the + * editor's preview fetch. + */ +function isCurrentProjectRequest({ + seq, + currentSeq, + generation, + storedKeyAtStart, + requestKey, +}) { + if (seq !== currentSeq) return false; + if (generation !== ffEndpointGeneration) return false; + return storedKeyAtStart === flutterflowApiKey + || requestKey === flutterflowApiKey; +} + async function initializeApiKeys() { // Remove an exportable key left by an earlier version even if its encrypted // credentials were already cleared. @@ -1436,6 +1579,7 @@ async function saveApiKeys() { const flutterflowInput = document.getElementById("flutterflow-api-key-input"); const projectSelect = document.getElementById("flutterflow-projects-select"); const enteredKey = flutterflowInput.value.trim(); + const isKeyReplaced = Boolean(enteredKey) && enteredKey !== flutterflowApiKey; const selectedProjectId = projectSelect?.value.trim() || ""; // Only save if user entered a new value @@ -1450,13 +1594,54 @@ async function saveApiKeys() { } } + // The key the selection is saved under: a replacement key is the effective + // one even though initializeApiKeys has not loaded it into memory yet. + const effectiveKey = enteredKey || flutterflowApiKey; + if (selectedProjectId) { if (!validateFlutterFlowProjectId(selectedProjectId)) { showToast("The selected FlutterFlow project has an unexpected ID format.", "error"); projectSelect.focus(); return; } - await saveApiKey("flutterflow_project_id", selectedProjectId); + // A selection is persisted only while the identity binding confirms it for + // the key being saved: the dropdown's change handler binds a choice to the + // key + endpoint generation of the list that produced it, and a completed + // response re-confirms the stored value. A choice made from the old key's + // list (or a value auto-reselected under a replaced key) carries the old + // identity, so it is cleared instead of becoming the new key's target. + if (isProjectSelectionConfirmed(selectedProjectId, effectiveKey)) { + await saveApiKey("flutterflow_project_id", selectedProjectId); + } else { + // The on-screen choice belongs to a different key's list. Only a key + // replacement may drop the stored target: with the configured key + // unchanged, that choice is a preview leftover — the stored project + // stays configured and merely the options are reloaded for it. + if (isKeyReplaced) { + clearStoredProjectSelection(); + } else { + // Drop the preview binding so the reloaded list's reconcile can + // re-confirm the stored project (its different-project guard would + // otherwise keep skipping it, leaving the stored target refused). + projectSelectionIdentity = null; + } + projectSelect.value = ""; + // The options on screen came from a list that does not belong to the key + // being saved; refresh them for the saved key so the user can make a + // fresh, confirmable choice. + if ( + effectiveKey && + !( + projectListIdentity && + projectListIdentity.key === effectiveKey && + projectListIdentity.generation === ffEndpointGeneration + ) + ) { + fetchProjects(effectiveKey); + } + } + } else if (isKeyReplaced) { + clearStoredProjectSelection(); } // Reinitialize keys @@ -1510,6 +1695,11 @@ async function clearAllApiKeys() { sessionStorage.removeItem(SESSION_STORAGE_KEY_PREFIX + "flutterflow"); sessionStorage.removeItem(SESSION_STORAGE_KEY_PREFIX + "flutterflow_project_id"); + // No key and no list: every project selection binding is meaningless now. + projectSelectionIdentity = null; + projectListIdentity = null; + confirmProjectListIdentity = null; + // Reinitialize keys await initializeApiKeys(); @@ -1602,8 +1792,35 @@ function renderApiKeyConnection() { * @returns {Promise} the loaded projects, or null on error. */ async function validateFlutterFlowConnection() { + // The connection identity this check belongs to. If the endpoint, the + // configured key, or the newest connection check moves while the check is in + // flight, its outcome describes a previous identity and must be discarded + // rather than overwrite the new one's state. A pending dropdown fetch is a + // different kind and does not supersede the check (nor the check it). + const seq = beginConnectionCheck(); + const order = ++ffRequestOrderSeq; + const generation = ffEndpointGeneration; + const storedKeyAtStart = flutterflowApiKey; const apiKey = await getApiKey("flutterflow"); + const outcomeIsCurrent = () => + isCurrentProjectRequest({ + seq, + currentSeq: ffConnectionCheckSeq, + generation, + storedKeyAtStart, + requestKey: apiKey, + }); + // Everything a check writes is shared connection state, so a check that + // started before a newer request's outcome was applied has nothing left to + // write: skip it entirely rather than flash "validating" over the newer + // result and then fail silently. + if (!outcomeIsCurrent() || order <= ffAppliedOutcomeOrder) return null; + const applyConfirmation = () => { + ffAppliedConfirmationOrder = Math.max(ffAppliedConfirmationOrder, order); + }; if (!apiKey || !hasStoredKey("flutterflow")) { + ffAppliedOutcomeOrder = order; + applyConfirmation(); ffConnectionState = "not-configured"; renderApiKeyConnection(); return null; @@ -1620,12 +1837,29 @@ async function validateFlutterFlowConnection() { getFlutterFlowEndpoint(), ); const projects = await client.listProjects(); + if (!outcomeIsCurrent()) return null; + // A newer request of either kind already applied its outcome: this older + // response must not write shared state over it. + if (order <= ffAppliedOutcomeOrder) return null; + ffAppliedOutcomeOrder = order; + applyConfirmation(); ffConnectionState = projects && projects.length > 0 ? "connected" : "no-projects"; + // A completed check for the current key is authoritative for what that key + // can reach: it re-confirms the stored selection when the response lists + // it, and strips its confirmation when it does not — but only while it is + // still the newest confirmation writer, so a fresher list response wins. + if (order >= ffAppliedConfirmationOrder) { + reconcileStoredProjectSelection(projects, apiKey, generation); + } renderApiKeyConnection(); return projects; } catch (error) { + if (!outcomeIsCurrent()) return null; + if (order <= ffAppliedOutcomeOrder) return null; + ffAppliedOutcomeOrder = order; + applyConfirmation(); const msg = String((error && error.message) || ""); ffConnectionState = /(401|403)|denied|unauthorized|scoped|list permission/i.test(msg) ? "unauthorized" @@ -1638,12 +1872,14 @@ async function validateFlutterFlowConnection() { /** * Clears the currently selected FlutterFlow project so a stale selection * (belonging to a different endpoint, or a removed key) is never reused as a - * deploy/sync default. Storage, the in-memory value and the dropdown mirror - * each other: the account connection card and the deploy confirmation read the - * same stored value, so invalidating it keeps them consistent. + * deploy/sync default. Storage, the in-memory value, its identity binding and + * the dropdown mirror each other: the account connection card and the deploy + * confirmation read the same stored value, so invalidating it keeps them + * consistent. */ function clearStoredProjectSelection() { flutterflowProjectId = ""; + projectSelectionIdentity = null; localStorage.removeItem(STORAGE_KEY_PREFIX + "flutterflow_project_id"); sessionStorage.removeItem(SESSION_STORAGE_KEY_PREFIX + "flutterflow_project_id"); } @@ -1654,6 +1890,12 @@ function clearStoredProjectSelection() { * re-fetch the project list for the new endpoint when a key is configured. */ function invalidateStaleProjectSelection() { + // Any request still in flight belongs to the previous endpoint: advancing + // the generation makes it discard its outcome instead of writing it over the + // new endpoint's status or project list. + ffEndpointGeneration += 1; + projectListIdentity = null; + confirmProjectListIdentity = null; clearStoredProjectSelection(); const select = document.getElementById("flutterflow-projects-select"); if (select) { @@ -1700,6 +1942,26 @@ function setupFlutterFlowValidation() { }, 500), ); } + + const projectSelect = document.getElementById("flutterflow-projects-select"); + if (projectSelect) { + projectSelect.addEventListener("change", () => { + const chosen = projectSelect.value.trim(); + // A choice is one of the options a completed fetch produced, so it is + // bound to that list's identity. Without a completed list there is + // nothing to bind, and any previous binding no longer matches the value + // on screen. + if (chosen && projectListIdentity) { + bindProjectSelection( + chosen, + projectListIdentity.key, + projectListIdentity.generation, + ); + } else { + bindProjectSelection("", null, null); + } + }); + } } /** @@ -1734,13 +1996,49 @@ async function fetchProjects(apiKey) { return; } - // Show loading state + // The connection identity this fetch belongs to. If the endpoint, the + // configured key, or the newest dropdown fetch moves while the fetch is in + // flight, its response describes a previous identity and must be discarded + // rather than overwrite the new one's list or status. A connection check is + // a different kind and does not supersede the fetch (nor the fetch it). + const seq = beginProjectFetch(); + const order = ++ffRequestOrderSeq; + const generation = ffEndpointGeneration; + const storedKeyAtStart = flutterflowApiKey; + const outcomeIsCurrent = () => + isCurrentProjectRequest({ + seq, + currentSeq: ffProjectFetchSeq, + generation, + storedKeyAtStart, + requestKey: apiKey, + }); + // The dropdown is this fetch's own surface: the newest fetch still fills it + // even when its shared-state outcome is stale. Shared writes — connection + // status and stored-project confirmation — go to the newest request of + // either kind only, so an older list cannot re-mark the account connected + // after a newer check failed. + const mayWriteConnectionOutcome = () => order > ffAppliedOutcomeOrder; + const claimConnectionOutcome = () => { + ffAppliedOutcomeOrder = Math.max(ffAppliedOutcomeOrder, order); + }; + + // Show loading state. The options are replaced by the loading placeholder + // below, so no list is on screen until this fetch's response is applied. + projectListIdentity = null; select.innerHTML = ''; if (errorElement) errorElement.classList.add("hidden"); // A real request is in flight; the account connection card shows validating - // until the outcome is known (never "connected" from storage alone). - if (hasStoredKey("flutterflow") || apiKey) { + // until the outcome is known (never "connected" from storage alone). Only a + // request for the key that is actually configured describes the account — a + // preview request for a key that is never saved must not flash validating + // (and must not write any account status below). + if ( + mayWriteConnectionOutcome() && + apiKey === flutterflowApiKey && + (hasStoredKey("flutterflow") || apiKey) + ) { ffConnectionState = "validating"; } renderApiKeyConnection(); @@ -1754,16 +2052,39 @@ async function fetchProjects(apiKey) { getFlutterFlowEndpoint(), ); const projects = await client.listProjects(); + if (!outcomeIsCurrent()) return; + + // The applied options belong to this fetch's key + endpoint generation. + projectListIdentity = { key: apiKey, generation }; + // Shared writes — account status and stored-project confirmation — belong + // to the configured key only: a preview fetch for a key that is never + // saved must not describe the account or confirm a selection, while one + // whose key became the configured key mid-flight still applies. + const requestIsForConfiguredKey = apiKey === flutterflowApiKey; + const outcomeIsNewest = + mayWriteConnectionOutcome() && requestIsForConfiguredKey; + if (outcomeIsNewest) claimConnectionOutcome(); + // A response for the configured key re-confirms (or strips) the stored + // selection while it is still the newest confirmation response: an older + // list must not restore the target's confirmation over a newer one. + if (order > ffAppliedConfirmationOrder && requestIsForConfiguredKey) { + ffAppliedConfirmationOrder = order; + reconcileStoredProjectSelection(projects, apiKey, generation); + } if (!projects || projects.length === 0) { - ffConnectionState = "no-projects"; - renderApiKeyConnection(); + if (outcomeIsNewest) { + ffConnectionState = "no-projects"; + renderApiKeyConnection(); + } select.innerHTML = ''; return; } - ffConnectionState = "connected"; - renderApiKeyConnection(); + if (outcomeIsNewest) { + ffConnectionState = "connected"; + renderApiKeyConnection(); + } // Populate dropdown select.innerHTML = ''; @@ -1780,12 +2101,16 @@ async function fetchProjects(apiKey) { select.value = flutterflowProjectId; } } catch (error) { + if (!outcomeIsCurrent()) return; console.error("Failed to fetch projects:", error); const msg = String((error && error.message) || ""); - ffConnectionState = /(401|403)|denied|unauthorized|scoped|list permission/i.test(msg) - ? "unauthorized" - : "network-error"; - renderApiKeyConnection(); + if (mayWriteConnectionOutcome() && apiKey === flutterflowApiKey) { + claimConnectionOutcome(); + ffConnectionState = /(401|403)|denied|unauthorized|scoped|list permission/i.test(msg) + ? "unauthorized" + : "network-error"; + renderApiKeyConnection(); + } select.innerHTML = ''; if (errorElement) { errorElement.textContent = `Failed to load projects: ${error.message}`; @@ -3611,6 +3936,72 @@ const commitState = { }, }; +/** + * The only way a deploy resolves its target from stored state. The stored + * project is returned only while the identity binding confirms it for the key + * the deploy will actually use (the stored key); otherwise "" is returned and + * the caller fails or asks for a fresh selection. Every deploy read goes + * through here, so a selection that survived a key or endpoint change cannot + * reach a push. + */ +async function getConfirmedStoredProjectId(forApiKey) { + // The deploy passes the key it captured, so the verification describes the + // same credential identity the push will use — never a key saved mid-flight. + const apiKey = + forApiKey !== undefined ? forApiKey : await getApiKey("flutterflow"); + const projectId = await getApiKey("flutterflow_project_id"); + if (!apiKey || !projectId) return ""; + if (isProjectSelectionConfirmed(projectId, apiKey)) return projectId; + + // The binding is process-local and starts empty on load, so a stored target + // is unconfirmed while the startup listProjects is still pending — and stays + // unconfirmed when no fetch ever ran. Before refusing the deploy, verify the + // stored target against the current key's real list here: the same guard + // applies (a completed response that omits the project leaves it refused), + // and a superseded or failed verification still returns "". + const order = ++ffRequestOrderSeq; + const generation = ffEndpointGeneration; + let verified = false; + try { + const client = new FlutterFlowApiClient( + apiKey, + "", + "main", + getFlutterFlowEndpoint(), + ); + const projects = await client.listProjects(); + verified = (projects || []).some( + (project) => (project.id || project.projectId) === projectId, + ); + } catch (error) { + // A project-scoped key can sync its project without list permission: its + // listProjects denial is a 403, not proof the stored project is absent. + // Nothing can disprove the stored target for such a key, so it stands + // (still guarded by the endpoint generation + key checks below) and the + // push itself decides reachability; any other failure refuses. + const msg = String((error && error.message) || ""); + if (!/denied \(403\)|without list permission/i.test(msg)) return ""; + verified = true; + } + // A response that describes a superseded identity — the endpoint moved on, + // the configured key changed, or a newer request already applied its + // outcome (including one that stripped this target's confirmation) — + // verifies nothing. + if (generation !== ffEndpointGeneration) return ""; + if (apiKey !== flutterflowApiKey) return ""; + if (order <= ffAppliedConfirmationOrder) return ""; + ffAppliedConfirmationOrder = order; + if (!verified) return ""; + // Never overwrite an in-flight explicit choice for a different project. + if ( + !projectSelectionIdentity || + projectSelectionIdentity.projectId === projectId + ) { + bindProjectSelection(projectId, apiKey, generation); + } + return projectId; +} + /** * Commits generated code to FlutterFlow with full state tracking. * @param {string} dartCode - The generated Dart code to commit @@ -3636,13 +4027,27 @@ async function commitToFlutterFlow(dartCode, fileName, options = {}) { commitState.setState(CommitState.PREPARING); try { - // Get credentials + // Get credentials. The target project is read through the identity gate: + // a stored selection that is not confirmed for the stored key is never + // used. const apiKey = await getApiKey("flutterflow"); - const projectId = await getApiKey("flutterflow_project_id"); + const endpoint = getFlutterFlowEndpoint(); + let projectId = await getConfirmedStoredProjectId(apiKey); + // A key save or endpoint switch landing during the verification await + // would pair this captured identity with a target confirmed for another + // one — refuse rather than deploy a mismatched credential pair. + if (apiKey !== flutterflowApiKey || endpoint !== getFlutterFlowEndpoint()) { + projectId = ""; + } - if (!apiKey || !projectId) { + if (!apiKey) { + throw new Error( + "FlutterFlow API Key not configured. Please set your API key in the API Keys settings.", + ); + } + if (!projectId) { throw new Error( - "FlutterFlow credentials not configured. Please set your API key and Project ID in the API Keys settings.", + "No confirmed FlutterFlow project target. Choose a project from the loaded list in API Keys settings.", ); } @@ -3650,7 +4055,6 @@ async function commitToFlutterFlow(dartCode, fileName, options = {}) { throw new Error("Invalid FlutterFlow Project ID format."); } - const endpoint = getFlutterFlowEndpoint(); const apiClient = new FlutterFlowApiClient( apiKey, projectId, @@ -3835,8 +4239,18 @@ async function executeCommit(code, options = {}) { // Step 3: Validate FlutterFlow credentials commitState.setState(CommitState.VALIDATING); const apiKey = await getApiKey("flutterflow"); - const projectId = - commitTargetProjectId || (await getApiKey("flutterflow_project_id")); + const endpoint = getFlutterFlowEndpoint(); + const storedProjectId = await getApiKey("flutterflow_project_id"); + // The modal's choice was gated on its list identity at read time; the + // stored fallback verifies against this deploy's captured key so the push + // always pairs one credential identity — and a key save or endpoint + // switch landing during that verification refuses rather than pairing + // the captured key with a target confirmed for another identity. + let projectId = + commitTargetProjectId || (await getConfirmedStoredProjectId(apiKey)); + if (apiKey !== flutterflowApiKey || endpoint !== getFlutterFlowEndpoint()) { + projectId = ""; + } if (!apiKey) { throw new Error( @@ -3845,7 +4259,9 @@ async function executeCommit(code, options = {}) { } if (!projectId) { throw new Error( - "FlutterFlow Project ID not configured. Please add it in API Keys settings.", + storedProjectId + ? "The FlutterFlow project target is not confirmed for your current API key. Reopen the deploy dialog and choose a project from the loaded list." + : "FlutterFlow Project ID not configured. Please add it in API Keys settings.", ); } @@ -3883,7 +4299,6 @@ async function executeCommit(code, options = {}) { } commitState.setState(CommitState.PUSHING); - const endpoint = getFlutterFlowEndpoint(); const apiClient = new FlutterFlowApiClient( apiKey, projectId, @@ -4072,14 +4487,28 @@ async function executeBundleCommit(bundlePlan, options = {}) { commitState.setState(CommitState.VALIDATING); const apiKey = await getApiKey("flutterflow"); - const projectId = - commitTargetProjectId || (await getApiKey("flutterflow_project_id")); + const endpoint = getFlutterFlowEndpoint(); + const storedProjectId = await getApiKey("flutterflow_project_id"); + // The modal's choice was gated on its list identity at read time; the + // stored fallback verifies against this deploy's captured key so the push + // always pairs one credential identity — and a key save or endpoint + // switch landing during that verification refuses rather than pairing + // the captured key with a target confirmed for another identity. + let projectId = + commitTargetProjectId || (await getConfirmedStoredProjectId(apiKey)); + if (apiKey !== flutterflowApiKey || endpoint !== getFlutterFlowEndpoint()) { + projectId = ""; + } if (!apiKey) { throw new Error("FlutterFlow API Key not configured. Please add it in API Keys settings."); } if (!projectId) { - throw new Error("FlutterFlow Project ID not configured. Please add it in API Keys settings."); + throw new Error( + storedProjectId + ? "The FlutterFlow project target is not confirmed for your current API key. Reopen the deploy dialog and choose a project from the loaded list." + : "FlutterFlow Project ID not configured. Please add it in API Keys settings.", + ); } if (!validateFlutterFlowProjectId(projectId)) { throw new Error("Invalid FlutterFlow Project ID format."); @@ -4090,7 +4519,6 @@ async function executeBundleCommit(bundlePlan, options = {}) { targetIdentity.projectId = projectId; commitState.setState(CommitState.PUSHING); - const endpoint = getFlutterFlowEndpoint(); const apiClient = new FlutterFlowApiClient( apiKey, projectId, @@ -5561,10 +5989,14 @@ async function handleMagicLinkRequest() { try { const data = await sendMagicLink(email) - const isAliasRejected = data?.code === PLUS_ALIAS_REJECTED_CODE - if (input && !isAliasRejected) input.value = '' - input?.setAttribute('aria-invalid', String(isAliasRejected)) - setSignInMessage(getMagicLinkResultMessage(data, email), isAliasRejected ? 'error' : 'success') + // Only the explicit success code (or a legacy code-less response) counts + // as sent. Any other code — the plus-alias rejection or an unexpected + // error — keeps the address, marks the field invalid and reports an + // error, so the user is never told to check for a link that was not sent. + const isSuccess = isMagicLinkSuccess(data) + if (input && isSuccess) input.value = '' + input?.setAttribute('aria-invalid', String(!isSuccess)) + setSignInMessage(getMagicLinkResultMessage(data, email), isSuccess ? 'success' : 'error') // A successful send must stay retryable — the user may want to resend to // a different address, or send another link if the first one expires — // so the button is re-enabled either way, never left permanently disabled. @@ -5572,8 +6004,8 @@ async function handleMagicLinkRequest() { // clearly reads as usable again. if (btn) { btn.disabled = false - btn.textContent = isAliasRejected ? 'Send Sign-in Link' : 'Sent!' - if (!isAliasRejected) { + btn.textContent = isSuccess ? 'Sent!' : 'Send Sign-in Link' + if (isSuccess) { setTimeout(() => { if (btn.textContent === 'Sent!') btn.textContent = 'Send Sign-in Link' }, 2500) } } @@ -6828,7 +7260,13 @@ async function populateConfirmProjectSelect() { // closed and reopened while this is in flight, its token falls behind and it // bails instead of resetting the selection with stale data. const token = ++confirmProjectToken; + const order = ++ffRequestOrderSeq; const isCurrent = () => token === confirmProjectToken; + const generation = ffEndpointGeneration; + + // The options are replaced by the loading placeholder below, so the previous + // list is no longer on screen and nothing may be read from it. + confirmProjectListIdentity = null; const apiKey = await getApiKey("flutterflow"); const storedId = await getApiKey("flutterflow_project_id"); @@ -6852,6 +7290,23 @@ async function populateConfirmProjectSelect() { ); const projects = await client.listProjects(); if (!isCurrent()) return; + // The response describes the endpoint + key it was issued under. If either + // moved while the request was in flight, its options belong to a different + // identity than the deploy would use — repopulate for the current one + // rather than filling the modal with stale, undeployable projects. + if (generation !== ffEndpointGeneration || apiKey !== flutterflowApiKey) { + return populateConfirmProjectSelect(); + } + + // A completed response for the current key is authoritative: it re-confirms + // the stored selection when it lists it and strips its confirmation when it + // does not, so the stored fallback can never target a project this key's + // list did not offer. The confirmation write is shared connection state, + // so it applies only while no newer request has already applied an outcome. + if (order > ffAppliedConfirmationOrder) { + ffAppliedConfirmationOrder = order; + reconcileStoredProjectSelection(projects, apiKey, generation); + } if (!projects || projects.length === 0) { select.innerHTML = ''; @@ -6867,9 +7322,16 @@ async function populateConfirmProjectSelect() { select.appendChild(option); }); + // Only now do the options belong to this key + endpoint generation and may + // a value read from them be used as the deploy target. + confirmProjectListIdentity = { key: apiKey, generation }; + if (storedId) select.value = storedId; } catch (error) { if (!isCurrent()) return; + if (generation !== ffEndpointGeneration || apiKey !== flutterflowApiKey) { + return populateConfirmProjectSelect(); + } console.error("Failed to load projects for deploy:", error); select.innerHTML = ''; @@ -6877,13 +7339,26 @@ async function populateConfirmProjectSelect() { } /** - * Reads the project chosen in the confirm modal, falling back to the stored - * API Keys default when none was selected. + * Reads the project chosen in the confirm modal. A target is only returned + * while the dropdown still holds a list a completed fetch produced for the + * current key + endpoint: a value that survives a key or endpoint change is not + * a confirmed target. Returns null when no confirmed choice exists, so the + * caller falls back to the stored selection (itself identity-gated) or fails + * rather than deploying to an unconfirmed project. */ function readCommitTargetProjectId() { const select = document.getElementById("confirm-project-select"); const chosen = select?.value?.trim(); - return chosen || null; + if (!chosen) return null; + const identity = confirmProjectListIdentity; + if ( + !identity || + identity.key !== flutterflowApiKey || + identity.generation !== ffEndpointGeneration + ) { + return null; + } + return chosen; } /** diff --git a/e2e/stu384-connection.spec.js b/e2e/stu384-connection.spec.js index 6705b2e..35eb1cd 100644 --- a/e2e/stu384-connection.spec.js +++ b/e2e/stu384-connection.spec.js @@ -9,6 +9,7 @@ import { sampleProjectList, stagingProjectList, emptyProjectList, + projectExport, ENDPOINTS, } from "./fixtures/apiFixtures.js"; @@ -24,6 +25,7 @@ import { * key material (raw or masked) is rendered into a status label. */ const KEY = "ff-secret-token-abc123"; +const NEW_KEY = "ff-secret-token-xyz789"; const PROJ = "proj-abc-123"; const EMAIL = "metered@example.com"; const STAGING_LIST = "https://api.flutterflow.io/v2-staging/listProjects"; @@ -110,6 +112,40 @@ async function saveKeyThroughModal(page, { key, project } = {}) { await dismissOpenModals(page); } +// Gate the production projects endpoint: the next request can be held pending +// and released on demand, so a test can let a stale response land only after +// the endpoint changed. Every other production request answers immediately; +// the held request answers with `heldResponse` on release. +async function gateProductionListProjects(page, heldResponse = sampleProjectList) { + let holdNext = false; + let releaseHeld = null; + let markHeld = null; + const held = new Promise((resolve) => { + markHeld = resolve; + }); + + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + if (!holdNext) { + await route.fulfill(sampleProjectList()); + return; + } + holdNext = false; + markHeld(); + await new Promise((resolve) => { + releaseHeld = resolve; + }); + await route.fulfill(heldResponse()); + }); + + return { + holdNext: () => { + holdNext = true; + }, + waitForHeld: () => held, + release: () => releaseHeld(), + }; +} + test.describe("STU-384 account connection", () => { test.beforeEach(async ({ page }) => { await page.addInitScript(() => { @@ -272,6 +308,365 @@ test.describe("STU-384 account connection", () => { ).toHaveCount(0); }); + test("a late project fetch from the previous endpoint cannot overwrite the new endpoint's list", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: sampleProjectList(), + [STAGING_LIST]: stagingProjectList(), + }); + const gate = await gateProductionListProjects(page); + + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Re-open the editor: its production re-fetch is held in flight. + gate.holdNext(); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await gate.waitForHeld(); + + // Switch to staging while the production response is still pending, so the + // staging list is what the dropdown must show. + await page + .locator("#api-keys-modal summary", { hasText: "Advanced" }) + .click(); + await page + .locator("#flutterflow-endpoint-select") + .selectOption("https://api.flutterflow.io/v2-staging/"); + await expect( + page.locator(`#flutterflow-projects-select option[value="proj-stg-789"]`), + ).toHaveCount(1); + + // Release the stale production response: it must be discarded, not allowed + // to replace the staging list the new endpoint already returned. + const staleSettled = page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + gate.release(); + await staleSettled; + await page.waitForTimeout(150); + + await expect( + page.locator(`#flutterflow-projects-select option[value="proj-stg-789"]`), + ).toHaveCount(1); + await expect( + page.locator(`#flutterflow-projects-select option[value="proj-abc-123"]`), + ).toHaveCount(0); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + }); + + test("a late connection check from the previous endpoint cannot overwrite the new endpoint's status", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: sampleProjectList(), + [STAGING_LIST]: stagingProjectList(), + }); + // The held check resolves with an empty list: a stale "no-projects" + // outcome is visibly different from staging's "connected". + const gate = await gateProductionListProjects(page, emptyProjectList); + + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + + // Start a production connection check and keep its response pending. + gate.holdNext(); + await page.evaluate(() => { + window.validateFlutterFlowConnection(); + }); + await gate.waitForHeld(); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Checking connection…", + ); + + // Switch to staging while the check is still pending; staging's re-fetch + // settles the card to connected. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await page + .locator("#api-keys-modal summary", { hasText: "Advanced" }) + .click(); + await page + .locator("#flutterflow-endpoint-select") + .selectOption("https://api.flutterflow.io/v2-staging/"); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // Release the stale check with a different outcome: it must not overwrite + // the staging status or resurrect a target project. + const staleSettled = page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + gate.release(); + await staleSettled; + await page.waitForTimeout(150); + + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + await expect( + page.locator(`#flutterflow-projects-select option[value="proj-stg-789"]`), + ).toHaveCount(1); + }); + + test("a connection check started while a dropdown fetch is pending must not strand the dropdown", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + const gate = await gateProductionListProjects(page); + + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Re-open the editor: its dropdown re-fetch is held in flight, so the + // dropdown shows the loading option while the response is pending. + gate.holdNext(); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await gate.waitForHeld(); + await expect(page.locator("#flutterflow-projects-select")).toContainText( + "Loading projects...", + ); + + // Start a connection check for the same key and endpoint while the fetch + // is still pending. It answers immediately (the gate holds one request); + // the held fetch is the only response still outstanding. + await page.evaluate(() => window.validateFlutterFlowConnection()); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // Release the fetch. It belongs to the current key and endpoint, so its + // response must still populate the dropdown — the check must not have + // superseded it. If it did, the dropdown would be stranded on + // "Loading projects..." forever. + const fetchSettled = page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + gate.release(); + await fetchSettled; + await expect( + page.locator(`#flutterflow-projects-select option[value="${PROJ}"]`), + ).toHaveCount(1); + await expect(page.locator("#flutterflow-projects-select")).not.toContainText( + "Loading projects...", + ); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + }); + + test("a late project fetch from the previous key cannot overwrite the new key's list", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + // Only the held OLD-key request answers with this list, so a stale + // response landing after the key was replaced is unmistakable. + const staleKeyList = () => + ok({ + success: true, + value: JSON.stringify({ + entries: [ + { id: "proj-old-key-111", project: { name: "Old Key Project" } }, + ], + }), + }); + const gate = await gateProductionListProjects(page, staleKeyList); + + await page.goto("/"); + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Re-open the editor: its fetch with the saved key is held in flight. + gate.holdNext(); + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await gate.waitForHeld(); + + // Replace the key while the old key's fetch is still pending. The blur + // fetch for the new key answers immediately, so the dropdown now shows + // the list that belongs to the key being saved. + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(NEW_KEY); + await input.blur(); + await expect( + page.locator(`#flutterflow-projects-select option[value="${PROJ}"]`), + ).toHaveCount(1); + + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + await page.waitForTimeout(1300); + await dismissOpenModals(page); + + // Release the stale old-key response: it must be discarded, not allowed + // to replace the new key's list or regress its connection status. + const staleSettled = page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + gate.release(); + await staleSettled; + await page.waitForTimeout(150); + + await expect( + page.locator(`#flutterflow-projects-select option[value="${PROJ}"]`), + ).toHaveCount(1); + await expect( + page.locator( + '#flutterflow-projects-select option[value="proj-old-key-111"]', + ), + ).toHaveCount(0); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + }); + + test("replacing the key without choosing a project clears the old target", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Re-open the editor and replace the key. The dropdown re-selects the + // stored project when its list loads, but that is not a choice. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(NEW_KEY); + await input.blur(); + await expect(page.locator("#flutterflow-projects-select")).toHaveValue(PROJ); + + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + await page.waitForTimeout(1300); + await dismissOpenModals(page); + + // The old key's target is gone: nothing a later deploy reads can silently + // point at it. + const storedAfter = await page.evaluate(() => + localStorage.getItem("ccc_api_key_flutterflow_project_id"), + ); + expect(storedAfter).toBeNull(); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + }); + + test("a project chosen from the old key's list cannot become the replaced key's target", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Re-open the editor: the dropdown shows the OLD key's list. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await expect( + page.locator('#flutterflow-projects-select option[value="proj-def-456"]'), + ).toHaveCount(1); + + // Choose a project from the old key's list before replacing the key. The + // choice was produced by the old key's completed fetch, so it must not be + // persisted as the new key's deploy target. + await page.locator("#flutterflow-projects-select").selectOption("proj-def-456"); + + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(NEW_KEY); + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + await page.waitForTimeout(1300); + await dismissOpenModals(page); + + // No target survives the key replacement: the old-key choice is not the + // new key's target, and no later read can point at it. + const storedAfter = await page.evaluate(() => + localStorage.getItem("ccc_api_key_flutterflow_project_id"), + ); + expect(storedAfter).toBeNull(); + await expect(page.locator("#acct-ff-project")).toHaveText("—"); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // The editor requires a fresh selection: the list loaded for the new key, + // but nothing is selected from it. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await expect( + page.locator(`#flutterflow-projects-select option[value="${PROJ}"]`), + ).toHaveCount(1); + await expect(page.locator("#flutterflow-projects-select")).toHaveValue(""); + await page.evaluate(() => window.closeApiKeysModal()); + + // The deploy dialog likewise preselects nothing: a target may only come + // from the list its own completed fetch produced for the current key. + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + await expect(page.locator("#confirm-project-select")).toHaveValue(""); + }); + + test("a stored target the current key's completed list omits is not deployable", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + // Establish a stored, confirmed target. + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + + // The key's project list changes: a later completed response for the same + // key no longer lists the stored target. + await applyDefaultRoutes(page, { + ...signedInContext(), + [ENDPOINTS.flutterFlowListProjects]: ok({ + success: true, + value: JSON.stringify({ + entries: [ + { id: "proj-def-456", project: { name: "Test Project Beta" } }, + ], + }), + }), + }); + + // The real deploy dialog's completed fetch does not list the stored + // target, so it must not be preselected... + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + await expect( + page.locator('#confirm-project-select option[value="proj-def-456"]'), + ).toHaveCount(1); + await expect(page.locator("#confirm-project-select")).toHaveValue(""); + + // ...and confirming must fail before any push: the stored target lost its + // confirmation with the completed list, so no deploy may target it. + await page.evaluate(() => window.confirmCommitToFlutterFlow()); + const terminal = page.locator("#commit-terminal-modal"); + await expect(terminal).toBeVisible(); + await expect(terminal).toContainText("not confirmed"); + await expect(terminal).not.toContainText(PROJ); + }); + test("removing the key flips the connection card back to not-configured", async ({ page }) => { await seedSession(page); await applyDefaultRoutes(page, signedInContext()); @@ -312,6 +707,467 @@ test.describe("STU-384 account connection", () => { await expect(page.locator("#acct-ff-status")).not.toContainText("••••"); }); + test("a previewed key's leftover choice cannot erase the stored target on save", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // The preview key answers with a distinct list: its only project is + // unmistakably the preview key's, never the configured key's. listProjects + // carries the key in its Authorization header, so the route can tell the + // two requests apart. + const previewList = () => + ok({ + success: true, + value: JSON.stringify({ + entries: [ + { id: "proj-prev-999", project: { name: "Preview Only" } }, + ], + }), + }); + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + const auth = route.request().headers()["authorization"] || ""; + await route.fulfill(auth.includes(NEW_KEY) ? previewList() : sampleProjectList()); + }); + + // Re-open the editor and preview a different key without saving it: the + // blur fetch fills the dropdown with the preview key's list, and picking + // from it binds that choice to the preview key — not the configured one. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(NEW_KEY); + await input.blur(); + await expect( + page.locator('#flutterflow-projects-select option[value="proj-prev-999"]'), + ).toHaveCount(1); + await page + .locator("#flutterflow-projects-select") + .selectOption("proj-prev-999"); + + // Clear the key input and save: the configured key stays the stored one, + // so the dropdown's preview choice is not a selection for it. The stored + // target must survive instead of being cleared with the preview. + await input.fill(""); + await page.locator("#api-keys-modal .bg-blue-500").click(); + await expect(page.locator("#api-keys-modal")).toBeHidden(); + await page.waitForTimeout(1300); + await dismissOpenModals(page); + + const storedAfter = await page.evaluate(() => + localStorage.getItem("ccc_api_key_flutterflow_project_id"), + ); + expect(storedAfter).not.toBeNull(); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // The deploy dialog must still offer the stored project: its completed + // fetch re-confirms the target the preview almost erased. + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + await expect(page.locator("#confirm-project-select")).toHaveValue(PROJ); + }); + + test("a late dropdown fetch cannot overwrite a newer failed connection check", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // From here the next projects request (the editor's dropdown fetch) is + // held pending; the connection check issued behind it answers 401. + let releaseHeld; + let markHeld; + const held = new Promise((resolve) => { + markHeld = resolve; + }); + let heldUsed = false; + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + if (!heldUsed) { + heldUsed = true; + markHeld(); + await new Promise((resolve) => { + releaseHeld = resolve; + }); + await route.fulfill(sampleProjectList()); + return; + } + await route.fulfill(err(401, { error: "unauthorized" })); + }); + + // Open the editor: its dropdown fetch is the held request. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + await held; + + // The connection check issues behind the held fetch and settles first: + // its 401 is the newest shared outcome. + await page.evaluate(() => window.validateFlutterFlowConnection()); + await expect(page.locator("#acct-ff-status")).toHaveText( + "API key rejected (401/403) — re-enter your key", + ); + + // Release the older fetch. Its options still populate the dropdown (its + // own surface), but its connection outcome is stale: it must not flip the + // card back to connected or re-confirm the stored target over the 401. + const fetchSettled = page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + releaseHeld(); + await fetchSettled; + await page.waitForTimeout(150); + + await expect( + page.locator(`#flutterflow-projects-select option[value="${PROJ}"]`), + ).toHaveCount(1); + await expect(page.locator("#acct-ff-status")).toHaveText( + "API key rejected (401/403) — re-enter your key", + ); + await expect(page.locator("#acct-ff-status")).not.toHaveText( + "Connected to FlutterFlow", + ); + }); + + test("a direct commit issued while the post-reload list is pending still deploys the saved project", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Reload with the startup check held pending: storage has the saved key + + // project but the process-local confirmation binding is still empty. + const gate = await gateProductionListProjects(page); + gate.holdNext(); + await page.reload({ waitUntil: "domcontentloaded" }); + await gate.waitForHeld(); + + // A direct commit must not refuse the saved target merely because the + // binding is unpopulated: it verifies the stored project against the + // current key's real list and proceeds. The proof is the deploy reaching + // the remote pipeline — exportCode is the first call past the target + // gate, and a refused target never produces it. The fixtures' fake + // project source fails the later pubspec merge; that downstream error is + // fine, as long as it is not the target-gate refusal. + const exported = page.waitForRequest(ENDPOINTS.flutterFlowExportCode); + const result = await page.evaluate(() => + window.commitToFlutterFlow( + "class TestWidget extends StatelessWidget {}", + "test_widget.dart", + ), + ); + await exported; + expect(result.error || "").not.toMatch(/no confirmed|not confirmed/i); + gate.release(); + }); + + test("a stale deploy verification cannot revive a target a newer response removed", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // A list that omits the stored project: the startup check and the later + // superseding check both answer with it, while the held deploy + // verification answers with the full list. + const omittingList = () => + ok({ + success: true, + value: JSON.stringify({ + entries: [ + { id: "proj-def-456", project: { name: "Beta Only" } }, + ], + }), + }); + let releaseVerify; + let markVerify; + const verifyHeld = new Promise((resolve) => { + markVerify = resolve; + }); + let seen = 0; + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + seen += 1; + if (seen === 2) { + markVerify(); + await new Promise((resolve) => { + releaseVerify = resolve; + }); + await route.fulfill(sampleProjectList()); + return; + } + await route.fulfill(omittingList()); + }); + + // Reload: the process-local binding starts empty, and the startup check + // (request 1) does not re-confirm the omitted stored target. + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + + // A direct commit issues its own verification (request 2) and waits. + const commitPromise = page.evaluate(() => + window.commitToFlutterFlow( + "class TestWidget extends StatelessWidget {}", + "test_widget.dart", + ), + ); + await verifyHeld; + + // A newer check (request 3) settles first with a list that omits the + // stored target — the newest applied outcome. + await page.evaluate(() => window.validateFlutterFlowConnection()); + + // The older verification now lands with a list that still contains the + // project. It must not revive the target the newer outcome refused. + releaseVerify(); + const result = await commitPromise; + expect(result.error || "").toMatch(/no confirmed|not confirmed/i); + }); + + test("a project-scoped key without list permission keeps its stored deploy target", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + // Establish a stored target while the key can list projects. + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // The key becomes scoped to its single project: every listProjects call + // now answers 403, but the key can still reach the project itself. + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + await route.fulfill(err(403, { error: "forbidden" })); + }); + await page.reload({ waitUntil: "domcontentloaded" }); + + // A direct commit must not refuse the saved target for lack of a list: + // the scoped denial cannot disprove it, so the deploy proceeds and the + // push itself decides reachability. + const exported = page.waitForRequest(ENDPOINTS.flutterFlowExportCode); + const result = await page.evaluate(() => + window.commitToFlutterFlow( + "class TestWidget extends StatelessWidget {}", + "test_widget.dart", + ), + ); + await exported; + expect(result.error || "").not.toMatch(/no confirmed|not confirmed/i); + }); + + test("an old endpoint's response cannot fill the deploy dialog", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...signedInContext(), + [STAGING_LIST]: stagingProjectList(), + }); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Hold the deploy dialog's production fetch so the endpoint can change + // while it is in flight. + const gate = await gateProductionListProjects(page); + gate.holdNext(); + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + await gate.waitForHeld(); + + // Switch to staging while the production response is pending. + await page.evaluate(() => + window.setFlutterFlowEndpoint("https://api.flutterflow.io/v2-staging/"), + ); + + // Release the stale production response: its options belong to the old + // endpoint, so the dialog must repopulate from the new endpoint instead + // of offering undeployable production projects. + const fetchSettled = page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + gate.release(); + await fetchSettled; + + await expect( + page.locator('#confirm-project-select option[value="proj-stg-789"]'), + ).toHaveCount(1); + await expect( + page.locator(`#confirm-project-select option[value="${PROJ}"]`), + ).toHaveCount(0); + }); + + test("a deploy-dialog fetch cannot strand the connection card on checking", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // Hold the startup check (first request after reload). The deploy + // dialog's fetch settles first and must not claim the connection outcome + // — it writes no account status, so doing so would leave the older check + // unable to settle the card. + let releaseCheck; + let markCheck; + const checkHeld = new Promise((resolve) => { + markCheck = resolve; + }); + let seen = 0; + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + seen += 1; + if (seen === 1) { + markCheck(); + await new Promise((resolve) => { + releaseCheck = resolve; + }); + } + await route.fulfill(sampleProjectList()); + }); + await page.reload({ waitUntil: "domcontentloaded" }); + await checkHeld; + + // The dialog's fetch (request 2) resolves while the check is pending. + const modalSettled = page.waitForResponse( + ENDPOINTS.flutterFlowListProjects, + ); + await page.evaluate(() => window.__CCC_OPEN_COMMIT_CONFIRM__()); + await modalSettled; + + // The older check now lands: its status must still reach the card. + releaseCheck(); + await page.evaluate(() => window.closeCommitConfirmModal()); + await openAccount(page); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + }); + + test("a preview key's failure cannot mark the saved account rejected", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, signedInContext()); + // Requests for the unsaved preview key are denied; the saved key keeps + // answering normally. + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + const auth = route.request().headers()["authorization"] || ""; + await route.fulfill( + auth.includes(NEW_KEY) ? err(401, { error: "unauthorized" }) : sampleProjectList(), + ); + }); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + + // Preview a different key without saving: its blur fetch is denied, but + // the outcome describes the preview key — not the saved account — so the + // card must stay connected. + await page + .locator(".acct-connection button", { hasText: "Configure" }) + .first() + .click(); + await expect(page.locator("#api-keys-modal")).toBeVisible(); + const previewSettled = page.waitForResponse( + ENDPOINTS.flutterFlowListProjects, + ); + const input = page.locator("#flutterflow-api-key-input"); + await input.fill(NEW_KEY); + await input.blur(); + await previewSettled; + await page.waitForTimeout(150); + + await expect(page.locator("#acct-ff-status")).toHaveText( + "Connected to FlutterFlow", + ); + }); + + test("a key saved mid-deploy cannot pair the old key with a new target", async ({ page }) => { + await seedSession(page); + const newKeyList = () => + ok({ + success: true, + value: JSON.stringify({ + entries: [ + { id: "proj-b-789", project: { name: "New Key Project" } }, + ], + }), + }); + await applyDefaultRoutes(page, signedInContext()); + // The replacement key answers with its own project's list. + await page.route(ENDPOINTS.flutterFlowListProjects, async (route) => { + const auth = route.request().headers()["authorization"] || ""; + await route.fulfill( + auth.includes(NEW_KEY) ? newKeyList() : sampleProjectList(), + ); + }); + let exportCalls = 0; + await page.route(ENDPOINTS.flutterFlowExportCode, async (route) => { + exportCalls += 1; + await route.fulfill(projectExport()); + }); + await page.goto("/"); + + await saveKeyThroughModal(page, { key: KEY, project: PROJ }); + await expect(page.locator("#acct-ff-project")).toHaveText(PROJ); + + // Capture the old stored key's ciphertext, then replace the credential + // pair through the app's real save path (input blur fires the preview + // fetch, the select change binds the new project to the new key, and + // saveApiKeys persists both — the deploy progress overlay would block a + // UI save, so the app's own handlers are driven directly). + const oldCipher = await page.evaluate(() => + localStorage.getItem("ccc_api_key_flutterflow"), + ); + await page.evaluate((key) => { + const input = document.getElementById("flutterflow-api-key-input"); + input.value = key; + input.dispatchEvent(new FocusEvent("blur")); + }, NEW_KEY); + await page.waitForResponse(ENDPOINTS.flutterFlowListProjects); + await page.evaluate((project) => { + const select = document.getElementById("flutterflow-projects-select"); + select.value = project; + select.dispatchEvent(new Event("change", { bubbles: true })); + }, "proj-b-789"); + await page.evaluate(() => window.saveApiKeys()); + + // Model the save landing mid-deploy: the deploy's first credential read + // still returns the pre-save ciphertext (the captured key), while every + // later read — the target lookup's own reads — sees the saved pair. + await page.evaluate((cipher) => { + const orig = localStorage.getItem.bind(localStorage); + let reads = 0; + localStorage.getItem = (k) => + k === "ccc_api_key_flutterflow" && ++reads === 1 ? cipher : orig(k); + }, oldCipher); + + // The deploy must refuse: its captured key belongs to a different + // credential identity than the saved target now confirms — pushing + // would pair the old key with the new key's project. + const result = await page.evaluate(() => + window.commitToFlutterFlow( + "class TestWidget extends StatelessWidget {}", + "test_widget.dart", + ), + ); + expect(result.error || "").toMatch(/no confirmed|not confirmed/i); + expect(exportCalls).toBe(0); + }); + test("signed-out entry can still save the key without a connection card", async ({ page }) => { // No session seeded: the account view shows the signed-out prompt. await applyDefaultRoutes(page, { diff --git a/e2e/stu386-magic-link-signin.spec.js b/e2e/stu386-magic-link-signin.spec.js index d9afc1a..934fa89 100644 --- a/e2e/stu386-magic-link-signin.spec.js +++ b/e2e/stu386-magic-link-signin.spec.js @@ -9,6 +9,7 @@ import { magicLinkAliasRejected, magicLinkSendFailure, magicLinkVerifyFailure, + ok, ENDPOINTS, } from "./fixtures/apiFixtures.js"; @@ -105,6 +106,63 @@ test.describe("Magic-link sign-in", () => { await expect(input).toHaveValue("user+tag@gmail.com"); }); + test("an HTTP-success response with an unexpected error code is surfaced as an error", async ({ page }) => { + await applyDefaultRoutes(page, { + [ENDPOINTS.identity]: guestIdentity(), + [ENDPOINTS.getSubscription]: freeSubscription(), + // HTTP 200, but the body carries an error code that is not the + // specifically handled plus-alias rejection: the send did not happen. + [ENDPOINTS.authSendMagicLink]: ok({ + code: "SEND_FAILED", + message: "We could not send the link right now.", + }), + }); + + await page.goto("/#account"); + await page.locator("#auth-signedout button", { hasText: "Sign In" }).click(); + + const input = page.locator("#signin-email-input"); + const submit = page.locator("#signin-submit-btn"); + await input.fill("person@example.com"); + await submit.click(); + + // The error is styled as an error, the address is kept for retry, and the + // control never claims the link was sent. + await expect(page.locator("#signin-message")).toContainText( + "could not send the link", + ); + await expect(page.locator("#signin-message")).toHaveClass(/signin-message-error/); + await expect(input).toHaveValue("person@example.com"); + await expect(input).toHaveAttribute("aria-invalid", "true"); + await expect(submit).toHaveText("Send Sign-in Link"); + }); + + test("an HTTP-success response that declares success:false is surfaced as an error", async ({ page }) => { + await applyDefaultRoutes(page, { + [ENDPOINTS.identity]: guestIdentity(), + [ENDPOINTS.getSubscription]: freeSubscription(), + // HTTP 200 with an explicit failure and no code: this must never read as + // "Sent!" or clear the address. + [ENDPOINTS.authSendMagicLink]: ok({ success: false }), + }); + + await page.goto("/#account"); + await page.locator("#auth-signedout button", { hasText: "Sign In" }).click(); + + const input = page.locator("#signin-email-input"); + const submit = page.locator("#signin-submit-btn"); + await input.fill("person@example.com"); + await submit.click(); + + await expect(page.locator("#signin-message")).toContainText( + "Something went wrong", + ); + await expect(page.locator("#signin-message")).toHaveClass(/signin-message-error/); + await expect(input).toHaveValue("person@example.com"); + await expect(input).toHaveAttribute("aria-invalid", "true"); + await expect(submit).toHaveText("Send Sign-in Link"); + }); + test("recovers from a failed send, and retry succeeds", async ({ page }) => { let attempt = 0; await applyDefaultRoutes(page, { diff --git a/e2e/stu387-account-access.spec.js b/e2e/stu387-account-access.spec.js index 0d645dd..940c4a0 100644 --- a/e2e/stu387-account-access.spec.js +++ b/e2e/stu387-account-access.spec.js @@ -4,6 +4,7 @@ import { guestIdentity, professionalSubscription, magicLinkSent, + ok, err, ENDPOINTS, } from "./fixtures/apiFixtures.js"; @@ -162,6 +163,33 @@ test.describe("STU-387 account access & data", () => { expect(rateLimitText?.trim()).not.toBe(serverErrorText?.trim()); }); + test("a resolved 200 that reports a send failure renders an error, never the sent confirmation", async ({ page }) => { + await seedSession(page); + await applyDefaultRoutes(page, { + ...refreshOverride(), + [ENDPOINTS.identity]: identityWithUsage({ email: EMAIL, count: 12 }), + [ENDPOINTS.getSubscription]: professionalSubscription(), + // HTTP 200, but the body says the send failed: the request resolving is + // not proof a link was sent, so the row must surface the server's + // failure message instead of the sent confirmation. + [ENDPOINTS.authSendMagicLink]: ok({ + code: "SEND_FAILED", + message: "We could not send the link right now.", + }), + }); + await page.goto("/"); + await openAccount(page); + + const msg = page.locator("#send-new-link-msg"); + const btn = page.locator("#send-new-link-btn"); + await btn.click(); + + await expect(msg).toContainText("We could not send the link right now."); + await expect(msg).toHaveClass(/error/); + await expect(msg).not.toContainText("Check your email"); + await expect(btn).toBeEnabled(); + }); + test("a second send while one is in flight is refused (single request)", async ({ page }) => { let calls = 0; await seedSession(page); diff --git a/src/accountAccess.js b/src/accountAccess.js index 9f3cf2d..4555d46 100644 --- a/src/accountAccess.js +++ b/src/accountAccess.js @@ -12,6 +12,8 @@ // the identity/metering cookie, and stored credentials are never cleared by // any control in this section. +import { isMagicLinkSuccess } from "./authMagicLink.js"; + // --------------------------------------------------------------------------- // Key contract // --------------------------------------------------------------------------- @@ -84,11 +86,26 @@ export function performSignOut({ storage, onIdentityChanged, onPlanChanged }) { // Send new link (reauth) // --------------------------------------------------------------------------- +/** + * The most useful user-facing message an unsuccessful send response carries: + * its `message`, else its `error` string, else a generic failure. Never empty, + * so the row's error line always says something. + */ +function sendFailureMessage(data) { + const message = data?.message || data?.error; + if (typeof message === "string" && message.trim()) return message.trim(); + return "The server could not send the link."; +} + /** * Controller for the "Send new link" control. It reflects the ACTUAL backing * response or failure via onState(), moves through pending -> sent|error, and * hard-blocks a duplicate request while one is still in flight (settle first, - * then allow the next send). + * then allow the next send). A resolved request is not automatically a sent + * link: the response is classified with the shared magic-link contract first, + * so an HTTP-success body that reports a failure (an error code, + * success:false, or an error field) becomes the error state carrying the + * server's message instead of a false "sent" confirmation. */ export function createSendLinkController({ sendLink, getEmail, onState }) { let inFlight = false; @@ -99,6 +116,9 @@ export function createSendLinkController({ sendLink, getEmail, onState }) { try { onState({ status: "pending", error: null }); const data = await sendLink(getEmail()); + if (!isMagicLinkSuccess(data)) { + throw new Error(sendFailureMessage(data)); + } result = { status: "sent", data }; onState({ status: "sent", data }); return result; diff --git a/src/accountAccess.test.js b/src/accountAccess.test.js index 6bc7eb8..d9b6718 100644 --- a/src/accountAccess.test.js +++ b/src/accountAccess.test.js @@ -30,6 +30,7 @@ import { isDeleteAccountSupported, DELETE_ACCOUNT_MESSAGE, } from "./accountAccess.js"; +import { MAGIC_LINK_SUCCESS_CODE } from "./authMagicLink.js"; // Minimal in-memory Storage stand-in so the logic is hermetically testable with // no browser, network, clock dependency, or host-timezone coupling. @@ -99,7 +100,10 @@ test("criterion 1: sign out clears the session but never scrubs identity or usag test("criterion 2: a successful reauth reports pending then sent with the actual response", async () => { const states = []; const send = createSendLinkController({ - sendLink: async () => ({ code: "sent", message: "Check your email" }), + sendLink: async () => ({ + code: MAGIC_LINK_SUCCESS_CODE, + message: "Check your email", + }), getEmail: () => "a@b.com", onState: (state) => states.push(state), }); @@ -108,7 +112,56 @@ test("criterion 2: a successful reauth reports pending then sent with the actual assert.equal(result.status, "sent"); assert.equal(result.data.message, "Check your email"); assert.equal(states[states.length - 1].status, "sent"); - assert.equal(states[states.length - 1].data.code, "sent"); + assert.equal(states[states.length - 1].data.code, MAGIC_LINK_SUCCESS_CODE); +}); + +test("criterion 2: a resolved 200 that reports a send failure is an error, never 'sent'", async () => { + const states = []; + const send = createSendLinkController({ + sendLink: async () => ({ + code: "SEND_FAILED", + message: "We could not send the link right now.", + }), + getEmail: () => "a@b.com", + onState: (state) => states.push(state), + }); + const result = await send(); + assert.equal(states[0].status, "pending"); + assert.equal(result.status, "error"); + // The server's own message is preserved so the row can say why. + assert.equal(result.error.message, "We could not send the link right now."); + assert.equal(states[states.length - 1].status, "error"); + // A resolved HTTP request is not proof of a sent link: no state may claim it. + assert.ok( + !states.some((state) => state.status === "sent"), + "a failed send must never render as sent", + ); +}); + +test("criterion 2: a resolved 200 with success:false is an error even without a code", async () => { + const states = []; + const send = createSendLinkController({ + sendLink: async () => ({ success: false, error: "Mail delivery failed" }), + getEmail: () => "a@b.com", + onState: (state) => states.push(state), + }); + const result = await send(); + assert.equal(result.status, "error"); + assert.equal(result.error.message, "Mail delivery failed"); + assert.equal(states[states.length - 1].status, "error"); +}); + +test("criterion 2: a genuine success body still reports sent", async () => { + const states = []; + const send = createSendLinkController({ + sendLink: async () => ({ success: true, message: "Magic link sent" }), + getEmail: () => "a@b.com", + onState: (state) => states.push(state), + }); + const result = await send(); + assert.equal(result.status, "sent"); + assert.equal(result.data.message, "Magic link sent"); + assert.equal(states[states.length - 1].status, "sent"); }); test("criterion 2: a failed reauth surfaces the real failure, not a canned success", async () => { @@ -151,7 +204,7 @@ test("criterion 2: a duplicate reauth while one is in flight is refused and send } finally { // Always settle the in-flight request so a failing assertion cannot strand // a forever-pending promise and cancel the sibling tests. - release({ code: "sent" }); + release({ code: MAGIC_LINK_SUCCESS_CODE }); } firstResult = await first; assert.equal(firstResult.status, "sent"); @@ -163,7 +216,7 @@ test("criterion 2: after a settle the controller accepts the next send", async ( const send = createSendLinkController({ sendLink: async () => { sendLinkCalls += 1; - return { code: "sent" }; + return { code: MAGIC_LINK_SUCCESS_CODE }; }, getEmail: () => "a@b.com", onState: () => {}, diff --git a/src/authMagicLink.js b/src/authMagicLink.js index a2c57e5..51a086a 100644 --- a/src/authMagicLink.js +++ b/src/authMagicLink.js @@ -36,12 +36,29 @@ export function explainPlusAliasRule(rawEmail) { return renderRejectionMessage(email, domain); } +/** + * A magic-link send is only successful when the server says so: an explicit + * `success: true`, the success code, or a legacy response that carries no code + * and no failure field. An explicit `success: false` or a truthy `error` is + * never a success, with or without a code, and an empty or malformed body is + * not success-shaped either. Any other code — including the plus-alias + * rejection — is an error, even though the HTTP request itself succeeded. + */ +export function isMagicLinkSuccess(data) { + if (!data || typeof data !== 'object' || Array.isArray(data)) return false; + if (data.success === false || data.error) return false; + if (data.code) return data.code === MAGIC_LINK_SUCCESS_CODE; + // Legacy code-less response: only an explicit success or the legacy + // message-only shape counts. + return data.success === true || typeof data.message === 'string'; +} + export function getMagicLinkResultMessage(data, email) { if (data?.code === PLUS_ALIAS_REJECTED_CODE) { return data.message || explainPlusAliasRule(email) || 'Please enter your primary email address.'; } - if (data?.code === MAGIC_LINK_SUCCESS_CODE || !data?.code) { + if (isMagicLinkSuccess(data)) { return `Check your email — we sent a link to ${trimEmail(email)}`; } - return data.message || 'Something went wrong. Please try again.'; + return data?.message || 'Something went wrong. Please try again.'; } diff --git a/src/authMagicLink.test.js b/src/authMagicLink.test.js index b876031..9860fa7 100644 --- a/src/authMagicLink.test.js +++ b/src/authMagicLink.test.js @@ -5,6 +5,7 @@ import { explainPlusAliasRule, getMagicLinkResultMessage, isKnownProviderPlusAlias, + isMagicLinkSuccess, trimEmail, } from "./authMagicLink.js"; @@ -78,3 +79,81 @@ test("getMagicLinkResultMessage treats legacy success responses as sent", () => `Check your email — we sent a link to user@${firstAllowlistedDomain}` ); }); + +test("isMagicLinkSuccess accepts only the success code or a genuinely success-shaped legacy response", () => { + assert.equal(isMagicLinkSuccess({ code: fixtures.successCode }), true); + assert.equal(isMagicLinkSuccess({ success: true, message: "Magic link sent" }), true); + assert.equal(isMagicLinkSuccess({ message: "Check your email" }), true); + + // The alias rejection is an error despite the HTTP success... + assert.equal( + isMagicLinkSuccess({ code: fixtures.validationCode, message: "Use primary email." }), + false + ); + // ...and so is any other server error code, which must never read as sent. + assert.equal( + isMagicLinkSuccess({ code: "SEND_FAILED", message: "Could not send the link." }), + false + ); +}); + +test("isMagicLinkSuccess rejects explicit failures, error fields and empty bodies", () => { + // A 200 that says it failed is a failure, code or no code — never "sent". + assert.equal(isMagicLinkSuccess({ success: false }), false); + assert.equal( + isMagicLinkSuccess({ success: false, message: "Could not send the link." }), + false + ); + assert.equal( + isMagicLinkSuccess({ success: false, error: "Mail delivery failed" }), + false + ); + assert.equal( + isMagicLinkSuccess({ success: false, code: fixtures.successCode }), + false + ); + + // A code-less error payload is an error shape, not a legacy success. + assert.equal(isMagicLinkSuccess({ error: "Mail delivery failed" }), false); + + // Empty or malformed bodies are not success-shaped. + assert.equal(isMagicLinkSuccess({}), false); + assert.equal(isMagicLinkSuccess(null), false); + assert.equal(isMagicLinkSuccess(undefined), false); + assert.equal(isMagicLinkSuccess("sent"), false); + assert.equal(isMagicLinkSuccess([]), false); +}); + +test("getMagicLinkResultMessage stays aligned with the success predicate", () => { + // A code-less failure must never read as a sent link. + assert.equal( + getMagicLinkResultMessage({ success: false }, `user@${firstAllowlistedDomain}`), + "Something went wrong. Please try again." + ); + assert.equal( + getMagicLinkResultMessage( + { success: false, message: "Could not send the link." }, + `user@${firstAllowlistedDomain}` + ), + "Could not send the link." + ); + assert.equal( + getMagicLinkResultMessage( + { error: "Mail delivery failed" }, + `user@${firstAllowlistedDomain}` + ), + "Something went wrong. Please try again." + ); + // Even the success code cannot rescue an explicit failure. + assert.equal( + getMagicLinkResultMessage( + { success: false, code: fixtures.successCode, message: "Delivery failed." }, + `user@${firstAllowlistedDomain}` + ), + "Delivery failed." + ); + assert.equal( + getMagicLinkResultMessage(null, `user@${firstAllowlistedDomain}`), + "Something went wrong. Please try again." + ); +});