diff --git a/.github/workflows/check_oss_submodule.yml b/.github/workflows/check_oss_submodule.yml index 1a7cfb7f6..34c241937 100644 --- a/.github/workflows/check_oss_submodule.yml +++ b/.github/workflows/check_oss_submodule.yml @@ -17,5 +17,8 @@ jobs: uses: astral-sh/setup-uv@v3 - name: Check OSS branch + # pygit2 1.14.1 ships wheels only up to CPython 3.12. On runner images + # with a newer default Python (ubuntu-26.04 has 3.14), uv would build it + # from source and fail, because the libgit2 headers are not installed. run: | - uv run --with "pygit2==1.14.1" submodule_is_mergeable.py + uv run --python 3.12 --with "pygit2==1.14.1" submodule_is_mergeable.py diff --git a/.github/workflows/enterprise_e2e.yml b/.github/workflows/enterprise_e2e.yml new file mode 100644 index 000000000..0116b80f7 --- /dev/null +++ b/.github/workflows/enterprise_e2e.yml @@ -0,0 +1,79 @@ +name: Enterprise E2E + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: enterprise-e2e-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + dispatch: + name: Dispatch Enterprise Web Console E2E + runs-on: ubuntu-latest + timeout-minutes: 5 + # Mirrors the TypeScript client lane. Fork PRs never receive dispatch + # credentials; live EE tests/builds execute only in the private repository. + if: >- + vars.ENTERPRISE_E2E_ENABLED == 'true' && (github.event_name != + 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository) + steps: + - name: Queue private Enterprise Web Console pipeline + env: + ENT_DISPATCH_PAT: ${{ secrets.ENT_DISPATCH_PAT }} + UI_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + UI_PR_NUMBER: ${{ github.event.pull_request.number || '' }} + UI_BRANCH: ${{ github.head_ref || github.ref_name }} + run: | + set -euo pipefail + test -n "${ENT_DISPATCH_PAT:-}" || { + echo "ENT_DISPATCH_PAT is not configured" >&2 + exit 1 + } + [[ "$UI_COMMIT" =~ ^[0-9a-f]{40}$ ]] || { + echo "Expected an immutable UI commit SHA" >&2 + exit 1 + } + ORG_URL="https://dev.azure.com/questdb/" + PROJECT="questdb-enterprise" + PIPELINE_NAME="build-and-test-e2e-web-console" + azure_failure() { + if [ -z "$2" ]; then + echo "Azure DevOps $1 failed without a response body; see curl's error above." >&2 + else + echo "Azure DevOps rejected $1: $( + jq -er '.message | strings | select(length > 0)' <<<"$2" 2>/dev/null || + head -c 500 <<<"$2" + )" >&2 + fi + } + if ! PIPELINES=$(curl --fail-with-body -sS -u ":${ENT_DISPATCH_PAT}" \ + "${ORG_URL}${PROJECT}/_apis/pipelines?api-version=7.0"); then + azure_failure "pipeline lookup" "$PIPELINES" + exit 1 + fi + PIPELINE_ID=$(jq -r --arg name "$PIPELINE_NAME" \ + '.value[] | select(.name == $name) | .id' <<<"$PIPELINES" | head -1) + test -n "$PIPELINE_ID" && [ "$PIPELINE_ID" != "null" ] || { + echo "Enterprise pipeline '$PIPELINE_NAME' is not registered" >&2 + exit 1 + } + BODY=$(jq -n --arg commit "$UI_COMMIT" --arg pr "$UI_PR_NUMBER" --arg branch "$UI_BRANCH" ' + {templateParameters: ( + {webConsoleCommit: $commit, clientBranch: $branch} + + (if $pr == "" then {} else {webConsolePrNumber: $pr} end) + )}') + if ! RESPONSE=$(curl --fail-with-body -sS -u ":${ENT_DISPATCH_PAT}" \ + -H "Content-Type: application/json" -X POST -d "$BODY" \ + "${ORG_URL}${PROJECT}/_apis/pipelines/${PIPELINE_ID}/runs?api-version=7.0"); then + azure_failure "dispatch" "$RESPONSE" + exit 1 + fi + echo "Enterprise E2E queued: $(jq -r '._links.web.href' <<<"$RESPONSE")" diff --git a/e2e/README.md b/e2e/README.md index 50ed02fcf..28281f677 100644 --- a/e2e/README.md +++ b/e2e/README.md @@ -2,14 +2,17 @@ To run browser tests in a local dev environment: -* make sure `localhost:9999` points to QuestDB Web Console -* disable telemetry in QuestDB Web Console. Use env variable `QDB_TELEMETRY_ENABLED=false` - if running with docker, you can pass the variable like this: +- make sure `localhost:9999` points to QuestDB Web Console +- disable telemetry in QuestDB Web Console. Use env variable + `QDB_TELEMETRY_ENABLED=false` if running with docker, you can pass the + variable like this: + ``` docker run -e QDB_TELEMETRY_ENABLED=false -p 9000:9000 -p 9009:9009 questdb/questdb:latest ``` -* run `cypress` through `yarn`: +- run `cypress` through `yarn`: + ``` yarn test:e2e # console tests yarn test:e2e:auth # auth tests @@ -21,3 +24,57 @@ If you want to interact with cypress, you can start it like so: ``` yarn cypress open ``` + +## Enterprise integration lane + +The ordinary CI workflow always runs `e2e/tests/auth/*.spec.js`. These use +stubbed HTTP responses and cover token parsing (including string/zero expiry), +JSON 429/503 refresh failures, credential precedence, error focus/dismissal and +retrying the initial SQL check without requiring a licensed database. + +Live EE builds and authentication fixtures belong in the **private Enterprise +repository**, following its JavaScript-client cross-repository lane. This +repository's `.github/workflows/enterprise_e2e.yml` only dispatches Azure; it +neither checks out Enterprise source nor downloads an EE binary. + +One-time maintainer setup: + +1. Register Enterprise's `ci/build-and-test-e2e-web-console.yaml` as the Azure + pipeline `build-and-test-e2e-web-console` in the `questdb-enterprise` + project. Authorize its existing `github-public-read` variable group and + ensure the private lane's `GITHUB_TOKEN` can post commit statuses to + `questdb/ui`. +2. Give the UI repository the Actions secret `ENT_DISPATCH_PAT`, scoped to Azure + Build (Read & execute) with permission to queue that pipeline. It needs no + access to the private GitHub repository or to Enterprise binaries. +3. Set the UI Actions variable `ENTERPRISE_E2E_ENABLED=true`. + +Same-repository PRs, main pushes and manual runs dispatch the exact UI commit +SHA. Fork PRs never receive the PAT. Enterprise matches the UI branch when an EE +branch of the same name exists (otherwise uses main), builds EE with its +existing private test harness, and builds the public UI checkout at that SHA. +The private pytest driver runs `yarn test:e2e:enterprise` against a fresh EE +server, validates that invalid credentials are rejected, and supplies the +server-side userinfo fixture. Cypress still stubs browser code/token exchange. +The result is posted to the tested SHA as `enterprise-e2e-web-console`, even if +the PR head moves while tests run. A successful dispatch is **not** a passed +integration run: check that separate status. + +One `oidc.spec.js` test leaves `/exec` unstubbed to check the tandem server +change (questdb#7799). After a reload installs a bearer that the userinfo +fixture rejects, the server answers 401 even though the browser still sends a +valid `qdb_session` cookie, and the console renews its token once and resends +once. The 401 also deleted the cookie, so the resend asks the server for a new +session: downloads authenticate with that cookie alone. ID-token bearers +(`acl.oidc.groups.encoded.in.token=true`) are tested only by unit and stubbed +browser tests: the lane would need a fixture that signs ID tokens. +`auth/auth.spec.js` replays that startup sequence with stubbed 401s, for both +bearer kinds, so the required auth lane covers the console side of it. It cannot +replace this lane: only a real server shows how the server responds. + +For local runs, follow the Enterprise e2e README and set +`QDB_WEB_CONSOLE_DIR=/absolute/path/to/built/ui`, then run +`pytest -m web_console` in `questdb-ent/e2e`. The UI checkout needs +`yarn install --immutable`, `yarn build`, and a working Cypress installation +(`yarn cypress verify`). Ports 9000 and 9999 must be free; the harness owns and +tears down both services. diff --git a/e2e/commands.js b/e2e/commands.js index 40c020d64..ccd355a83 100644 --- a/e2e/commands.js +++ b/e2e/commands.js @@ -581,13 +581,18 @@ Cypress.Commands.add("selectQueries", (selection) => { }) Cypress.Commands.add("execQuery", (query) => { - const authHeader = localStorage.getItem("basic.auth.header") - cy.request({ - method: "GET", - url: `${baseUrl}/exec?query=${encodeURIComponent(query)};`, - headers: { - Authorization: authHeader, - }, + return cy.window().then(({ localStorage }) => { + // EE password login stores a REST token, OSS stores a Basic header. + // Read the application's storage at request time, not the runner window. + const restToken = localStorage.getItem("rest.token") + const authorization = restToken + ? `Bearer ${restToken}` + : localStorage.getItem("basic.auth.header") + return cy.request({ + method: "GET", + url: `${baseUrl}/exec?query=${encodeURIComponent(query)};`, + headers: authorization ? { Authorization: authorization } : {}, + }) }) }) diff --git a/e2e/questdb b/e2e/questdb index 117720894..f21c6570c 160000 --- a/e2e/questdb +++ b/e2e/questdb @@ -1 +1 @@ -Subproject commit 11772089470d893ad904af4ce6a233601ffbc221 +Subproject commit f21c6570ca286060ff23ff0fb06a040871c3bd1c diff --git a/e2e/tests/auth/auth.spec.js b/e2e/tests/auth/auth.spec.js index cfff7e699..2507e6064 100644 --- a/e2e/tests/auth/auth.spec.js +++ b/e2e/tests/auth/auth.spec.js @@ -1,5 +1,7 @@ /// +const { ctrlOrCmd, seedNotebookOnboarding } = require("../../utils") + const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" const baseUrl = `http://localhost:9999${contextPath}` @@ -19,10 +21,30 @@ const startedFlow = { codeVerifier: "verifier-we-generated", } -const visitCallback = (query, flow) => +const queryResult = { + query: "select 2", + columns: [{ name: "column", type: "INT" }], + dataset: [[2]], + count: 1, +} + +const stubConsoleRequests = () => { + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*` }, + { statusCode: 200, body: { columns: [], dataset: [], count: 0 } }, + ) + cy.intercept({ method: "GET", url: `${baseUrl}/warnings*` }, []) + cy.intercept({ method: "GET", url: `${baseUrl}/chk*` }, { status: "Exists" }) +} + +const visitCallback = (query, flow, credentials = {}) => cy.visit(`${baseUrl}${query}`, { onBeforeLoad(win) { win.localStorage.clear() + seedNotebookOnboarding(win) + Object.entries(credentials).forEach(([key, value]) => { + win.localStorage.setItem(key, value) + }) if (flow) { win.localStorage.setItem(STORE_KEY.oauthState, flow.state) win.localStorage.setItem(STORE_KEY.pkceCodeVerifier, flow.codeVerifier) @@ -102,6 +124,7 @@ describe("Auth - OIDC", () => { describe("Auth - Basic", () => { before(() => { + stubConsoleRequests() interceptSettings({ config: { "release.type": "EE", @@ -256,23 +279,7 @@ describe("Auth - OIDC callback validation", () => { }, ).as("tokenExchange") - cy.intercept( - { method: "GET", url: `${baseUrl}/exec*` }, - { - statusCode: 200, - body: { - query: "select 2", - columns: [{ name: "column", type: "INT" }], - dataset: [[2]], - count: 1, - }, - }, - ) - cy.intercept({ method: "GET", url: `${baseUrl}/warnings*` }, []) - cy.intercept( - { method: "GET", url: `${baseUrl}/chk*` }, - { status: "Exists" }, - ) + stubConsoleRequests() } const expectNoTokenExchange = () => { @@ -362,3 +369,1097 @@ describe("Auth - OIDC callback validation", () => { }) }) }) + +describe("Auth - OIDC sign-in errors", () => { + const tokenUrl = "https://oauth.example.com/token" + // The console's own params, e.g. a shared query, which it saves before the + // redirect to the provider + const returnParams = "query=select+1" + + const visitCallbackWithReturnParams = (query) => + cy.visit(`${baseUrl}${query}`, { + onBeforeLoad(win) { + win.localStorage.clear() + win.sessionStorage.setItem("oauth.return.params", returnParams) + }, + }) + + beforeEach(() => { + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": false, + }, + }) + stubConsoleRequests() + }) + + it("does not resubmit a code that failed to sign in on reload", () => { + // Given a provider that rejects the authorization code + cy.intercept( + { method: "POST", url: tokenUrl }, + { + statusCode: 400, + body: { error: "invalid_grant", error_description: "Code not valid" }, + }, + ).as("tokenExchange") + visitCallbackWithReturnParams("?code=code-from-the-provider") + cy.wait("@settings") + cy.contains("Code not valid").should("be.visible") + + // Then the URL gets back the console's own params instead of the code + cy.location("search").should("eq", `?${returnParams}`) + + // When the page is reloaded + cy.reload() + + // Then the console shows the login page without exchanging the code again + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("Code not valid").should("not.exist") + cy.get("@tokenExchange.all").should("have.length", 1) + }) + + it("does not show a provider error again on reload", () => { + // Given a provider that redirects back with an error + visitCallbackWithReturnParams( + "?error=access_denied&error_description=Sign-in+cancelled", + ) + cy.wait("@settings") + cy.contains("access_denied: Sign-in cancelled").should("be.visible") + + // Then the URL gets back the console's own params instead of the error + cy.location("search").should("eq", `?${returnParams}`) + + // When the page is reloaded + cy.reload() + + // Then the console shows the login page without the old error + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("access_denied: Sign-in cancelled").should("not.exist") + }) + + it("restores focus to the sign-in heading when the error is dismissed", () => { + cy.intercept("POST", tokenUrl, { + statusCode: 400, + body: { error: "invalid_grant" }, + }) + visitCallback("?code=bad-code") + cy.getByDataHook("sign-in-error") + .should("be.focused") + .should(($error) => { + const win = $error[0].ownerDocument.defaultView + const style = win.getComputedStyle($error[0]) + expect(style.outlineStyle).not.to.equal("none") + expect(parseFloat(style.outlineWidth)).to.be.greaterThan(0) + }) + cy.get('[aria-label="Dismiss sign-in error"]').focus().click() + cy.getByDataHook("sign-in-error").should("not.exist") + cy.contains("h2", "Sign in to QuestDB").should("be.focused") + }) + + it("keeps password focus when submitting with an SSO error still visible", () => { + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { session: "true", query: /alter user .* create token/ }, + }, + { statusCode: 401, delay: 1000, body: "Unauthorized" }, + ).as("passwordLogin") + visitCallback("?error=access_denied&error_description=Sign-in+cancelled") + cy.getByDataHook("sign-in-error").should("be.focused") + cy.get('form[name="login"] input[name="username"]').type("admin") + cy.get('form[name="login"] input[name="password"]').type("wrong{enter}") + cy.getByDataHook("sign-in-error").should("not.exist") + cy.get('form[name="login"] input[name="password"]').should("be.focused") + cy.wait("@passwordLogin") + cy.contains("Invalid username or password").should("be.visible") + cy.get('form[name="login"] input[name="password"]').should("be.focused") + }) + + for (const { failure, reply, message } of [ + { + failure: "cannot be reached", + reply: { forceNetworkError: true }, + message: + "Could not reach the identity provider to complete the sign-in. Please try again.", + }, + { + failure: "returns a response that is not JSON", + reply: { statusCode: 502, body: "Bad Gateway" }, + message: + "The identity provider returned an unexpected response (HTTP 502). Please try again.", + }, + ]) { + it(`shows an error, not a blank page, when the token endpoint ${failure}`, () => { + // Given a token endpoint that fails without an OAuth response + cy.intercept({ method: "POST", url: tokenUrl }, reply) + visitCallback("?code=code-from-the-provider") + cy.wait("@settings") + + // Then the console shows the login page with the error + cy.getByDataHook("auth-login").should("be.visible") + cy.contains(message) + .closest("[data-hook='sign-in-error']") + .should("be.focused") + }) + } + + it("shows an error, not a blank page, when the token endpoint does not respond", () => { + // Given a token endpoint that does not respond within the timeout + let requested = false + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + requested = true + req.reply({ delay: 30000, body: {} }) + }) + // Fake only the timeouts, so the test does not wait for 10 seconds + cy.clock(Date.now(), ["setTimeout", "clearTimeout"]) + visitCallback("?code=code-from-the-provider") + cy.wait("@settings") + cy.wrap(null).should(() => expect(requested).to.equal(true)) + + // When the timeout elapses + cy.tick(10000) + + // Then the console shows the login page with the error + cy.getByDataHook("auth-login").should("be.visible") + cy.contains( + "The identity provider did not respond within 10 seconds. Please try again.", + ) + .closest("[data-hook='sign-in-error']") + .should("be.focused") + }) +}) + +describe("Auth - OIDC ID token", () => { + const tokenUrl = "https://oauth.example.com/token" + + // Unsigned: the console only reads the claims, and the stubbed server does + // not validate the token. + const idToken = (subject, lifetimeSeconds) => { + const base64url = (part) => + btoa(JSON.stringify(part)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, "") + const iat = Math.floor(Date.now() / 1000) + return [ + base64url({ alg: "RS256", typ: "JWT" }), + base64url({ sub: subject, iat, exp: iat + lifetimeSeconds }), + "signature", + ].join(".") + } + + // Signs in with SSO to a server that expects the ID token as the bearer. + // The provider returns the ID token given for each grant type, while the + // token response's expires_in describes the access token. + const signIn = ({ + idTokens, + accessTokenLifetime = 3600, + refreshReply, + credentials, + authCheckReply, + }) => { + const grants = [] + + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": true, + }, + }) + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + const grant = new URLSearchParams(req.body).get("grant_type") + grants.push(grant) + if (grant === "refresh_token" && refreshReply) { + req.alias = "refreshFailure" + if (typeof refreshReply === "function") return refreshReply(req) + req.reply(refreshReply) + return + } + req.reply({ + access_token: `${grant}-access-token`, + refresh_token: "refresh-token", + id_token: idTokens[grant], + token_type: "Bearer", + expires_in: accessTokenLifetime, + }) + }) + stubConsoleRequests() + // The first query after sign-in, which waits for a due refresh + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: /^SELECT 1$/ }, + }, + authCheckReply ?? { statusCode: 200, body: queryResult }, + ).as("authCheck") + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*`, query: { session: "true" } }, + { statusCode: 200, body: queryResult }, + ).as("sessionStart") + + visitCallback("?code=code-from-the-provider", undefined, credentials) + cy.wait("@settings") + return grants + } + + const expectMissingIdTokenError = (detail) => { + cy.getByDataHook("auth-login").should("be.visible") + cy.contains("Missing ID token") + .closest("[data-hook='sign-in-error']") + .should("be.focused") + cy.contains("ask your administrator to check the SSO configuration").should( + "be.visible", + ) + cy.contains("Your identity provider did not send the information") + .should("be.visible") + .and("contain", detail) + cy.getEditor().should("not.exist") + } + + for (const { failure, reply } of [ + { failure: "network error", reply: { forceNetworkError: true } }, + { + failure: "non-JSON 502", + reply: { statusCode: 502, body: "Bad Gateway" }, + }, + { + failure: "JSON 503", + reply: { statusCode: 503, body: { error: "temporarily_unavailable" } }, + }, + { + failure: "JSON 429", + reply: { statusCode: 429, body: { error: "rate_limited" } }, + }, + ]) { + it(`keeps the session after a refresh ${failure} and a later SQL 401, then recovers`, () => { + // Freeze only Date so the SQL rejection occurs during refresh backoff; + // real timers still drive requests and notification transitions. + cy.clock(Date.now(), ["Date"]) + const initial = idToken("initial", 20) + signIn({ idTokens: { authorization_code: initial }, refreshReply: reply }) + cy.wait("@refreshFailure") + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${initial}`) + cy.getEditor().should("be.visible") + cy.contains( + "Could not renew your SSO session. Your current token expires at", + { matchCase: false }, + ).should("be.visible") + // A persistent warning has no running auto-close animation. + cy.get("#sso-refresh-failed .Toastify__progress-bar").should( + "not.have.class", + "Toastify__progress-bar--animated", + ) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("expiredBearerQuery") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@expiredBearerQuery") + cy.contains("the request was not executed").should("be.visible") + cy.getEditor().should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.get("#sso-refresh-failed").should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.session.active") + .should("eq", "true") + + const renewed = idToken("renewed", 3600) + cy.intercept("POST", tokenUrl, { + body: { + access_token: "renewed-access-token", + id_token: renewed, + refresh_token: "rotated-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }, + }).as("refreshRecovery") + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 200, body: queryResult }, + ).as("recoveredQuery") + cy.tick(5000) + cy.runLine() + cy.wait("@refreshRecovery") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", `Bearer ${renewed}`) + cy.get("#sso-refresh-failed").should("not.exist") + cy.getByDataHook("auth-login").should("not.exist") + + // Exercise the installed rotated credential through the next real + // AuthProvider -> QuestProvider -> token POST, not just its storage. + const renewedAgain = idToken("renewed-again", 7200) + cy.intercept("POST", tokenUrl, (req) => { + const body = new URLSearchParams(req.body) + expect(body.get("grant_type")).to.eq("refresh_token") + expect(body.get("refresh_token")).to.eq("rotated-refresh-token") + req.reply({ + access_token: "renewed-again-access-token", + id_token: renewedAgain, + refresh_token: "next-refresh-token", + token_type: "Bearer", + expires_in: 3600, + }) + }).as("rotatedRefresh") + cy.clock().then((clock) => clock.setSystemTime(Date.now() + 3600000)) + cy.runLine() + cy.wait("@rotatedRefresh") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", `Bearer ${renewedAgain}`) + cy.getByDataHook("auth-login").should("not.exist") + }) + } + + it("renews and resends an editor statement exactly once after the server rejects its bearer", () => { + // The server rejects a bearer the console still considers valid (revoked, + // or the browser clock is behind). A 401 executed no SQL, so the console + // renews and resends the statement once, without signing out. + const initial = idToken("initial", 3600) + const renewed = idToken("renewed", 3600) + signIn({ + idTokens: { authorization_code: initial, refresh_token: renewed }, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.wait("@authCheck") + cy.getEditor().should("be.visible") + const sentWith = [] + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + (req) => { + sentWith.push(req.headers.authorization) + req.reply( + req.headers.authorization === `Bearer ${initial}` + ? { statusCode: 401, body: "Unauthorized" } + : { statusCode: 200, body: queryResult }, + ) + }, + ).as("statement") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@statement") + cy.wait("@statement") + cy.getByDataHook("success-notification").should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.wrap(sentWith).should("deep.equal", [ + `Bearer ${initial}`, + `Bearer ${renewed}`, + ]) + }) + + it("clears the remembered identity before the redirect guard forces sign-in", () => { + // Unknown expiry avoids a renewal on 401: this test targets the redirect + // guard's forced-login path, not refresh recovery or its connection events. + signIn({ + idTokens: { authorization_code: "opaque-id-token" }, + accessTokenLifetime: null, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.getEditor().should("be.visible") + cy.window().then(({ localStorage }) => { + localStorage.setItem("sso.username.test-client", "previous-user") + }) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejectedIdentity") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.window() + .its("localStorage") + .invoke("setItem", "oauth.redirect.count", "5") + cy.runLine() + cy.wait("@rejectedIdentity") + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("button-sso-continue").should("not.exist") + cy.getByDataHook("button-sso-login").should( + "contain.text", + "Single Sign-On (SSO)", + ) + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.test-client") + .should("be.null") + cy.intercept("GET", "https://oauth.example.com/auth*", (req) => { + req.redirect(`${baseUrl}?error=access_denied`) + }).as("forcedSignIn") + cy.getByDataHook("button-sso-login").click() + cy.wait("@forcedSignIn").then(({ request }) => { + expect(new URL(request.url).searchParams.get("prompt")).to.eq("login") + }) + cy.getByDataHook("auth-login").should("be.visible") + }) + + it("hands focus to Login after the warning's sign-in action", () => { + signIn({ + idTokens: { authorization_code: idToken("initial", 20) }, + refreshReply: { + statusCode: 503, + body: { error: "temporarily_unavailable" }, + }, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.wait("@refreshFailure") + cy.get("#sso-refresh-failed").should("be.visible") + cy.contains("#sso-refresh-failed button", "Sign in again").click() + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("sign-in-error") + .should("be.focused") + .and("contain.text", "Your previous session has ended") + cy.get('[aria-label="Dismiss sign-in error"]').click() + cy.contains("h2", "Sign in to QuestDB").should("be.focused") + cy.get('form[name="login"] input[name="username"]') + .type("admin") + .should("be.focused") + }) + + describe("an editor script when the session ends", () => { + const statements = ["select 1001", "select 1002", "select 1003"] + + // Statement 1 gets the given reply, the others would succeed. + const interceptStatements = (firstStatement) => { + statements.forEach((statement, index) => { + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: new RegExp(`^${statement}$`) }, + }, + index === 0 ? firstStatement : { statusCode: 200, body: queryResult }, + ).as(`statement${index + 1}`) + }) + } + + const runScriptDespiteFailures = () => { + cy.clearEditor() + cy.typeQuery(statements.map((statement) => `${statement};`).join("\n")) + cy.typeQuery(`${ctrlOrCmd}{shift}{enter}`) + cy.getByRole("dialog").should("contain", "Run all queries") + // Then only the end of the session can stop the script after statement 1 + cy.getByDataHook("stop-after-failure-checkbox").uncheck() + cy.getByDataHook("run-all-queries-confirm").click() + } + + // Signing in with a password does not leave the page, so the console + // still shows what the previous session left in the query log. + const signInWithPassword = () => { + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { session: "true", query: /alter user .* create token/ }, + }, + { + statusCode: 200, + body: { + query: "alter user", + columns: [ + { name: "username", type: "STRING" }, + { name: "token", type: "STRING" }, + ], + dataset: [["admin", "password-rest-token"]], + count: 1, + }, + }, + ).as("passwordSignIn") + cy.get('form[name="login"] input[name="username"]').type("admin") + cy.get('form[name="login"] input[name="password"]').type("quest{enter}") + cy.wait("@passwordSignIn") + cy.getEditor().should("be.visible") + } + + const expectStoppedAfterStatement1 = (statement1Outcome) => { + cy.get("@statement2.all").should("have.length", 0) + cy.get("@statement3.all").should("have.length", 0) + cy.expandNotifications() + cy.getExpandedNotifications() + .should("contain", statement1Outcome) + .invoke("text") + .should("match", /Stopped after running\s+1 failed\s+query/) + } + + it("stops before the next statement when signing in again, and says the interrupted one may have executed", () => { + // Freeze only Date, so the token stays valid while renewal fails. + cy.clock(Date.now(), ["Date"]) + signIn({ + idTokens: { authorization_code: idToken("initial", 20) }, + refreshReply: { + statusCode: 503, + body: { error: "temporarily_unavailable" }, + }, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.wait("@refreshFailure") + cy.getEditor().should("be.visible") + cy.get("#sso-refresh-failed").should("be.visible") + // Statement 1 reaches the server, which does not answer it yet + let statement1Sent = false + interceptStatements((req) => { + statement1Sent = true + req.reply({ statusCode: 200, body: queryResult, delay: 60000 }) + }) + runScriptDespiteFailures() + cy.wrap(null).should(() => expect(statement1Sent).to.equal(true)) + + cy.on("window:confirm", (text) => { + expect(text).to.contain("statements may already have executed") + return true + }) + cy.contains("#sso-refresh-failed button", "Sign in again").click() + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("sign-in-error") + .should("contain.text", "Your previous session has ended.") + .and("contain.text", "statements may have executed") + + signInWithPassword() + expectStoppedAfterStatement1( + "Interrupted when your previous session ended. The query may have executed; verify its outcome before running it again.", + ) + }) + + it("stops before the next statement when the server signs the session out, and says the rejected one was not run", () => { + // Unknown expiry: the console signs out on 401 instead of renewing. + signIn({ + idTokens: { authorization_code: "opaque-id-token" }, + accessTokenLifetime: null, + credentials: { "auto.refresh.tables": "false" }, + }) + cy.getEditor().should("be.visible") + interceptStatements({ statusCode: 401, body: "Unauthorized" }) + runScriptDespiteFailures() + cy.wait("@statement1") + cy.getByDataHook("auth-login").should("be.visible") + + signInWithPassword() + expectStoppedAfterStatement1( + "Not executed: your previous session ended without running this query.", + ) + }) + }) + + it("shows Login when a pending refresh fails after Settings recovery replaces the providers", () => { + let releaseRefresh + let failSettings = false + cy.clock(Date.now(), ["Date"]) + const grants = signIn({ + idTokens: { authorization_code: idToken("initial", 3600) }, + refreshReply: (req) => + new Promise((resolve) => { + releaseRefresh = () => { + req.reply({ + statusCode: 400, + body: { error: "invalid_grant", error_description: "Revoked" }, + }) + resolve() + } + }), + credentials: { "auto.refresh.tables": "true" }, + }) + cy.getEditor().should("be.visible") + cy.get("@settings").then(({ response }) => { + cy.intercept({ method: "GET", url: `${baseUrl}/settings` }, (req) => { + req.reply( + failSettings + ? { statusCode: 503, body: "Unavailable" } + : { body: response.body }, + ) + }) + }) + cy.clearEditor() + cy.typeQuery("select 42") + cy.tick(3575000) + cy.clickRunIconInLine(1) + cy.wrap(null).should(() => expect(releaseRefresh).to.be.a("function")) + cy.window().then((win) => { + failSettings = true + win.dispatchEvent(new win.Event("focus")) + }) + cy.contains("button", "Retry") + .should("be.visible") + .then(() => { + failSettings = false + }) + .click() + cy.contains("button", "Retry").should("not.exist") + cy.then(() => { + expect( + grants.filter((grant) => grant === "refresh_token"), + ).to.have.length(1) + releaseRefresh() + }) + cy.getByDataHook("auth-login").should("be.visible") + cy.getByDataHook("sign-in-error") + .should("be.focused") + .and("contain.text", "Your SSO session has expired") + .and("contain.text", "Revoked. Please sign in again.") + cy.getEditor().should("not.exist") + }) + + it("starts telemetry only after the auth check and uses the refreshed SSO bearer", () => { + const initial = idToken("initial", 20) + const refreshed = idToken("refreshed", 3600) + let authRequested = false + let releaseAuth + const telemetryHeaders = [] + signIn({ + idTokens: { authorization_code: initial, refresh_token: refreshed }, + credentials: { "auto.refresh.tables": "false" }, + authCheckReply: (req) => { + authRequested = true + return new Promise((resolve) => { + releaseAuth = () => { + req.reply({ body: queryResult }) + resolve() + } + }) + }, + }) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: /^telemetry_config limit -1$/i }, + }, + (req) => { + telemetryHeaders.push(req.headers.authorization) + req.reply({ body: { columns: [], count: 0, dataset: [] } }) + }, + ).as("telemetrySQL") + cy.wrap(null).should(() => { + expect(authRequested).to.equal(true) + expect(telemetryHeaders).to.deep.equal([]) + }) + cy.getEditor().should("not.exist") + cy.then(() => releaseAuth()) + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${refreshed}`) + cy.wait("@telemetrySQL") + .its("request.headers.authorization") + .should("eq", `Bearer ${refreshed}`) + cy.getEditor().should("be.visible") + cy.then(() => + expect(telemetryHeaders).to.deep.equal([`Bearer ${refreshed}`]), + ) + }) + + it("preserves other tabs' password credentials when signing in with SSO", () => { + const initial = idToken("initial", 3600) + signIn({ + idTokens: { authorization_code: initial }, + credentials: { + "rest.token": "expired-rest-token", + "basic.auth.header": "Basic expired", + }, + }) + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${initial}`) + cy.getEditor().should("be.visible") + cy.window().then(({ localStorage, sessionStorage }) => { + expect(localStorage.getItem("rest.token")).to.eq("expired-rest-token") + expect(localStorage.getItem("basic.auth.header")).to.eq("Basic expired") + expect(sessionStorage.getItem("auth.login.method")).to.eq("sso") + }) + }) + + it("refreshes an ID token that expires before the access token", () => { + // Given an ID token within the 30s refresh window, and an access token + // that is valid for an hour + const refreshed = idToken("refreshed", 3600) + const grants = signIn({ + idTokens: { + authorization_code: idToken("initial", 20), + refresh_token: refreshed, + }, + accessTokenLifetime: 3600, + }) + + // Then the console refreshes before its first query, and sends the new + // ID token + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${refreshed}`) + cy.wrap(grants).should("deep.equal", [ + "authorization_code", + "refresh_token", + ]) + }) + + it("does not refresh an ID token that outlives the access token", () => { + // Given an ID token that is valid for an hour, and an access token within + // the 30s refresh window + const initial = idToken("initial", 3600) + const grants = signIn({ + idTokens: { + authorization_code: initial, + refresh_token: idToken("refreshed", 3600), + }, + accessTokenLifetime: 20, + }) + + // Then the console keeps sending the initial ID token + cy.wait("@authCheck") + .its("request.headers.authorization") + .should("eq", `Bearer ${initial}`) + cy.wrap(grants).should("deep.equal", ["authorization_code"]) + }) + + it("explains a sign-in without the ID token the server expects", () => { + // Given a provider that returns no ID token, as without the openid scope + signIn({ idTokens: {} }) + + // Then the console explains the problem instead of starting a session, + // and drops the used code from the URL + expectMissingIdTokenError("needed to sign you in.") + cy.get("@sessionStart.all").should("have.length", 0) + cy.location("search").should("eq", "") + }) + + it("explains a refresh without the ID token the server expects", () => { + // Given a provider that omits the ID token on refresh, which OIDC allows + const initial = idToken("initial", 20) + signIn({ idTokens: { authorization_code: initial } }) + + // Then the console ends the session and cancels the waiting SQL request. + expectMissingIdTokenError("needed to keep you signed in.") + cy.get("@authCheck.all").should("have.length", 0) + }) +}) + +describe("Auth - OIDC bearer rejected at startup", () => { + // questdb#7799 answers a rejected bearer with 401 even when the browser also + // sends a valid qdb_session cookie, and closes that session. The Enterprise + // lane checks this against a real server (enterprise/oidc.spec.js). This + // replays its startup sequence with stubs, for both bearer kinds, so that + // the required auth lane covers the console's side of it. + const tokenUrl = "https://oauth.example.com/token" + const rejected = "bearer-the-server-rejects" + const accepted = "bearer-the-server-accepts" + + for (const { bearer, groupsEncodedInToken } of [ + { bearer: "access token", groupsEncodedInToken: false }, + { bearer: "ID token", groupsEncodedInToken: true }, + ]) { + it(`renews once and resends its startup check once when the server rejects a new ${bearer} bearer`, () => { + const grants = [] + const sessionStarts = [] + const authChecks = [] + // The token endpoint issues both bearers; the server accepts only one. + const answer = (requests) => (req) => { + const statusCode = + req.headers.authorization === `Bearer ${accepted}` ? 200 : 401 + // session=true asks the server to open a qdb_session cookie session. + const session = req.query.session === "true" ? " session=true" : "" + requests.push(`${req.headers.authorization}${session} -> ${statusCode}`) + req.reply( + statusCode === 200 + ? { statusCode, body: queryResult } + : { statusCode, body: "Unauthorized" }, + ) + } + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": groupsEncodedInToken, + }, + }) + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + const grant = new URLSearchParams(req.body).get("grant_type") + grants.push(grant) + const token = grant === "refresh_token" ? accepted : rejected + // Either token may be the bearer, depending on the server's setting. + req.reply({ + access_token: token, + id_token: token, + refresh_token: `${grant}-refresh-token`, + token_type: "Bearer", + expires_in: 3600, + }) + }) + stubConsoleRequests() + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*`, query: { session: "true" } }, + answer(sessionStarts), + ) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: /^SELECT 1$/ }, + }, + answer(authChecks), + ) + + visitCallback("?code=code-from-the-provider", undefined, { + "auto.refresh.tables": "false", + }) + cy.wait("@settings") + + // A 401 executed no SQL: the console renews its token once, resends its + // startup check once with the renewed bearer, and keeps the session. + // The 401 also closed the cookie session that downloads use, so the + // resent check asks for a new one. + cy.getEditor().should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.wrap(authChecks).should("deep.equal", [ + `Bearer ${rejected} -> 401`, + `Bearer ${accepted} session=true -> 200`, + ]) + cy.wrap(sessionStarts).should("deep.equal", [ + `Bearer ${rejected} session=true -> 401`, + ]) + cy.wrap(grants).should("deep.equal", [ + "authorization_code", + "refresh_token", + ]) + + // Statements then use the renewed bearer, without another renewal, and + // don't ask for another session. + const statements = [] + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + answer(statements), + ).as("statement") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@statement") + cy.getByDataHook("success-notification").should("be.visible") + cy.then(() => { + expect(statements).to.deep.equal([`Bearer ${accepted} -> 200`]) + expect(authChecks).to.have.length(2) + expect(sessionStarts).to.have.length(1) + expect(grants).to.have.length(2) + }) + }) + } +}) + +describe("Auth - OIDC token without a lifetime", () => { + const tokenUrl = "https://oauth.example.com/token" + + beforeEach(() => { + interceptSettings({ + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": tokenUrl, + "acl.oidc.state.required": false, + "acl.oidc.groups.encoded.in.token": false, + }, + }) + stubConsoleRequests() + }) + + for (const expires_in of ["3600", 0, -1, null]) { + it(`signs in with optional lifetime ${JSON.stringify(expires_in)} and null fields`, () => { + cy.intercept("POST", tokenUrl, { + body: { + access_token: "mock-access-token", + expires_in, + refresh_token: null, + id_token: null, + token_type: null, + error: null, + error_description: null, + }, + }) + visitCallback("?code=code-from-the-provider") + cy.getEditor().should("be.visible") + }) + } + + it("keeps recovery focus and announces failure before a successful retry", () => { + cy.intercept("POST", tokenUrl, { access_token: "mock-access-token" }) + let attempts = 0 + let completeRetry + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*`, query: { query: "SELECT 1" } }, + (req) => { + attempts++ + if (attempts === 1) { + req.reply({ statusCode: 502 }) + return + } + return new Promise((resolve) => { + completeRetry = () => { + req.reply( + attempts === 2 ? { statusCode: 502 } : { body: queryResult }, + ) + resolve() + } + }) + }, + ) + visitCallback("?code=code-from-the-provider") + cy.contains("QuestDB is not reachable [502]") + .should("be.visible") + .should(($error) => { + const win = $error[0].ownerDocument.defaultView + expect(win.getComputedStyle($error[0]).color).not.to.equal( + win.getComputedStyle(win.document.body).color, + ) + }) + cy.contains("h1", "Could not connect to QuestDB").should("be.focused") + cy.contains("button", "Retry connection").focus().click() + cy.contains("button", "Retry connection") + .should("be.focused") + .and("have.attr", "aria-disabled", "true") + .and("have.attr", "aria-busy", "true") + .and("not.be.disabled") + .click() + cy.get('[role="status"]').should("contain.text", "Reconnecting…") + cy.contains("QuestDB is not reachable [502]").should("be.visible") + cy.getEditor().should("not.exist") + cy.wrap(null).should(() => { + expect(attempts).to.equal(2) + expect(completeRetry).to.be.a("function") + }) + cy.then(() => completeRetry()) + cy.get('[role="status"]').should( + "contain.text", + "Connection failed. Retry connection.", + ) + cy.contains("button", "Retry connection") + .should("be.focused") + .and("have.attr", "aria-busy", "false") + .click() + cy.get('[role="status"]').should("contain.text", "Reconnecting…") + cy.wrap(null).should(() => expect(attempts).to.equal(3)) + cy.then(() => completeRetry()) + cy.getEditor().should("be.visible") + }) + + for (const expires_in of [0, "0"]) { + it(`renews a zero-lifetime token (${JSON.stringify(expires_in)}) before SQL`, () => { + cy.intercept("POST", tokenUrl, (req) => { + const body = new URLSearchParams(req.body) + const refreshing = body.get("grant_type") === "refresh_token" + if (refreshing) { + expect(body.get("refresh_token")).to.eq("initial-refresh-token") + req.alias = "zeroLifetimeRefresh" + } + req.reply({ + access_token: refreshing + ? "renewed-access-token" + : "expired-access-token", + refresh_token: refreshing + ? "rotated-refresh-token" + : "initial-refresh-token", + expires_in: refreshing ? 3600 : expires_in, + }) + }) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "SELECT 1" }, + }, + { statusCode: 200, body: queryResult }, + ).as("zeroLifetimeSQL") + visitCallback("?code=code-from-the-provider") + cy.wait("@zeroLifetimeRefresh") + cy.wait("@zeroLifetimeSQL") + .its("request.headers.authorization") + .should("eq", "Bearer renewed-access-token") + cy.getEditor().should("be.visible") + }) + } + + it("signs in, never refreshes the token, and signs out once the server rejects it", () => { + // Given a provider that sends no expires_in, although it sends a refresh + // token + const grants = [] + cy.intercept({ method: "POST", url: tokenUrl }, (req) => { + grants.push(new URLSearchParams(req.body).get("grant_type")) + req.reply({ + access_token: "mock-access-token", + refresh_token: "refresh-token", + token_type: "Bearer", + }) + }) + visitCallback("?code=code-from-the-provider") + cy.wait("@settings") + + // Then the console signs in, and uses the token without refreshing it + cy.getEditor().should("be.visible") + cy.wrap(grants).should("deep.equal", ["authorization_code"]) + + // When the server rejects the expired token + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejected") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + + // Then the user signs in again, without a refresh or a retried query + cy.getByDataHook("auth-login").should("be.visible") + cy.get("@rejected.all").should("have.length", 1) + cy.wrap(grants).should("deep.equal", ["authorization_code"]) + }) +}) diff --git a/e2e/tests/auth/unauthorized.spec.js b/e2e/tests/auth/unauthorized.spec.js new file mode 100644 index 000000000..946c203c7 --- /dev/null +++ b/e2e/tests/auth/unauthorized.spec.js @@ -0,0 +1,72 @@ +/// + +const { seedNotebookOnboarding } = require("../../utils") + +const contextPath = process.env.QDB_HTTP_CONTEXT_WEB_CONSOLE || "" +const baseUrl = `http://localhost:9999${contextPath}` + +for (const oidcEnabled of [false, true]) { + for (const { key, value, authorization } of [ + { + key: "rest.token", + value: "rest-token", + authorization: "Bearer rest-token", + }, + { + key: "basic.auth.header", + value: "Basic dXNlcjpwYXNz", + authorization: "Basic dXNlcjpwYXNz", + }, + ]) { + describe(`401 recovery: ${key}, OIDC ${oidcEnabled ? "on" : "off"}`, () => { + it("logs out the current tab even if another tab removed the saved credential", () => { + cy.intercept(`${baseUrl}/settings`, { + config: { + "release.type": "EE", + "release.version": "1.2.3", + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + "acl.oidc.enabled": oidcEnabled, + "acl.oidc.client.id": "test-client", + "acl.oidc.authorization.endpoint": "https://oauth.example.com/auth", + "acl.oidc.token.endpoint": "https://oauth.example.com/token", + }, + }) + cy.intercept(`${baseUrl}/exec*`, { + columns: [], + dataset: [], + count: 0, + }) + cy.intercept(`${baseUrl}/warnings*`, []) + cy.intercept(`${baseUrl}/chk*`, { status: "Exists" }) + cy.visit(baseUrl, { + onBeforeLoad(win) { + win.localStorage.clear() + seedNotebookOnboarding(win) + win.localStorage.setItem(key, value) + }, + }) + cy.getEditor().should("be.visible") + // localStorage changes in another tab do not change this tab's headers. + cy.window().its("localStorage").invoke("removeItem", key) + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: "select current_user()" }, + }, + (req) => { + expect(req.headers.authorization).to.eq(authorization) + req.reply({ statusCode: 401, body: "Unauthorized" }) + }, + ).as("unauthorized") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@unauthorized") + cy.getByDataHook("auth-login").should("be.visible") + cy.getEditor().should("not.exist") + }) + }) + } +} diff --git a/e2e/tests/enterprise/oidc.spec.js b/e2e/tests/enterprise/oidc.spec.js index a1aa48aaf..932fc942a 100644 --- a/e2e/tests/enterprise/oidc.spec.js +++ b/e2e/tests/enterprise/oidc.spec.js @@ -61,25 +61,393 @@ describe("OIDC", () => { cy.getEditor().should("not.exist") }) - it("should login via OIDC", () => { + it("refreshes an immediately expired zero-lifetime OIDC token before SQL", () => { interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) interceptTokenRequest({ access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", token_type: "Bearer", + expires_in: 0, + }) + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.wait("@tokens").then(({ request }) => { + expect(new URLSearchParams(request.body).get("grant_type")).to.eq( + "authorization_code", + ) + }) + cy.wait("@tokens").then(({ request }) => { + expect(new URLSearchParams(request.body).get("grant_type")).to.eq( + "refresh_token", + ) + }) + cy.getEditor().should("be.visible") + + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + + cy.logout() + cy.getByDataHook("auth-login").should("be.visible") + }) + + it("accepts an IdP's string expiry with null optional fields", () => { + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + interceptTokenRequest({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: null, + id_token: null, + token_type: "Bearer", + expires_in: "300", + }) + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + }) + + it("uses the SSO token instead of retained REST credentials", () => { + cy.window().then(({ localStorage }) => { + localStorage.setItem("rest.token", "expired-rest-token") + localStorage.setItem("basic.auth.header", "Basic expired") + }) + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + interceptTokenRequest({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", expires_in: 300, }) + + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + cy.window().then(({ localStorage, sessionStorage }) => { + expect(localStorage.getItem("rest.token")).to.eq("expired-rest-token") + expect(localStorage.getItem("basic.auth.header")).to.eq("Basic expired") + expect(sessionStorage.getItem("auth.login.method")).to.eq("sso") + }) + }) + + for (const { name, reply } of [ + { name: "a network error", reply: { forceNetworkError: true } }, + { + name: "a non-JSON 502", + reply: { statusCode: 502, body: "Bad Gateway" }, + }, + ]) { + it(`continues loading the editor when token refresh fails with ${name}`, () => { + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + if ( + new URLSearchParams(req.body).get("grant_type") === "refresh_token" + ) { + req.alias = "refreshFailure" + req.reply(reply) + } else { + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 20, + }) + } + }).as("tokens") + + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.wait("@refreshFailure") + cy.getEditor().should("be.visible") + cy.executeSQL("select current_user();") + cy.getGridRow(0).should("contain", "john doe") + }) + } + + it("keeps the session after a refresh network failure and a later SQL 401, then recovers", () => { + // Freeze Date only: keep the bearer valid during refresh backoff while + // real timers continue driving requests and notification transitions. + cy.clock(Date.now(), ["Date"]) + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + if ( + new URLSearchParams(req.body).get("grant_type") === "refresh_token" + ) { + req.alias = "refreshFailure" + req.reply({ forceNetworkError: true }) + } else { + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 20, + }) + } + }) + cy.getByDataHook("button-sso-login").click() cy.wait("@authorizationCode") + cy.wait("@refreshFailure") + cy.getEditor().should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.client1") + .should("not.be.empty") + cy.intercept( + { + method: "GET", + url: "**/exec?*", + query: { query: "select current_user()" }, + times: 1, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejectedBearerQuery") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@rejectedBearerQuery") + cy.contains("the request was not executed").should("be.visible") cy.getEditor().should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + cy.get("#sso-refresh-failed").should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.session.active") + .should("eq", "true") + + // The EE userinfo fixture recognizes this access token. Renew its + // lifetime and rotate the refresh token, then execute against real EE. + cy.intercept("POST", oidcTokenUrl, { + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "rotated-refresh-token", + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 3600, + }).as("refreshRecovery") + cy.intercept({ + method: "GET", + url: "**/exec?*", + query: { query: "select current_user()" }, + }).as("recoveredQuery") + cy.tick(5000) + cy.runLine() + cy.wait("@refreshRecovery") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", "Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom") // gitleaks:allow + cy.getGridRow(0).should("contain", "john doe") + cy.get("#sso-refresh-failed").should("not.exist") + cy.getByDataHook("auth-login").should("not.exist") + + // The next renewal must consume the refresh token from the recovery + // response, while keeping the bearer recognized by the EE fixture. + cy.intercept("POST", oidcTokenUrl, (req) => { + const body = new URLSearchParams(req.body) + expect(body.get("grant_type")).to.eq("refresh_token") + expect(body.get("refresh_token")).to.eq("rotated-refresh-token") + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "next-refresh-token", + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 3600, + }) + }).as("rotatedRefresh") + cy.tick(3600000) + cy.runLine() + cy.wait("@rotatedRefresh") + cy.wait("@recoveredQuery") + .its("request.headers.authorization") + .should("eq", "Bearer gslpJtzmmi6RwaPSx0dYGD4tEkom") // gitleaks:allow + cy.getGridRow(0).should("contain", "john doe") + cy.getByDataHook("auth-login").should("not.exist") + }) + + it("renews and resends once when the server rejects the bearer of a cookie session", () => { + // No /exec request is stubbed: the EE server answers them all. Its + // userinfo fixture accepts acceptedBearer and rejects rejectedBearer. + const acceptedBearer = "gslpJtzmmi6RwaPSx0dYGD4tEkom" // gitleaks:allow + const rejectedBearer = "access-token-unknown-to-userinfo" // gitleaks:allow + const grants = [] + const sessionStarts = [] + const authChecks = [] + const recordTo = (requests) => (req) => { + const request = { + authorization: req.headers.authorization, + cookie: req.headers.cookie ?? "", + session: req.query.session, + } + requests.push(request) + req.continue((res) => { + request.statusCode = res.statusCode + request.setCookie = String(res.headers["set-cookie"] ?? "") + }) + } + const outcomes = (requests) => + requests.map( + ({ authorization, statusCode }) => + `${authorization} -> ${statusCode}`, + ) + cy.intercept( + { method: "GET", url: `${baseUrl}/exec*`, query: { session: "true" } }, + recordTo(sessionStarts), + ).as("sessionStart") + cy.intercept( + { + method: "GET", + url: `${baseUrl}/exec*`, + query: { query: /^SELECT 1$/ }, + }, + recordTo(authChecks), + ) + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + const body = new URLSearchParams(req.body) + const grant = { + type: body.get("grant_type"), + refreshToken: body.get("refresh_token"), + } + grants.push(grant) + const tokens = (accessToken, refreshToken) => ({ + access_token: accessToken, + refresh_token: refreshToken, + token_type: "Bearer", + expires_in: 3600, + }) + const codeExchanges = grants.filter( + ({ type }) => type === "authorization_code", + ).length + if (grant.type === "refresh_token") { + req.reply(tokens(acceptedBearer, "refresh-token-after-recovery")) + } else if (codeExchanges === 1) { + req.reply(tokens(acceptedBearer, "refresh-token-of-first-sign-in")) + } else { + req.reply(tokens(rejectedBearer, "refresh-token-of-rejected-bearer")) + } + }) + + // Signing in with the accepted bearer opens a qdb_session cookie session. + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.wait("@sessionStart").then(({ response }) => { + expect(response.statusCode).to.eq(200) + expect(String(response.headers["set-cookie"])).to.contain( + "qdb_session=", + ) + }) + cy.getEditor().should("be.visible") + // The silent re-auth on reload needs the username the toolbar stores. + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.client1") + .should("not.be.empty") + // Silent re-auth installs a bearer the server rejects, while the browser + // still sends the cookie of the session opened above. + cy.reload() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.getByDataHook("auth-login").should("not.exist") + + // questdb#7799: the server no longer serves a rejected bearer from the + // cookie. Before it, this session start was answered with 200. + cy.wrap(sessionStarts).should((requests) => { + expect(outcomes(requests)).to.deep.equal([ + `Bearer ${acceptedBearer} -> 200`, + `Bearer ${rejectedBearer} -> 401`, + ]) + expect(requests[1].cookie).to.contain("qdb_session=") + }) + // A 401 executed no SQL: the console renews the token once and resends + // its startup check once, with the renewed bearer. + cy.wrap(authChecks).should((requests) => { + expect(outcomes(requests)).to.deep.equal([ + `Bearer ${acceptedBearer} -> 200`, + `Bearer ${rejectedBearer} -> 401`, + `Bearer ${acceptedBearer} -> 200`, + ]) + }) + // The 401 also deleted the qdb_session cookie, which downloads use. The + // resent check asks for a new session, so exports keep working. + cy.wrap(authChecks).should((requests) => { + expect(requests[2].session).to.eq("true") + expect(requests[2].setCookie).to.contain("qdb_session=") + }) + // Like a download, this request authenticates with the cookie alone. + cy.request({ + url: `${baseUrl}/exp?query=${encodeURIComponent("select 1")}`, + failOnStatusCode: false, + }) + .its("status") + .should("eq", 200) cy.executeSQL("select current_user();") cy.getGridRow(0).should("contain", "john doe") + cy.getByDataHook("auth-login").should("not.exist") + cy.wrap(grants).should("deep.equal", [ + { type: "authorization_code", refreshToken: null }, + { type: "authorization_code", refreshToken: null }, + { + type: "refresh_token", + refreshToken: "refresh-token-of-rejected-bearer", + }, + ]) + }) - cy.logout() + it("shows the expired-session error when refresh is authoritatively rejected", () => { + interceptAuthorizationCodeRequest(`${baseUrl}?code=abcdefgh`) + cy.intercept("POST", oidcTokenUrl, (req) => { + if ( + new URLSearchParams(req.body).get("grant_type") === "refresh_token" + ) { + req.alias = "refreshRejected" + req.reply({ statusCode: 400, body: { error: "invalid_grant" } }) + } else { + req.reply({ + access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", // gitleaks:allow + refresh_token: "FUuAAqMp6LSTKmkUd5uZuodhiE4Kr6M7Eyv", // gitleaks:allow + id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", // gitleaks:allow + token_type: "Bearer", + expires_in: 300, + }) + } + }) + cy.getByDataHook("button-sso-login").click() + cy.wait("@authorizationCode") + cy.getEditor().should("be.visible") + cy.window() + .its("localStorage") + .invoke("getItem", "sso.username.client1") + .should("not.be.empty") + + // The first SQL 401 triggers refresh while the token is locally valid. + // The invalid_grant, not that old bearer's rejection, ends the session. + cy.intercept( + { + method: "GET", + url: "**/exec?*", + query: { query: "select current_user()" }, + }, + { statusCode: 401, body: "Unauthorized" }, + ).as("rejectedBearerQuery") + cy.clearEditor() + cy.typeQuery("select current_user()") + cy.runLine() + cy.wait("@rejectedBearerQuery") + cy.wait("@refreshRejected") cy.getByDataHook("auth-login").should("be.visible") + cy.contains("Your SSO session has expired") + .closest("[data-hook='sign-in-error']") + .should("be.focused") + cy.contains( + "Could not renew your SSO session. Please sign in again.", + ).should("be.visible") + cy.getByDataHook("button-sso-continue").should("be.visible") }) it("should request a new token on page reload, even if there is no refresh token", () => { @@ -88,7 +456,7 @@ describe("OIDC", () => { access_token: "gslpJtzmmi6RwaPSx0dYGD4tEkom", id_token: "eyJhbGciOiJSUzI1NiIsImtpZCI6I", token_type: "Bearer", - expires_in: 0, + expires_in: 300, }) cy.getByDataHook("button-sso-login").click() cy.wait("@authorizationCode") diff --git a/package.json b/package.json index 651435654..a8860a925 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@types/react-grid-layout": "^2.1.0", "@types/react-highlight-words": "^0.16.7", "@types/react-redux": "7.1.9", + "@types/react-test-renderer": "17.0.9", "@types/react-transition-group": "4.4.0", "@types/styled-components": "5.1.7", "@types/throttle-debounce": "2.1.0", @@ -144,6 +145,7 @@ "jiti": "^2.6.1", "lint-staged": "^16.2.6", "prettier": "^3.3.0", + "react-test-renderer": "17.0.2", "rollup-plugin-license": "^3.7.1", "sass": "^1.93.2", "stylelint": "^13.13.1", diff --git a/src/components/ConnectionErrorView.tsx b/src/components/ConnectionErrorView.tsx new file mode 100644 index 000000000..f056c2aaa --- /dev/null +++ b/src/components/ConnectionErrorView.tsx @@ -0,0 +1,77 @@ +import React, { useEffect, useRef } from "react" +import styled from "styled-components" +import { Box, Button, CenteredLayout, Text } from "." +import { CloseOutline, Refresh } from "./icons" + +const Heading = styled.h1` + margin: 0; + font-size: 2rem; + color: ${({ theme }) => theme.color.contentPrimary}; + outline: none; +` + +const ErrorIcon = styled(CloseOutline)` + color: ${({ theme }) => theme.color.statusDanger}; + flex-shrink: 0; +` + +export const ConnectionErrorView = ({ + title = "Could not connect to QuestDB", + error, + status, + pending = false, + onRetry, + retryLabel = "Retry connection", + onSignIn, + preloadedImages, +}: { + title?: string + error: React.ReactNode + status?: string + pending?: boolean + onRetry: () => void + retryLabel?: string + onSignIn?: () => void + preloadedImages?: Record +}) => { + const heading = useRef(null) + useEffect(() => heading.current?.focus(), []) + return ( + + + + {title} + +
+ + +
+ {status && ( +
+ {status} +
+ )} + + {/* Keep focus on Retry while it runs; the handler also guards re-entry. */} + + {onSignIn && } + +
+
+ ) +} diff --git a/src/components/Toast/index.tsx b/src/components/Toast/index.tsx index afe145b7b..9a78d9031 100644 --- a/src/components/Toast/index.tsx +++ b/src/components/Toast/index.tsx @@ -25,6 +25,7 @@ type StyledIconProps = { export type ToastOptions = RTToastOptions export const useNotificationCenter = RTNotificationCenter +export const REFRESH_FAILED_TOAST_ID = "sso-refresh-failed" export const ToastIcon = ({ type, diff --git a/src/components/TopBar/toolbar.tsx b/src/components/TopBar/toolbar.tsx index 5e57efbdc..bbba5612e 100644 --- a/src/components/TopBar/toolbar.tsx +++ b/src/components/TopBar/toolbar.tsx @@ -19,10 +19,10 @@ import { InstanceSettingsPopper } from "./InstanceSettingsPopper" import { pickReadableTextColor, Preferences } from "../../utils" import { PopperHover, Placement } from "../" import { useTheme } from "styled-components" -import { TelemetryTable } from "../../consts" -import { TelemetryConfigShape } from "../../store/Telemetry/types" +import { queryTelemetryConfig } from "../../utils/telemetryQueries" import { sendServerInfoTelemetry } from "../../utils/telemetry" -import { ssoAuthState } from "../../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { isSessionEnded } from "../../modules/OAuth2/session" import { InstanceTypeIcon } from "./InstanceTypeIcon" const EnvIconWrapper = styled.div<{ $background?: string }>` @@ -376,13 +376,14 @@ export const Toolbar = () => { "SELECT current_user", { limit: "0,1", + background: true, }, ) if (response.type === QuestDB.Type.DQL && response.count === 1) { const currentUser = response.data[0].current_user setCurrentUser(currentUser) - const ssoAuthenticated = ssoAuthState.isSSOAuthenticated() + const ssoAuthenticated = authSessionState.isSSOAuthenticated() if (ssoAuthenticated && currentUser && settings["acl.oidc.client.id"]) { // it is an SSO user, we should update the SSO username setSSOUserNameWithClientID( @@ -436,24 +437,34 @@ export const Toolbar = () => { }, [result]) const handleSaveSettings = async (values: Preferences) => { + const session = authSessionState.capture() try { const result = await quest.savePreferences(values) + if (!session.isCurrent()) return if (result.success) { await handleToggle(false) + if (!session.isCurrent()) return toast.success("Instance information updated successfully.") - const response = await quest.query( - `${TelemetryTable.CONFIG} limit -1`, - ) - if (response.type === QuestDB.Type.DQL && response.count === 1) { - const serverInfo = response.data[0] - void sendServerInfoTelemetry(serverInfo) - } + // Telemetry is best-effort and cannot turn an accepted save into a + // failure notification (or renew an otherwise idle SSO session). + void queryTelemetryConfig(quest) + .then((response) => { + if ( + session.isCurrent() && + response.type === QuestDB.Type.DQL && + response.count === 1 + ) { + void sendServerInfoTelemetry(response.data[0]) + } + }) + .catch(() => {}) return } const { preferences: newPreferences } = await refreshSettingsAndPreferences() + if (!session.isCurrent()) return setPreviewValues(newPreferences) if (result.status === 409) { toast.error( @@ -465,9 +476,13 @@ export const Toolbar = () => { throw new Error(result.message) } catch (e) { - toast.error(`Failed to update instance information: ${e}`, { - autoClose: 5000, - }) + if (!session.isCurrent() || isSessionEnded(e)) return + toast.error( + `Failed to update instance information: ${e instanceof Error ? e.message : String(e)}`, + { + autoClose: 5000, + }, + ) } } diff --git a/src/hooks/catalogSource/requestDeadline.test.ts b/src/hooks/catalogSource/requestDeadline.test.ts new file mode 100644 index 000000000..9a4b48967 --- /dev/null +++ b/src/hooks/catalogSource/requestDeadline.test.ts @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it, vi } from "vitest" +import { createCatalogRequestDeadline } from "./requestDeadline" + +afterEach(() => { + vi.useRealTimers() +}) + +describe("catalog request deadline", () => { + it("allows a full SQL timeout after a hung refresh", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("table metadata", onTimeout) + deadline.onRefreshWait(10_000) + + await vi.advanceTimersByTimeAsync(10_000) + expect(onTimeout).not.toHaveBeenCalled() + deadline.onRequestStart() + await vi.advanceTimersByTimeAsync(9_999) + expect(onTimeout).not.toHaveBeenCalled() + + const rejection = expect(deadline.promise).rejects.toThrow( + "table metadata request timed out", + ) + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + }) + + it("allows a refresh after a rejected request before restarting the SQL timeout", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("query activity", onTimeout) + deadline.onRequestStart() + // The server rejects the token after 2s, then the refresh takes 8.5s. + await vi.advanceTimersByTimeAsync(2_000) + deadline.onRefreshWait(10_000) + await vi.advanceTimersByTimeAsync(8_500) + expect(onTimeout).not.toHaveBeenCalled() + + deadline.onRequestStart() + await vi.advanceTimersByTimeAsync(9_999) + expect(onTimeout).not.toHaveBeenCalled() + const rejection = expect(deadline.promise).rejects.toThrow( + "query activity request timed out", + ) + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + }) + + it("keeps the normal ten-second SQL timeout without refresh", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("columns", onTimeout) + deadline.onRequestStart() + await vi.advanceTimersByTimeAsync(9_999) + expect(onTimeout).not.toHaveBeenCalled() + + const rejection = expect(deadline.promise).rejects.toThrow( + "columns request timed out", + ) + await vi.advanceTimersByTimeAsync(1) + await rejection + expect(onTimeout).toHaveBeenCalledTimes(1) + deadline.clear() + }) + + it("clears the pending timeout after a successful request", async () => { + vi.useFakeTimers() + const onTimeout = vi.fn() + const deadline = createCatalogRequestDeadline("columns", onTimeout) + deadline.onRequestStart() + deadline.clear() + + await vi.advanceTimersByTimeAsync(20_000) + expect(onTimeout).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/catalogSource/requestDeadline.ts b/src/hooks/catalogSource/requestDeadline.ts new file mode 100644 index 000000000..364197735 --- /dev/null +++ b/src/hooks/catalogSource/requestDeadline.ts @@ -0,0 +1,34 @@ +import { SOURCE_TIMEOUT_MS } from "./sourceState" + +export const createCatalogRequestDeadline = ( + sourceName: string, + onTimeout: () => void, +) => { + let timeoutId: ReturnType | null = null + let rejectTimeout!: (error: Error) => void + const promise = new Promise((_, reject) => { + rejectTimeout = reject + }) + const schedule = (delay: number) => { + if (timeoutId !== null) clearTimeout(timeoutId) + timeoutId = setTimeout(() => { + onTimeout() + rejectTimeout(new Error(`${sourceName} request timed out`)) + }, delay) + } + + // The refresh is bounded independently; SQL still gets its full timeout + // after onRequestStart, even if refreshing consumed the entire first 10s. + // A query can also wait for a refresh after a 401, between two requests. + const allowRefreshThenRequest = (refreshTimeoutMs: number) => + schedule(refreshTimeoutMs + SOURCE_TIMEOUT_MS) + schedule(SOURCE_TIMEOUT_MS) + return { + promise, + onRefreshWait: allowRefreshThenRequest, + onRequestStart: () => schedule(SOURCE_TIMEOUT_MS), + clear: () => { + if (timeoutId !== null) clearTimeout(timeoutId) + }, + } +} diff --git a/src/hooks/catalogSource/useCatalogSource.test.tsx b/src/hooks/catalogSource/useCatalogSource.test.tsx new file mode 100644 index 000000000..ed2ab9207 --- /dev/null +++ b/src/hooks/catalogSource/useCatalogSource.test.tsx @@ -0,0 +1,256 @@ +import "../../test/stubBrowserGlobals" +import React from "react" +import { act, create, ReactTestRenderer } from "react-test-renderer" +import { afterEach, describe, expect, it, vi } from "vitest" +import { QuestContext } from "../../providers" +import { useCatalogSource } from "./useCatalogSource" +import { MANUAL_RETRY_POLICY } from "./sourceState" +import { Client } from "../../utils/questdb/client" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import type { Options, QueryLifecycle } from "../../utils/questdb" +import type { AuthPayload } from "../../modules/OAuth2/types" + +vi.mock("../../providers", async () => { + const { createContext } = await import("react") + return { QuestContext: createContext({}) } +}) + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + act(() => renderer?.unmount()) + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + authSessionState.endSession() +}) + +const transformResponse = () => [] + +const renderSource = (quest: Client) => { + vi.spyOn(console, "error").mockImplementation(() => {}) + let status: string | undefined + let readyData: unknown + type Props = { sourceKey: string; enabled: boolean } + const Harness = ({ sourceKey, enabled }: Props) => { + const { state, lastReadyData } = useCatalogSource({ + sourceKey, + revalidateKey: 0, + sourceName: "Tables", + enabled, + query: sourceKey === "catalog" ? "tables()" : sourceKey, + pollIntervalMs: null, + retryPolicy: MANUAL_RETRY_POLICY, + transformResponse, + }) + status = state.status + readyData = lastReadyData + return null + } + const value = { + quest, + questExecution: {} as React.ContextType< + typeof QuestContext + >["questExecution"], + buildVersion: { type: "oss" as const, version: "" }, + commitHash: "", + } + const tree = (props: Props) => ( + + + + ) + act(() => { + renderer = create(tree({ sourceKey: "catalog", enabled: true })) + }) + return { + status: () => status, + data: () => readyData, + update: (props: Props) => act(() => renderer?.update(tree(props))), + } +} + +const mount = (startImmediately: boolean) => { + vi.useFakeTimers() + const abort = vi.fn() + const queryRaw = vi.fn( + (_query: string, _options: Options, lifecycle: QueryLifecycle) => { + if (startImmediately) lifecycle.onRequestStart?.() + else lifecycle.onRefreshWait?.(10_000) + return { queryId: 1, promise: new Promise(() => {}) } + }, + ) + const { status } = renderSource({ queryRaw, abort } as unknown as Client) + return { abort, queryRaw, status } +} + +const advance = async (ms: number) => { + await act(async () => { + await vi.advanceTimersByTimeAsync(ms) + }) +} + +describe("useCatalogSource deadline integration", () => { + it("wires onRequestStart so SQL gets 10 seconds, not the combined 20", async () => { + const hook = mount(true) + expect(hook.queryRaw.mock.calls[0][2].onRequestStart).toBeTypeOf("function") + await advance(9_999) + expect(hook.status()).toBe("loading") + expect(hook.abort).not.toHaveBeenCalled() + await advance(1) + expect(hook.status()).toBe("unavailable") + expect(hook.abort).toHaveBeenCalledWith(1) + }) + + it("gives SQL its full budget after waiting for refresh", async () => { + const hook = mount(false) + await advance(9_000) + act(() => hook.queryRaw.mock.calls[0][2].onRequestStart?.()) + await advance(9_999) + expect(hook.status()).toBe("loading") + await advance(1) + expect(hook.status()).toBe("unavailable") + expect(hook.abort).toHaveBeenCalledWith(1) + }) + + it("still times out if no request-start callback ever arrives", async () => { + const hook = mount(false) + await advance(20_000) + expect(hook.status()).toBe("unavailable") + expect(hook.abort).toHaveBeenCalledWith(1) + }) + + it("waits for the refresh after a 401, then shows the retried result", async () => { + vi.useFakeTimers() + const later = (ms: number, value: T) => + new Promise((resolve) => setTimeout(() => resolve(value), ms)) + // The browser thinks the token is valid, but the server has expired it. + const issue = (access_token: string) => ({ + access_token, + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + 300_000).toISOString(), + }) + authSessionState.setAuthPayload(issue("stale")) + const fetchMock = vi + .fn() + .mockImplementationOnce(() => + later(2_000, { + ok: false, + status: 401, + statusText: "Unauthorized", + } as Response), + ) + .mockResolvedValue({ + ok: true, + status: 200, + text: () => + Promise.resolve( + JSON.stringify({ columns: [], count: 0, dataset: [] }), + ), + } as Response) + vi.stubGlobal("fetch", fetchMock) + const client = new Client() + client.setCommonHeaders({ Authorization: "Bearer stale" }) + const refresh = vi.fn(() => + later(8_500, issue("fresh")).then((payload) => { + authSessionState.setAuthPayload(payload) + return payload + }), + ) + client.refreshTokenMethod = refresh + const { status } = renderSource(client) + + // The refresh ends 10.5s after the first request started: past that + // request's 10s timeout, but within the refresh's own 10s bound. + await advance(10_000) + expect(status()).toBe("loading") + await advance(500) + expect(refresh).toHaveBeenCalledTimes(1) + expect(fetchMock).toHaveBeenCalledTimes(2) + expect(fetchMock).toHaveBeenLastCalledWith( + expect.any(String), + expect.objectContaining({ headers: { Authorization: "Bearer fresh" } }), + ) + expect(status()).toBe("ready") + }) + + it.each(["retarget", "disable", "unmount"])( + "cleans an abandoned source during shared refresh on %s", + async (change) => { + vi.useFakeTimers() + const client = new Client() + const abort = vi.spyOn(client, "abort") + authSessionState.setAuthPayload({ + access_token: "old", + refresh_token: "refresh", + token_type: "Bearer", + expires_at: new Date(Date.now() + 20_000).toISOString(), + }) + let finishRefresh!: () => void + const refresh = vi.fn( + () => + new Promise((resolve) => { + finishRefresh = () => { + const payload = { + access_token: "fresh", + token_type: "Bearer", + refresh_token: "rotated", + expires_at: new Date(Date.now() + 300_000).toISOString(), + } + authSessionState.setAuthPayload(payload) + resolve(payload) + } + }), + ) + client.refreshTokenMethod = refresh + const fetchMock = vi + .fn() + .mockImplementation(() => + Promise.resolve( + new Response( + JSON.stringify({ columns: [], count: 0, dataset: [] }), + ), + ), + ) + vi.stubGlobal("fetch", fetchMock) + const source = renderSource(client) + await advance(1_000) + // Another source must still be able to use the same refresh. + const current = client.queryRaw("current-source") + if (change === "unmount") { + act(() => renderer?.unmount()) + } else { + source.update({ + sourceKey: "new-source", + enabled: change === "retarget", + }) + } + await advance(0) + expect(abort).toHaveBeenCalledWith(1) + expect(fetchMock).not.toHaveBeenCalled() + expect(source.data()).toBeNull() + finishRefresh() + await advance(0) + await current + + expect(refresh).toHaveBeenCalledTimes(1) + const queries = fetchMock.mock.calls.map(([url]) => + new URL(url as string, "http://localhost").searchParams.get("query"), + ) + expect(queries).not.toContain("tables()") + expect(queries).toEqual( + change === "retarget" + ? ["current-source", "new-source"] + : ["current-source"], + ) + if (change === "retarget") { + expect(source.status()).toBe("ready") + expect(source.data()).toEqual([]) + } + expect(vi.getTimerCount()).toBe(0) + await advance(20_000) + expect(abort).toHaveBeenCalledTimes(1) + }, + ) +}) diff --git a/src/hooks/catalogSource/useCatalogSource.ts b/src/hooks/catalogSource/useCatalogSource.ts index 6bc4b1628..d44824952 100644 --- a/src/hooks/catalogSource/useCatalogSource.ts +++ b/src/hooks/catalogSource/useCatalogSource.ts @@ -4,11 +4,12 @@ import * as QuestDB from "../../utils/questdb" import { createSourceMachineState, nextSourceState, - SOURCE_TIMEOUT_MS, type SourceMachineState, type SourceRetryPolicy, } from "./sourceState" import type { SourceFetchOutcome, SourceState } from "./types" +import { createCatalogRequestDeadline } from "./requestDeadline" +import { isCancelledByUser } from "../../utils/questdb/cancellation" type Params = { sourceKey: string @@ -28,12 +29,6 @@ type CatalogSource = { poll: () => Promise } -const isCancelledRequest = (error: unknown): boolean => - typeof error === "object" && - error !== null && - "error" in error && - error.error === "Cancelled by user" - export const useCatalogSource = ({ sourceKey, revalidateKey, @@ -62,25 +57,27 @@ export const useCatalogSource = ({ const runRequest = useCallback(async (): Promise => { const requestKey = sourceKey let queryId: QuestDB.QueryId | null = null - let timeoutId: number | null = null let timedOut = false + const deadline = createCatalogRequestDeadline(sourceName, () => { + timedOut = true + if (queryId !== null && activeQueryIdRef.current === queryId) { + quest.abort(queryId) + } + }) try { - const request = quest.queryRaw(query, { cancellable: true }) + const request = quest.queryRaw( + query, + { cancellable: true, background: true }, + { + onRefreshWait: deadline.onRefreshWait, + onRequestStart: deadline.onRequestStart, + }, + ) queryId = request.queryId activeQueryIdRef.current = request.queryId - const timeout = new Promise((_, reject) => { - timeoutId = window.setTimeout(() => { - timedOut = true - if (activeQueryIdRef.current === request.queryId) { - quest.abort(request.queryId) - } - reject(new Error(`${sourceName} request timed out`)) - }, SOURCE_TIMEOUT_MS) - }) - - const response = await Promise.race([request.promise, timeout]) + const response = await Promise.race([request.promise, deadline.promise]) if ( currentKeyRef.current !== requestKey || activeQueryIdRef.current !== queryId @@ -106,7 +103,7 @@ export const useCatalogSource = ({ ) { return "skipped" } - if (isCancelledRequest(error) && !timedOut) return "skipped" + if (isCancelledByUser(error) && !timedOut) return "skipped" setMachine((previous) => nextSourceState( @@ -120,9 +117,7 @@ export const useCatalogSource = ({ console.error(`Failed to fetch ${sourceName}:`, error) return "failure" } finally { - if (timeoutId !== null) { - window.clearTimeout(timeoutId) - } + deadline.clear() if (activeQueryIdRef.current === queryId) { activeQueryIdRef.current = null } diff --git a/src/hooks/useEndedSessionWriteCount.ts b/src/hooks/useEndedSessionWriteCount.ts new file mode 100644 index 000000000..e1d6b9178 --- /dev/null +++ b/src/hooks/useEndedSessionWriteCount.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react" +import { questClient } from "../utils/questdb/queryClient" + +// Uploads and settings saves of ended sessions that are still being sent. They +// keep running until they settle, but leaving the page cancels them. +export const useEndedSessionWriteCount = () => { + const [count, setCount] = useState(() => questClient.endedSessionWriteCount()) + useEffect(() => { + // A write may have settled between the first render and subscribing. + setCount(questClient.endedSessionWriteCount()) + return questClient.onEndedSessionWritesChange(setCount) + }, []) + return count +} diff --git a/src/hooks/useQueryExecution.ts b/src/hooks/useQueryExecution.ts index 82bac71ef..daf46be44 100644 --- a/src/hooks/useQueryExecution.ts +++ b/src/hooks/useQueryExecution.ts @@ -21,8 +21,9 @@ export const useQueryExecution = (globals?: NotebookVariable[]) => { sql: string, signal?: AbortSignal, limit: number = RESULT_DISPLAY_LIMIT, + options?: { background?: boolean }, ): Promise => - executeSingleRaw(quest, sql, globals, signal, limit), + executeSingleRaw(quest, sql, globals, signal, limit, options), [quest, globals], ) diff --git a/src/index.tsx b/src/index.tsx index c8a85ff0d..c291bec60 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -50,6 +50,7 @@ import { } from "./components" import { ScreenSizeProvider } from "./hooks" import { rootEpic, rootReducer } from "./store" +import { clearLoginDataOnSessionEnd } from "./store/clearLoginDataOnSessionEnd" import { StoreAction, StoreShape } from "./types" import Layout from "./scenes/Layout" @@ -72,6 +73,8 @@ const store = createStore(rootReducer, compose(applyMiddleware(epicMiddleware))) epicMiddleware.run(rootEpic) +clearLoginDataOnSessionEnd(store.dispatch) + const FadeReg = createGlobalFadeTransition("fade-reg", TransitionDuration.REG) const FadeSlow = createGlobalFadeTransition( @@ -90,8 +93,8 @@ ReactDOM.render( + {ReactDOM.createPortal(, document.body)} - {ReactDOM.createPortal(, document.body)} diff --git a/src/js/console/quick-vis.ts b/src/js/console/quick-vis.ts index d9a84b094..7b70d580d 100644 --- a/src/js/console/quick-vis.ts +++ b/src/js/console/quick-vis.ts @@ -309,6 +309,7 @@ export function quickVis( cols: urlColumns, src: "vis", cancellable: true, + background: true, }) activeQueryId = queryId try { diff --git a/src/modules/EventBus/types.ts b/src/modules/EventBus/types.ts index dfd6d25a5..6e36b6014 100644 --- a/src/modules/EventBus/types.ts +++ b/src/modules/EventBus/types.ts @@ -15,6 +15,7 @@ export enum EventType { MSG_CONNECTION_ERROR = "query.connection.error", MSG_CONNECTION_UNAUTHORIZED = "query.connection.unauthorized", MSG_CONNECTION_FORBIDDEN = "query.connection.forbidden", + MSG_AUTH_REFRESH_FAILED = "auth.refresh.failed", REACT_READY = "react.ready", TAB_FOCUS = "tab.focus", TAB_BLUR = "tab.blur", diff --git a/src/modules/OAuth2/authSessionState.ts b/src/modules/OAuth2/authSessionState.ts new file mode 100644 index 000000000..a2919fad3 --- /dev/null +++ b/src/modules/OAuth2/authSessionState.ts @@ -0,0 +1,88 @@ +import { AuthPayload, LogoutOptions } from "./types" + +export type AuthSession = { isCurrent: () => boolean } + +// Tab-local authentication for ALL login methods. The SSO payload is only +// present for OIDC; stored REST/basic credentials do not make another tab +// authenticated. Keep these predicates distinct when handling a 401. +class AuthSessionState { + private authPayload: AuthPayload | null = null + private refreshFailed = false + private authenticated = false + private sessionId = 0 + private beforeEndListeners = new Set<() => void>() + private endListeners = new Set<(options?: LogoutOptions) => void>() + + capture(): AuthSession { + const sessionId = this.sessionId + return { isCurrent: () => this.sessionId === sessionId } + } + + // Runs before onEnd listeners abort this session's requests. Supersede + // in-flight work here so its aborted results are not recorded, or flush + // already-accepted data. Never start new network work. + beforeEnd(listener: () => void) { + this.beforeEndListeners.add(listener) + return () => { + this.beforeEndListeners.delete(listener) + } + } + + onEnd(listener: (options?: LogoutOptions) => void) { + this.endListeners.add(listener) + return () => { + this.endListeners.delete(listener) + } + } + + getSessionId() { + return this.sessionId + } + + // This state belongs to the tab, unlike credentials in localStorage. + markNonSSOAuthenticated() { + if (!this.authenticated) this.sessionId++ + this.authenticated = true + } + + // Any successful login in this tab (SSO, password or HTTP basic). + isAuthenticated(): boolean { + return this.authenticated + } + + setAuthPayload(authPayload: AuthPayload) { + if (!this.authPayload) this.sessionId++ + this.authPayload = authPayload + this.authenticated = true + this.refreshFailed = false + } + + markRefreshFailed() { + this.refreshFailed = true + } + + hasRefreshFailed(): boolean { + return this.refreshFailed + } + + getAuthPayload(): AuthPayload | null { + return this.authPayload + } + + // An installed OIDC session, eligible for token refresh. + isSSOAuthenticated(): boolean { + return !!this.authPayload + } + + // Ends the tab's session for every login method, not just OIDC. + endSession(options?: LogoutOptions) { + this.beforeEndListeners.forEach((listener) => listener()) + this.sessionId++ + this.authPayload = null + this.authenticated = false + this.refreshFailed = false + this.endListeners.forEach((listener) => listener(options)) + } +} + +export const authSessionState = new AuthSessionState() diff --git a/src/modules/OAuth2/handleUnauthorized.test.ts b/src/modules/OAuth2/handleUnauthorized.test.ts new file mode 100644 index 000000000..23efc74eb --- /dev/null +++ b/src/modules/OAuth2/handleUnauthorized.test.ts @@ -0,0 +1,80 @@ +import "../../test/stubBrowserGlobals" +import { afterEach, describe, expect, it, vi } from "vitest" +import { StoreKey } from "../../utils/localStorage/types" +import { authSessionState } from "./authSessionState" +import { handleSSOUnauthorized } from "./handleUnauthorized" +import type { AuthPayload } from "./types" + +afterEach(() => { + localStorage.clear() + authSessionState.endSession() +}) + +const login = () => { + authSessionState.setAuthPayload({ access_token: "token" } as AuthPayload) + localStorage.setItem(StoreKey.SSO_SESSION_ACTIVE, "true") +} + +describe("SSO unauthorized responses", () => { + it("ignores a burst of 401s after logout and preserves the refresh error", () => { + login() + authSessionState.markRefreshFailed() + const logout = vi.fn(() => authSessionState.endSession()) + + for (let i = 0; i < 6; i++) handleSSOUnauthorized(logout) + + expect(logout).toHaveBeenCalledTimes(1) + expect(logout).toHaveBeenCalledWith({ + errorTitle: "Your SSO session has expired", + errorMessage: "Could not renew your SSO session. Please sign in again.", + }) + expect(localStorage.getItem(StoreKey.OAUTH_REDIRECT_COUNT)).toBe("1") + expect(localStorage.getItem(StoreKey.SSO_SESSION_ACTIVE)).toBe("true") + }) + + it.each([StoreKey.REST_TOKEN, StoreKey.BASIC_AUTH_HEADER])( + "handles the current tab's 401 even after another tab removes %s", + (key) => { + localStorage.setItem(key, "credential") + authSessionState.markNonSSOAuthenticated() + localStorage.removeItem(key) + const logout = vi.fn(() => authSessionState.endSession()) + handleSSOUnauthorized(logout) + handleSSOUnauthorized(logout) + expect(logout).toHaveBeenCalledTimes(1) + expect(logout).toHaveBeenCalledWith({}) + }, + ) + + it("does not treat another tab's stored credentials as a login in this tab", () => { + localStorage.setItem(StoreKey.REST_TOKEN, "another-tab") + const logout = vi.fn() + handleSSOUnauthorized(logout) + expect(logout).not.toHaveBeenCalled() + }) + + it("handles a new password login after the previous session logged out", () => { + const logout = vi.fn(() => authSessionState.endSession()) + for (let i = 0; i < 2; i++) { + authSessionState.markNonSSOAuthenticated() + handleSSOUnauthorized(logout) + } + expect(logout).toHaveBeenCalledTimes(2) + }) + + it("still breaks a loop of six distinct failed logins", () => { + const logout = vi.fn(() => authSessionState.endSession()) + + for (let i = 0; i < 6; i++) { + login() + handleSSOUnauthorized(logout) + } + + expect(logout).toHaveBeenCalledTimes(6) + expect(logout).toHaveBeenLastCalledWith({ + promptForLogin: true, + clearSSOSession: true, + }) + expect(localStorage.getItem(StoreKey.OAUTH_REDIRECT_COUNT)).toBeNull() + }) +}) diff --git a/src/modules/OAuth2/handleUnauthorized.ts b/src/modules/OAuth2/handleUnauthorized.ts new file mode 100644 index 000000000..673d69a34 --- /dev/null +++ b/src/modules/OAuth2/handleUnauthorized.ts @@ -0,0 +1,32 @@ +import { getValue, removeValue, setValue } from "../../utils/localStorage" +import { StoreKey } from "../../utils/localStorage/types" +import { authSessionState } from "./authSessionState" + +import type { LogoutOptions } from "./types" +import { sessionRenewalError } from "./utils" + +type Logout = (options?: LogoutOptions) => void + +// Handles both OIDC and password sessions on an OIDC-enabled server. Only +// OIDC refresh failures receive the expired-SSO message. +export const handleSSOUnauthorized = (logout: Logout) => { + // A failed refresh can release many queries with the same expired token. + // Once the first 401 logs out, their remaining responses are not new + // redirects and must not clear the saved SSO session or login error. + if (!authSessionState.isAuthenticated()) { + return + } + + const logoutOptions = authSessionState.hasRefreshFailed() + ? sessionRenewalError() + : {} + const count = parseInt(getValue(StoreKey.OAUTH_REDIRECT_COUNT), 10) + if (!isNaN(count) && count >= 5) { + // Distinct failed sign-ins still trip the redirect-loop guard. + removeValue(StoreKey.OAUTH_REDIRECT_COUNT) + logout({ ...logoutOptions, promptForLogin: true, clearSSOSession: true }) + } else { + setValue(StoreKey.OAUTH_REDIRECT_COUNT, JSON.stringify((count || 0) + 1)) + logout(logoutOptions) + } +} diff --git a/src/modules/OAuth2/session.test.ts b/src/modules/OAuth2/session.test.ts new file mode 100644 index 000000000..0d392f3ad --- /dev/null +++ b/src/modules/OAuth2/session.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "vitest" +import { + AuthRefreshUnavailableError, + isAuthFailure, + SESSION_ENDED_MESSAGE, + SessionEndedError, + sessionEndedQueryMessage, +} from "./session" + +// The client's rejection for a query that the end of the session aborted. +const sessionEnded = { + position: -1, + query: "insert into trades values (1)", + type: "error", + error: SESSION_ENDED_MESSAGE, + sessionEnded: true, +} + +describe("sessionEndedQueryMessage", () => { + // Read after signing in again, so it must not ask the user to sign in. + it("keeps a query that was sent unverified", () => { + const message = sessionEndedQueryMessage(sessionEnded) + expect(message).toBe( + "Interrupted when your previous session ended. The query may have executed; verify its outcome before running it again.", + ) + expect(message).not.toMatch(/sign in/i) + }) + + // Also a query whose own 401 or 403 ended the session: it was sent, so the + // message must not say otherwise. + it("reports a query that was not executed as not executed", () => { + const message = sessionEndedQueryMessage({ + ...sessionEnded, + requestNotSent: true, + }) + expect(message).toBe( + "Not executed: your previous session ended without running this query.", + ) + expect(message).not.toMatch(/sign in|sent/i) + }) +}) + +describe("AuthRefreshUnavailableError", () => { + // Also thrown after the server rejected a sent request with 401. + it("says that nothing was executed, not that nothing was sent", () => { + expect(new AuthRefreshUnavailableError().message).toBe( + "Could not renew your SSO session; the request was not executed. Please retry or sign in again.", + ) + }) +}) + +describe("isAuthFailure", () => { + // The client rejects with plain objects; the classes are thrown internally. + it.each([ + ["a session end", new SessionEndedError()], + ["a session-ended result", sessionEnded], + ["a failed renewal", new AuthRefreshUnavailableError()], + [ + "a failed renewal result", + { + position: -1, + query: "select 1", + type: "error", + error: new AuthRefreshUnavailableError().message, + authRefreshUnavailable: true, + requestNotSent: true, + }, + ], + ])("recognizes %s", (_, error) => { + expect(isAuthFailure(error)).toBe(true) + }) + + it.each([ + ["a SQL error", { position: 7, type: "error", error: "unexpected token" }], + [ + "a network error", + { + position: -1, + type: "error", + error: "An error occurred, please try again", + connectionError: true, + }, + ], + ["an unauthorized response", { status: 401, error: "Unauthorized" }], + ["a cancelled query", { position: -1, error: "Cancelled by user" }], + ["no error", undefined], + ])("does not treat %s as one", (_, error) => { + expect(isAuthFailure(error)).toBe(false) + }) +}) diff --git a/src/modules/OAuth2/session.ts b/src/modules/OAuth2/session.ts new file mode 100644 index 000000000..ef963cd7e --- /dev/null +++ b/src/modules/OAuth2/session.ts @@ -0,0 +1,70 @@ +import type { AuthSession } from "./authSessionState" +import type { SessionFailure } from "../../utils/questdb/types" + +export type { SessionFailure } from "../../utils/questdb/types" + +export const endedFor = (session: AuthSession, error: unknown): boolean => + !session.isCurrent() || isSessionEnded(error) + +export const sessionFailureOf = (error: unknown): SessionFailure => ({ + ...(isSessionEnded(error) ? { sessionEnded: true } : {}), + ...(typeof error === "object" && + error !== null && + "requestNotSent" in error && + error.requestNotSent === true + ? { requestNotSent: true } + : {}), +}) + +export const SESSION_ENDED_MESSAGE = + "Your session has ended. Please sign in again." + +// For a query that ending the session interrupted. Its notification outlives +// the session and is read after signing in again, so unlike +// SESSION_ENDED_MESSAGE it must not ask the user to sign in. A query whose +// own 401 or 403 ended the session was sent, but not executed: say only that. +export const sessionEndedQueryMessage = (error: unknown): string => + sessionFailureOf(error).requestNotSent + ? "Not executed: your previous session ended without running this query." + : "Interrupted when your previous session ended. The query may have executed; verify its outcome before running it again." + +export class SessionEndedError extends Error { + constructor() { + super(SESSION_ENDED_MESSAGE) + this.name = "SessionEndedError" + } +} + +export const isSessionEnded = (error: unknown): boolean => + error instanceof SessionEndedError || + (typeof error === "object" && + error !== null && + "sessionEnded" in error && + error.sessionEnded === true) + +export const isAuthRefreshUnavailable = (error: unknown): boolean => + error instanceof AuthRefreshUnavailableError || + (typeof error === "object" && + error !== null && + "authRefreshUnavailable" in error && + error.authRefreshUnavailable === true) + +// The query failed for an authentication reason: its session ended around +// it, or its SSO session could not be renewed. That says nothing about the +// SQL, and has no position in it to mark. +export const isAuthFailure = (error: unknown): boolean => + isSessionEnded(error) || isAuthRefreshUnavailable(error) + +// Also thrown after the server rejected a sent request with 401, so the +// message says that nothing was executed, not that nothing was sent. +export class AuthRefreshUnavailableError extends Error { + readonly requestNotSent = true + readonly authRefreshUnavailable = true + + constructor() { + super( + "Could not renew your SSO session; the request was not executed. Please retry or sign in again.", + ) + this.name = "AuthRefreshUnavailableError" + } +} diff --git a/src/modules/OAuth2/ssoAuthState.ts b/src/modules/OAuth2/ssoAuthState.ts deleted file mode 100644 index 7ad416b6d..000000000 --- a/src/modules/OAuth2/ssoAuthState.ts +++ /dev/null @@ -1,23 +0,0 @@ -import { AuthPayload } from "./types" - -class SsoAuthState { - private authPayload: AuthPayload | null = null - - setAuthPayload(authPayload: AuthPayload) { - this.authPayload = authPayload - } - - getAuthPayload(): AuthPayload | null { - return this.authPayload - } - - isSSOAuthenticated(): boolean { - return !!this.authPayload - } - - clearAuthPayload() { - this.authPayload = null - } -} - -export const ssoAuthState = new SsoAuthState() diff --git a/src/modules/OAuth2/types.ts b/src/modules/OAuth2/types.ts index 5453ec898..2f0c3f0b7 100644 --- a/src/modules/OAuth2/types.ts +++ b/src/modules/OAuth2/types.ts @@ -1,9 +1,34 @@ export type AuthPayload = { access_token: string - id_token: string - refresh_token: string + id_token?: string + refresh_token?: string token_type: string - expires_in: number - expires_at: string + // The access token's lifetime. OAuth only recommends it: an ID token bearer + // expires at its exp claim instead. + expires_in?: number + // Absent when the bearer's expiry is unknown. Such a token is never + // refreshed: it is used until the server rejects it, then the user signs in. + expires_at?: string groups_encoded_in_token?: boolean } + +// Normalised fields from the token endpoint, not an installed client session. +// Unknown wire fields (including provider-supplied expires_at) are discarded. +export type TokenResponse = { + access_token?: string + id_token?: string + refresh_token?: string + token_type?: string + expires_in?: number + error?: string + error_description?: string +} + +export type SignInError = { errorTitle: string; errorMessage: string } + +export type LogoutOptions = Partial & { + promptForLogin?: boolean + isDisconnection?: boolean + reload?: boolean + clearSSOSession?: boolean +} diff --git a/src/modules/OAuth2/utils.test.ts b/src/modules/OAuth2/utils.test.ts new file mode 100644 index 000000000..820920a03 --- /dev/null +++ b/src/modules/OAuth2/utils.test.ts @@ -0,0 +1,612 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Base64 } from "js-base64" +import type { Settings } from "../../providers/SettingsProvider/types" +import { + TOKEN_REQUEST_TIMEOUT_MS, + exchangeAuthorizationCode, + getAuthToken, + getMissingIdTokenError, + getBearerAuthorization, + getTokenExpirationDate, + readAuthTokenResponse, + buildInstalledPayload, + getStoredCredentialAuthorization, + sessionRenewalError, +} from "./utils" +import "../../test/stubBrowserGlobals" +import { StoreKey } from "../../utils/localStorage/types" +import type { AuthPayload } from "./types" + +afterEach(() => { + vi.unstubAllGlobals() + vi.useRealTimers() + vi.restoreAllMocks() + localStorage.clear() +}) + +describe("installed payload normalization", () => { + const previous = { + access_token: "old", + refresh_token: "r1", + token_type: "Bearer", + } + it("retains only a refresh grant's omitted refresh token", () => { + expect( + buildInstalledPayload( + { access_token: "new" }, + false, + "refresh_token", + previous, + ), + ).toMatchObject({ + payload: { + access_token: "new", + refresh_token: "r1", + expires_at: undefined, + }, + }) + expect( + buildInstalledPayload( + { access_token: "new" }, + false, + "authorization_code", + previous, + ), + ).toMatchObject({ payload: { refresh_token: undefined } }) + }) + it("installs a rotated refresh token", () => { + expect( + buildInstalledPayload( + { access_token: "new", refresh_token: "r2" }, + false, + "refresh_token", + previous, + ), + ).toMatchObject({ payload: { refresh_token: "r2" } }) + }) + it("uses consistent actionable wording for a rejected refresh grant", () => { + expect( + buildInstalledPayload( + { error: "invalid_grant", error_description: "Revoked." }, + false, + "refresh_token", + previous, + ), + ).toEqual({ + error: { + errorTitle: "Your SSO session has expired", + errorMessage: "Revoked. Please sign in again.", + }, + }) + }) + it.each([ + ["Token is not active", "Token is not active. Please sign in again."], + ["Session not active ", "Session not active. Please sign in again."], + ["Revoked!", "Revoked! Please sign in again."], + [undefined, "Could not renew your SSO session. Please sign in again."], + [" ", "Could not renew your SSO session. Please sign in again."], + ])( + "ends the renewal reason %j as a sentence before the instruction", + (description, errorMessage) => { + expect(sessionRenewalError(description)).toEqual({ + errorTitle: "Your SSO session has expired", + errorMessage, + }) + }, + ) +}) + +describe("token expiration", () => { + const now = Date.UTC(2026, 0, 1) + const nowSeconds = now / 1000 + + // Unsigned, as only the claims are read: the server verifies the signature. + const jwt = (claims: Record) => + [{ alg: "RS256", typ: "JWT" }, claims] + .map((part) => Base64.encodeURI(JSON.stringify(part))) + .concat("signature") + .join(".") + + const expiration = (tokenResponse: { + groups_encoded_in_token: boolean + expires_in?: number + id_token?: string + }) => getTokenExpirationDate(tokenResponse as AuthPayload)?.getTime() + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(now) + }) + + it("uses expires_in when the access token is the bearer", () => { + expect( + expiration({ + groups_encoded_in_token: false, + expires_in: 300, + id_token: jwt({ iat: nowSeconds, exp: nowSeconds + 3600 }), + }), + ).toBe(now + 300_000) + }) + + it.each([ + { relation: "expires before", expiresIn: 3600, idTokenLifetime: 300 }, + { relation: "outlives", expiresIn: 300, idTokenLifetime: 3600 }, + ])( + "uses the exp claim when the ID token bearer $relation the access token", + ({ expiresIn, idTokenLifetime }) => { + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: expiresIn, + id_token: jwt({ + iat: nowSeconds, + exp: nowSeconds + idTokenLifetime, + }), + }), + ).toBe(now + idTokenLifetime * 1000) + }, + ) + + it("does not rebase exp onto a backdated iat", () => { + // Entra ID backdates iat by 5 minutes; the token still expires at exp. + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: 7200, + id_token: jwt({ iat: nowSeconds - 300, exp: nowSeconds + 3600 }), + }), + ).toBe(now + 3_600_000) + }) + + it("decodes a base64url payload without padding", () => { + const idToken = jwt({ + iat: nowSeconds, + exp: nowSeconds + 300, + name: "Zoë ???>>>", + }) + const payload = idToken.split(".")[1] + // Guard the fixture: it must differ from standard, padded base64. + expect(payload).toMatch(/[-_]/) + expect(payload.length % 4).not.toBe(0) + expect(payload).not.toContain("=") + + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: 3600, + id_token: idToken, + }), + ).toBe(now + 300_000) + }) + + it.each([ + { problem: "is missing", idToken: undefined }, + { problem: "is not a JWT", idToken: "opaque-token" }, + { problem: "has an empty payload", idToken: "header..signature" }, + { problem: "has an undecodable payload", idToken: "header.%%%.signature" }, + { + problem: "has a payload that is not JSON", + idToken: `header.${Base64.encodeURI("not json")}.signature`, + }, + { + problem: "has a null payload", + idToken: `header.${Base64.encodeURI("null")}.signature`, + }, + { problem: "has no exp claim", idToken: jwt({ iat: nowSeconds }) }, + { + problem: "has a non-numeric exp claim", + idToken: jwt({ iat: nowSeconds, exp: String(nowSeconds + 3600) }), + }, + ])("falls back to expires_in when the ID token $problem", ({ idToken }) => { + expect( + expiration({ + groups_encoded_in_token: true, + expires_in: 300, + id_token: idToken, + }), + ).toBe(now + 300_000) + }) + + it("does not need expires_in for an ID token bearer with an exp claim", () => { + // OAuth recommends, but does not require, the access token's lifetime. + expect( + expiration({ + groups_encoded_in_token: true, + id_token: jwt({ iat: nowSeconds, exp: nowSeconds + 600 }), + }), + ).toBe(now + 600_000) + }) + + it.each([false, true])( + "expires immediately for zero lifetime (ID token bearer: %s)", + (groupsEncodedInToken) => { + expect( + expiration({ + groups_encoded_in_token: groupsEncodedInToken, + expires_in: 0, + id_token: "opaque-token", + }), + ).toBe(now) + }, + ) + + it.each([ + { + bearer: "an access token without expires_in", + tokenResponse: { + groups_encoded_in_token: false, + id_token: jwt({ iat: nowSeconds, exp: nowSeconds + 600 }), + }, + }, + { + bearer: "an ID token without an exp claim or expires_in", + tokenResponse: { + groups_encoded_in_token: true, + id_token: jwt({ iat: nowSeconds }), + }, + }, + { + bearer: "an access token with a negative expires_in", + tokenResponse: { groups_encoded_in_token: false, expires_in: -1 }, + }, + ])("has no expiration for $bearer", ({ tokenResponse }) => { + expect(expiration(tokenResponse)).toBeUndefined() + }) +}) + +describe("missing ID token", () => { + it.each([ + { + grantType: "authorization_code", + detail: "needed to sign you in.", + }, + { + grantType: "refresh_token", + detail: "needed to keep you signed in.", + }, + ] as const)( + "explains a missing ID token bearer after the $grantType grant", + ({ grantType, detail }) => { + const error = getMissingIdTokenError( + { groups_encoded_in_token: true } as AuthPayload, + grantType, + ) + expect(error?.errorTitle).toBe("Missing ID token") + expect(error?.errorMessage).toContain( + "ask your administrator to check the SSO configuration", + ) + expect(error?.errorMessage).toContain(detail) + expect(error?.errorMessage).not.toContain("acl.oidc") + }, + ) + + it.each([ + { + bearer: "the access token", + tokenResponse: { groups_encoded_in_token: false }, + }, + { + bearer: "a present ID token", + tokenResponse: { groups_encoded_in_token: true, id_token: "id-token" }, + }, + ])("reports nothing when $bearer is the bearer", ({ tokenResponse }) => { + expect( + getMissingIdTokenError(tokenResponse as AuthPayload, "refresh_token"), + ).toBeUndefined() + }) +}) + +describe("normalised token responses", () => { + it.each(["3599", 3599, "0", 0])("accepts lifetime %j", async (expires_in) => { + const result = await readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "access", + expires_in, + }), + ), + ) + expect(result).toEqual({ + access_token: "access", + expires_in: Number(expires_in), + }) + }) + + it.each([-1, 1e20, null, "unknown", "", {}, "Infinity"])( + "treats unusable optional lifetime %j as unknown", + async (expires_in) => { + await expect( + readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "access", + expires_in, + }), + ), + ), + ).resolves.toEqual({ access_token: "access" }) + }, + ) + + it("drops null optional fields, OAuth errors and client-derived fields", async () => { + await expect( + readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "access", + id_token: null, + refresh_token: null, + token_type: null, + error: null, + error_description: null, + expires_at: "2000-01-01", + groups_encoded_in_token: true, + extra: "ignored", + }), + ), + ), + ).resolves.toEqual({ access_token: "access" }) + }) + + it("does not expose a malformed response body through parser diagnostics", async () => { + await expect( + readAuthTokenResponse( + new Response('{"access_token":"secret"} malformed'), + ), + ).rejects.toThrow("Invalid token response: expected JSON") + }) + + it("identifies the malformed field without including credentials", async () => { + await expect( + readAuthTokenResponse( + new Response( + JSON.stringify({ + access_token: "secret", + refresh_token: 123, + }), + ), + ), + ).rejects.toThrow("Invalid token response: refresh_token must be a string") + }) +}) + +describe("credential precedence", () => { + it("prefers the tab's SSO bearer over stored REST/basic credentials", () => { + localStorage.setItem(StoreKey.REST_TOKEN, "rest") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic password") + expect(getBearerAuthorization({ access_token: "sso" })).toBe("Bearer sso") + expect(getStoredCredentialAuthorization()).toBe("Bearer rest") + localStorage.removeItem(StoreKey.REST_TOKEN) + expect(getStoredCredentialAuthorization()).toBe("Basic password") + localStorage.clear() + expect(getStoredCredentialAuthorization()).toBeUndefined() + }) +}) + +describe("authorization code exchange", () => { + beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => {}) + }) + const settings = { "acl.oidc.token.endpoint": "/token" } as Settings + const payload = { grant_type: "authorization_code", code: "code" } + + it("resolves with the token response, even an OAuth error", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 400, + json: () => Promise.resolve({ error: "invalid_grant" }), + }), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { + tokenResponse: { error: "invalid_grant" }, + }, + ) + }) + + it("explains a provider that cannot be reached", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockRejectedValue(new TypeError("Failed to fetch")), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { + signInError: { + errorTitle: "Something went wrong.", + errorMessage: + "Could not reach the identity provider to complete the sign-in. Please try again.", + }, + }, + ) + }) + + it("explains a response that is not JSON", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 502, + json: () => Promise.reject(new SyntaxError("Unexpected token '<'")), + }), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { + signInError: { + errorTitle: "Something went wrong.", + errorMessage: + "The identity provider returned an unexpected response (HTTP 502). Please try again.", + }, + }, + ) + }) + + const timedOut = { + signInError: { + errorTitle: "Something went wrong.", + errorMessage: + "The identity provider did not respond within 10 seconds. Please try again.", + }, + } + + it("aborts a request that gets no response within the timeout", async () => { + vi.useFakeTimers() + // Like a browser fetch, the request only ends when it is aborted + const fetchMock = vi.fn( + (_url: string, init: RequestInit) => + new Promise((_, reject) => { + init.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + vi.stubGlobal("fetch", fetchMock) + let settled = false + const exchange = exchangeAuthorizationCode(settings, payload).finally( + () => { + settled = true + }, + ) + + await vi.advanceTimersByTimeAsync(TOKEN_REQUEST_TIMEOUT_MS - 1) + expect(settled).toBe(false) + await vi.advanceTimersByTimeAsync(1) + + await expect(exchange).resolves.toEqual(timedOut) + expect(fetchMock.mock.calls[0][1].signal?.aborted).toBe(true) + }) + + it("gives up on a response body that ignores the abort", async () => { + vi.useFakeTimers() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + json: () => new Promise(() => {}), + }), + ) + const exchange = exchangeAuthorizationCode(settings, payload) + + await vi.advanceTimersByTimeAsync(TOKEN_REQUEST_TIMEOUT_MS) + + await expect(exchange).resolves.toEqual(timedOut) + }) + + it.each([ + null, + [], + "token", + 123, + { access_token: 123 }, + { id_token: 123 }, + { refresh_token: {} }, + ])("explains an invalid JSON token response: %j", async (body) => { + vi.stubGlobal( + "fetch", + vi + .fn() + .mockResolvedValue({ status: 200, json: () => Promise.resolve(body) }), + ) + await expect( + exchangeAuthorizationCode(settings, payload), + ).resolves.toMatchObject({ + signInError: { + errorMessage: + "The identity provider returned an invalid token response. Please ask your administrator to check the SSO configuration.", + }, + }) + }) + + it("clears the timeout once the response arrives", async () => { + vi.useFakeTimers() + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + status: 200, + json: () => Promise.resolve({ access_token: "token" }), + }), + ) + + await expect(exchangeAuthorizationCode(settings, payload)).resolves.toEqual( + { tokenResponse: { access_token: "token" } }, + ) + expect(vi.getTimerCount()).toBe(0) + }) +}) + +describe("bearer authorization", () => { + it("uses the access token unless the server requires the ID token", () => { + expect( + getBearerAuthorization({ access_token: "access", id_token: "id" }), + ).toBe("Bearer access") + expect( + getBearerAuthorization({ + access_token: "access", + id_token: "id", + groups_encoded_in_token: true, + }), + ).toBe("Bearer id") + expect( + getBearerAuthorization({ + access_token: "access", + groups_encoded_in_token: true, + }), + ).toBeUndefined() + expect(getBearerAuthorization({})).toBeUndefined() + }) +}) + +describe("OIDC refresh requests", () => { + it("passes an explicit network cancellation signal to fetch", async () => { + const fetchMock = vi.fn().mockResolvedValue({}) + vi.stubGlobal("fetch", fetchMock) + const controller = new AbortController() + + await getAuthToken( + { "acl.oidc.token.endpoint": "/token" } as Settings, + { grant_type: "refresh_token", refresh_token: "old" }, + controller.signal, + ) + + expect(fetchMock).toHaveBeenCalledWith( + "/token", + expect.objectContaining({ + method: "POST", + signal: controller.signal, + body: new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: "old", + }), + }), + ) + }) + + it("preserves a successful response body that resolves after the wait deadline", async () => { + const controller = new AbortController() + let resolveBody!: (value: AuthPayload) => void + const body = new Promise((resolve) => { + resolveBody = resolve + }) + const result = readAuthTokenResponse( + { json: () => body } as Response, + controller.signal, + ) + + controller.abort() + resolveBody({ access_token: "late" } as AuthPayload) + await expect(result).resolves.toEqual({ access_token: "late" }) + }) + + it("does not apply a late OAuth error after the wait deadline", async () => { + const controller = new AbortController() + controller.abort() + await expect( + readAuthTokenResponse( + { json: () => Promise.resolve({ error: "invalid_grant" }) } as Response, + controller.signal, + ), + ).rejects.toThrow("Request timed out") + }) +}) diff --git a/src/modules/OAuth2/utils.ts b/src/modules/OAuth2/utils.ts index e1a191ef2..92e9d6ecc 100644 --- a/src/modules/OAuth2/utils.ts +++ b/src/modules/OAuth2/utils.ts @@ -1,5 +1,25 @@ +import { Base64 } from "js-base64" import { Settings } from "../../providers/SettingsProvider/types" +import { AuthPayload, SignInError, TokenResponse } from "./types" +import { DeadlineExceededError, withDeadline } from "../../utils/withDeadline" import { StoreKey } from "../../utils/localStorage/types" +import { getValue } from "../../utils/localStorage" + +export const SIGN_IN_ERROR_TITLE = "Something went wrong." +export const SESSION_EXPIRED_TITLE = "Your SSO session has expired" +const SENTENCE_ENDINGS = [".", "!", "?"] + +export const sessionRenewalError = (description?: string): SignInError => { + const reason = description?.trim() || "Could not renew your SSO session." + // Identity providers often omit the full stop, e.g. "Token is not active". + const sentence = SENTENCE_ENDINGS.some((ending) => reason.endsWith(ending)) + ? reason + : `${reason}.` + return { + errorTitle: SESSION_EXPIRED_TITLE, + errorMessage: `${sentence} Please sign in again.`, + } +} type TokenPayload = Partial<{ grant_type: string @@ -62,13 +82,134 @@ export const getAuthorisationURL = ({ ) } -export const getTokenExpirationDate = (expires_in: number) => { - return new Date(new Date().getTime() + expires_in * 1000) +// Reads the exp claim of a JWT, in milliseconds. The signature is not verified: +// the claim only schedules the refresh, and the server validates the token. +const getJwtExpiration = (jwt: string | undefined) => { + const payload = jwt?.split(".")[1] + if (!payload) return undefined + try { + const claims = JSON.parse(Base64.decode(payload)) as { + exp?: unknown + } | null + const exp = claims?.exp + return typeof exp === "number" && + !Number.isNaN(new Date(exp * 1000).getTime()) + ? exp * 1000 + : undefined + } catch { + return undefined + } +} + +// expires_in is the lifetime of the access token, so an ID token bearer expires +// at its exp claim instead. exp is not rebased onto iat, because some IdPs +// (e.g. Entra ID) backdate iat by several minutes to allow for clock skew. +// Returns undefined when the bearer's expiry is unknown, because a provider may +// omit expires_in. +export const getTokenExpirationDate = ( + tokenResponse: Pick< + AuthPayload, + "id_token" | "expires_in" | "groups_encoded_in_token" + >, +): Date | undefined => { + const idTokenExpiration = tokenResponse.groups_encoded_in_token + ? getJwtExpiration(tokenResponse.id_token) + : undefined + if (idTokenExpiration !== undefined) return new Date(idTokenExpiration) + const lifetime = tokenResponse.expires_in + // Zero is a known, immediately expired lifetime, not an omitted expiry. + if (lifetime === undefined || !Number.isFinite(lifetime) || lifetime < 0) + return undefined + const expiration = new Date(Date.now() + lifetime * 1000) + return Number.isNaN(expiration.getTime()) ? undefined : expiration +} + +// The server validates the bearer; clients share this helper so stale-401 +// comparisons use exactly the same authorization value as outgoing requests. +export const getBearerAuthorization = (payload: Partial) => { + const bearer = payload.groups_encoded_in_token + ? payload.id_token + : payload.access_token + return bearer ? `Bearer ${bearer}` : undefined +} + +// SSO is tab-local and takes precedence over credentials another tab may save. +export const getStoredCredentialAuthorization = () => { + const restToken = getValue(StoreKey.REST_TOKEN) + return restToken + ? `Bearer ${restToken}` + : getValue(StoreKey.BASIC_AUTH_HEADER) || undefined +} + +export type GrantType = "authorization_code" | "refresh_token" + +const MISSING_ID_TOKEN_MESSAGES: Record = { + authorization_code: + "Your identity provider did not send the information needed to sign you in. Please ask your administrator to check the SSO configuration.", + refresh_token: + "Your identity provider did not send the information needed to keep you signed in. You can sign in again, but please ask your administrator to check the SSO configuration to prevent this happening again.", +} + +// The server cannot authenticate an ID token bearer without the ID token. OIDC +// lets a refresh response omit it, and without the openid scope there is none. +export const getMissingIdTokenError = ( + tokenResponse: Pick, + grantType: GrantType, +): SignInError | undefined => + tokenResponse.groups_encoded_in_token && !tokenResponse.id_token + ? { + errorTitle: "Missing ID token", + errorMessage: MISSING_ID_TOKEN_MESSAGES[grantType], + } + : undefined + +// Pure normalization: only the response for this grant may supply a bearer; +// only a refresh may retain the previous refresh token. Endpoint expires_at +// fields are never trusted for scheduling. +export const buildInstalledPayload = ( + response: TokenResponse, + groupsEncodedInToken: boolean | undefined, + grant: GrantType, + previous: AuthPayload | null, +): { payload: AuthPayload } | { error: SignInError } => { + if (!response.access_token) { + return { + error: + grant === "refresh_token" + ? sessionRenewalError(response.error_description) + : { + errorTitle: SIGN_IN_ERROR_TITLE, + errorMessage: + response.error_description ?? + "Could not complete the sign-in. Please try again.", + }, + } + } + const payload: AuthPayload = { + access_token: response.access_token, + id_token: response.id_token, + expires_in: response.expires_in, + token_type: response.token_type ?? "Bearer", + refresh_token: + response.refresh_token ?? + (grant === "refresh_token" ? previous?.refresh_token : undefined), + groups_encoded_in_token: groupsEncodedInToken, + } + const error = getMissingIdTokenError(payload, grant) + return error + ? { error } + : { + payload: { + ...payload, + expires_at: getTokenExpirationDate(payload)?.toISOString(), + }, + } } export const getAuthToken = async ( settings: Settings, payload: TokenPayload, + signal?: AbortSignal, ) => { return fetch( `${getBaseURL(settings)}${settings["acl.oidc.token.endpoint"]}`, @@ -78,10 +219,133 @@ export const getAuthToken = async ( "Content-Type": "application/x-www-form-urlencoded", }, body: new URLSearchParams(payload), + signal, }, ) } +// Sign-in aborts the request at this deadline. Refresh only releases waiting +// queries here; its separate network deadline allows a late rotated token. +export const TOKEN_REQUEST_TIMEOUT_MS = 10_000 + +type CodeExchange = + | { tokenResponse: TokenResponse } + | { signInError: SignInError } + +const signInFailure = (errorMessage: string): CodeExchange => ({ + signInError: { errorTitle: SIGN_IN_ERROR_TITLE, errorMessage }, +}) + +const SIGN_IN_TIMED_OUT = `The identity provider did not respond within ${TOKEN_REQUEST_TIMEOUT_MS / 1000} seconds. Please try again.` + +const requestTokens = async ( + settings: Settings, + payload: TokenPayload, + signal: AbortSignal, +): Promise => { + let response: Response + try { + response = await getAuthToken(settings, payload, signal) + } catch { + return signInFailure( + signal.aborted + ? SIGN_IN_TIMED_OUT + : "Could not reach the identity provider to complete the sign-in. Please try again.", + ) + } + try { + return { tokenResponse: await readAuthTokenResponse(response) } + } catch (error) { + // Do not log the response body: it may contain credentials. + console.error("Invalid identity-provider token response", error) + return signInFailure( + signal.aborted + ? SIGN_IN_TIMED_OUT + : response.status >= 200 && response.status < 300 + ? "The identity provider returned an invalid token response. Please ask your administrator to check the SSO configuration." + : `The identity provider returned an unexpected response (HTTP ${response.status}). Please try again.`, + ) + } +} + +// Exchanges the authorization code for tokens. A request that fails or times +// out without an OAuth response resolves with the error to show, because the +// sign-in would otherwise stop on a blank screen. The token response itself +// may still be an OAuth error. +export const exchangeAuthorizationCode = async ( + settings: Settings, + payload: TokenPayload, +): Promise => { + const controller = new AbortController() + try { + return await withDeadline( + requestTokens(settings, payload, controller.signal), + TOKEN_REQUEST_TIMEOUT_MS, + () => controller.abort(), + ) + } catch (error) { + if (error instanceof DeadlineExceededError) { + return signInFailure(SIGN_IN_TIMED_OUT) + } + throw error + } +} + +export const readAuthTokenResponse = async ( + response: Response, + deadlineSignal?: AbortSignal, +): Promise => { + let body: unknown + try { + body = await response.json() + } catch { + // JSON parser errors can include snippets of the body (and credentials). + // Keep the diagnostic safe for the sign-in logger. + throw new Error("Invalid token response: expected JSON") + } + if (typeof body !== "object" || body === null || Array.isArray(body)) { + throw new Error("Invalid token response: expected an object") + } + const fields = body as Record + const tokenResponse: TokenResponse = {} + for (const key of [ + "access_token", + "id_token", + "refresh_token", + "token_type", + "error", + "error_description", + ] as const) { + const value = fields[key] + // Some IdPs return JSON null for omitted optional fields. + if (value === undefined || value === null) continue + if (typeof value !== "string") { + throw new Error(`Invalid token response: ${key} must be a string`) + } + tokenResponse[key] = value + } + // Entra's legacy/B2C endpoints can serialise this lifetime as a string. + // An unusable optional lifetime means unknown expiry, not invalid tokens. + const lifetime = + typeof fields.expires_in === "string" && /^\d+$/.test(fields.expires_in) + ? Number(fields.expires_in) + : fields.expires_in + if ( + typeof lifetime === "number" && + Number.isFinite(lifetime) && + lifetime >= 0 && + !Number.isNaN(new Date(Date.now() + lifetime * 1000).getTime()) + ) { + tokenResponse.expires_in = lifetime + } + // Preserve a late rotated refresh token, but don't let a late OAuth error + // log out a session whose waiting queries have already been released. + if (deadlineSignal?.aborted && !tokenResponse.access_token) { + throw new DeadlineExceededError() + } + return tokenResponse +} + export const hasUIAuth = (settings: Settings) => settings["acl.enabled"] && !settings["acl.basic.auth.realm.enabled"] diff --git a/src/modules/OAuth2/views/login.test.tsx b/src/modules/OAuth2/views/login.test.tsx new file mode 100644 index 000000000..14f7f78dc --- /dev/null +++ b/src/modules/OAuth2/views/login.test.tsx @@ -0,0 +1,221 @@ +import React from "react" +import { + act, + create, + type ReactTestInstance, + type ReactTestRenderer, +} from "react-test-renderer" +import { ThemeProvider } from "styled-components" +import { afterEach, describe, expect, it, vi } from "vitest" +import { darkTheme } from "../../../theme" + +vi.mock("../../../providers", () => ({ + useSettings: () => ({ + settings: { + "release.type": "EE", + "release.version": "test", + "acl.basic.auth.realm.enabled": false, + }, + }), +})) +vi.mock("../../../components", () => ({ + Badge: "div", + Text: "span", + Button: "button", + IconButton: "button", + Form: Object.assign( + (props: React.ComponentProps<"form">) =>
, + { Item: "label", Input: "input", Submit: "button" }, + ), +})) +vi.mock("../../../components/icons", () => ({ + User: () => null, + Building: () => null, + ErrorWarning: () => null, + XSquare: () => null, +})) +vi.mock("../utils", () => ({ + SIGN_IN_ERROR_TITLE: "Sign-in failed", + getSSOUserNameWithClientID: () => "", +})) +vi.mock("../../../components/LoadingSpinner", () => ({ + LoadingSpinner: () => null, +})) +vi.mock("./loginBackground", () => ({ LoginBackground: () => null })) +import { Login } from "./login" + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.unstubAllGlobals() +}) + +const mount = ( + errors: { errorTitle?: string; errorMessage?: string } = {}, + unfinishedWrites = 0, +) => { + const heading = { focus: vi.fn() } + const banner = { focus: vi.fn() } + const props = { + onOAuthLogin: vi.fn(), + onBasicAuthSuccess: vi.fn(), + resetErrors: vi.fn(), + unfinishedWrites, + ...errors, + } + const render = () => ( + + + + ) + act(() => { + renderer = create(render(), { + createNodeMock: ( + element: React.ReactElement>, + ) => { + if (element.type === "h2") return heading + if (element.props["data-hook"] === "sign-in-error") return banner + return null + }, + }) + }) + const clearErrors = () => { + props.errorTitle = undefined + props.errorMessage = undefined + renderer!.update(render()) + } + props.resetErrors.mockImplementation(clearErrors) + return { + heading, + banner, + props, + updateErrors: (next: typeof errors) => { + act(() => { + props.errorTitle = next.errorTitle + props.errorMessage = next.errorMessage + renderer!.update(render()) + }) + }, + updateUnfinishedWrites: (count: number) => { + act(() => { + props.unfinishedWrites = count + renderer!.update(render()) + }) + }, + } +} + +const textOf = (node: ReactTestInstance): string => + node.children + .map((child) => (typeof child === "string" ? child : textOf(child))) + .join("") + +// AuthProvider asks before leaving the page, as signing in replaces Login. +describe("unfinished writes of the previous session", () => { + const notices = () => + renderer!.root.findAll( + (node) => + node.type === "div" && node.props["data-hook"] === "unfinished-writes", + ) + + it("explains them until they finish", () => { + const { updateUnfinishedWrites } = mount({}, 2) + expect(notices()).toHaveLength(1) + expect(notices()[0].props.role).toBe("status") + expect(textOf(notices()[0])).toContain( + "Your previous session is still sending 2 requests", + ) + expect(textOf(notices()[0])).toContain( + "Leaving the page, including to sign in with SSO, cancels them", + ) + + updateUnfinishedWrites(1) + expect(textOf(notices()[0])).toContain( + "Your previous session is still sending 1 request", + ) + expect(textOf(notices()[0])).toContain("Keep this tab open until it") + + updateUnfinishedWrites(0) + expect(notices()).toHaveLength(0) + }) + + it("shows no notice when no write is unfinished", () => { + mount() + expect(notices()).toHaveLength(0) + }) +}) + +describe("login focus", () => { + it("focuses the heading on initial mount without a redirect error", () => { + const { heading, banner } = mount() + expect(heading.focus).toHaveBeenCalledOnce() + expect(banner.focus).not.toHaveBeenCalled() + }) + + it("focuses redirect errors when presented, but not the heading when cleared", () => { + const { heading, banner, updateErrors } = mount({ + errorMessage: "Sign-in cancelled", + }) + expect(banner.focus).toHaveBeenCalledOnce() + expect(heading.focus).not.toHaveBeenCalled() + + updateErrors({}) + expect(heading.focus).not.toHaveBeenCalled() + updateErrors({ errorTitle: "Another sign-in error" }) + expect(banner.focus).toHaveBeenCalledTimes(2) + }) + + it("retains explicit dismissal's heading focus", () => { + const { heading, props } = mount({ errorMessage: "Sign-in cancelled" }) + act(() => { + renderer!.root + .findByProps({ label: "Dismiss sign-in error" }) + .props.onClick() + }) + expect(props.resetErrors).toHaveBeenCalledOnce() + expect(heading.focus).toHaveBeenCalledOnce() + }) + + it("does not refocus the heading during password submission or credential rejection", async () => { + const { heading, props } = mount({ errorMessage: "Sign-in cancelled" }) + let finish!: (response: Response) => void + vi.stubGlobal( + "fetch", + vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ), + ) + let pending!: Promise + act(() => { + const { onSubmit } = renderer!.root.findByType("form").props as { + onSubmit: (values: { + username: string + password: string + }) => Promise + } + pending = onSubmit({ username: "admin", password: "wrong" }) + }) + expect(props.resetErrors).toHaveBeenCalledOnce() + expect(heading.focus).not.toHaveBeenCalled() + expect( + renderer!.root.findAllByProps({ "data-hook": "sign-in-error" }), + ).toHaveLength(0) + + await act(async () => { + finish(new Response("", { status: 401 })) + await pending + }) + expect(heading.focus).not.toHaveBeenCalled() + expect( + renderer!.root + .findAllByType("span") + .some((node) => node.children.includes("Invalid username or password")), + ).toBe(true) + expect(props.onBasicAuthSuccess).not.toHaveBeenCalled() + }) +}) diff --git a/src/modules/OAuth2/views/login.tsx b/src/modules/OAuth2/views/login.tsx index 47233fc05..f4d578a67 100644 --- a/src/modules/OAuth2/views/login.tsx +++ b/src/modules/OAuth2/views/login.tsx @@ -1,4 +1,4 @@ -import React, { useState } from "react" +import React, { useEffect, useRef, useState } from "react" import styled, { useTheme } from "styled-components" import { User, Building } from "../../../components/icons" import { ErrorWarning } from "../../../components/icons" @@ -9,7 +9,7 @@ import { Badge, Text, Form, Button, IconButton } from "../../../components" import { setValue } from "../../../utils/localStorage" import { StoreKey } from "../../../utils/localStorage/types" import { useSettings } from "../../../providers" -import { getSSOUserNameWithClientID } from "../utils" +import { getSSOUserNameWithClientID, SIGN_IN_ERROR_TITLE } from "../utils" import { RawDqlResult } from "utils/questdb/types" import { LoadingSpinner } from "../../../components/LoadingSpinner" import { Box } from "../../../components/Box" @@ -76,6 +76,7 @@ const Container = styled.div<{ $hasRedirectError: boolean }>` font-size: 16px; ` const Title = styled.h2` + outline: none; width: 100%; color: ${({ theme }) => theme.color.contentPrimary}; font-weight: 600; @@ -176,6 +177,18 @@ const RedirectErrorContainer = styled(ErrorContainer)` 0 1.2rem 3.2rem ${({ theme }) => theme.color.shadowSoft}; ` +const UnfinishedWritesNotice = styled.div` + display: flex; + flex-direction: column; + gap: 0.4rem; + margin: 2.4rem 4.8rem 0; + padding: 1.2rem 1.6rem; + border-radius: 0.8rem; + border: 1px solid ${({ theme }) => theme.color.statusWarning}; + border-left-width: 6px; + background: ${({ theme }) => theme.color.statusWarningSurface}; +` + const StyledButton = styled(Button)` margin: 0 !important; ` @@ -278,6 +291,7 @@ export const Login = ({ errorMessage: redirectErrorMessage, isDisconnection, resetErrors, + unfinishedWrites, }: { onOAuthLogin: (loginWithDifferentAccount?: boolean) => void onBasicAuthSuccess: () => void @@ -285,6 +299,9 @@ export const Login = ({ errorMessage?: string isDisconnection?: boolean resetErrors: () => void + // Uploads and settings saves of the previous session still being sent. + // AuthProvider asks before leaving the page until they finish. + unfinishedWrites: number }) => { const { settings } = useSettings() const theme = useTheme() @@ -296,6 +313,22 @@ export const Login = ({ : "" const version = settings["release.version"] const [loading, setLoading] = useState(false) + const redirectErrorRef = useRef(null) + const titleRef = useRef(null) + const hasFocusedInitialView = useRef(false) + + useEffect(() => { + if (redirectErrorTitle || redirectErrorMessage) { + redirectErrorRef.current?.focus() + } else if (!hasFocusedInitialView.current) { + titleRef.current?.focus() + } + // Clearing an SSO error during password submission must not steal focus + // from the form. Explicit dismissal restores heading focus separately. + hasFocusedInitialView.current = true + }, [redirectErrorTitle, redirectErrorMessage]) + + const hasUnfinishedWrites = unfinishedWrites > 0 const httpBasicAuthStrategy = isEE ? { @@ -358,14 +391,21 @@ export const Login = ({ {(redirectErrorTitle || redirectErrorMessage) && ( - - + + - - {redirectErrorTitle ?? "Something went wrong."} - - - {redirectErrorMessage ?? "Error logging in. Please try again."} - + + + {redirectErrorTitle ?? SIGN_IN_ERROR_TITLE} + + + + + {redirectErrorMessage ?? + "Error logging in. Please try again."} + + { + resetErrors() + titleRef.current?.focus() + }} > @@ -405,7 +453,23 @@ export const Login = ({ height="48" /> - Sign in to QuestDB + + Sign in to QuestDB + + {hasUnfinishedWrites && ( + + + {unfinishedWrites === 1 + ? "Your previous session is still sending 1 request" + : `Your previous session is still sending ${unfinishedWrites} requests`} + + + {unfinishedWrites === 1 + ? "Keep this tab open until it finishes. Leaving the page, including to sign in with SSO, cancels it, and a cancelled CSV upload can leave a partially imported table." + : "Keep this tab open until they finish. Leaving the page, including to sign in with SSO, cancels them, and a cancelled CSV upload can leave a partially imported table."} + + + )} {settings["acl.oidc.enabled"] && ( diff --git a/src/modules/OAuth2/views/refreshFailedWarning.test.tsx b/src/modules/OAuth2/views/refreshFailedWarning.test.tsx new file mode 100644 index 000000000..96fcd83e8 --- /dev/null +++ b/src/modules/OAuth2/views/refreshFailedWarning.test.tsx @@ -0,0 +1,123 @@ +import "../../../test/stubBrowserGlobals" +import React from "react" +import { ThemeProvider } from "styled-components" +import { darkTheme, lightTheme } from "../../../theme" +import { Text } from "../../../components/Text" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { RefreshFailedWarning } from "./refreshFailedWarning" + +vi.mock("../../../components/Button", () => ({ Button: "button" })) +vi.mock("../../../components/Box", () => ({ Box: "div" })) + +let renderer: ReactTestRenderer | undefined + +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date("2026-01-01T00:00:00Z")) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.useRealTimers() +}) + +const mount = (expiresAt?: string) => { + const onSignIn = vi.fn() + act(() => { + renderer = create( + + + , + ) + }) + return onSignIn +} + +const message = () => renderer!.root.findAllByType("span")[0].children[0] +const advanceTime = (ms: number) => { + act(() => { + vi.advanceTimersByTime(ms) + }) +} + +describe("refresh failure warning", () => { + it.each([darkTheme, lightTheme])( + "uses primary content color in $mode mode", + (theme) => { + act(() => { + renderer = create( + + + , + ) + }) + expect(renderer!.root.findAllByType(Text)[0].props.color).toBe( + "contentPrimary", + ) + expect(message()).not.toContain("paused") + }, + ) + it("updates an open warning at expiry without another refresh event", () => { + const onSignIn = mount(new Date(Date.now() + 20_000).toISOString()) + expect(message()).toContain("Your current token expires at") + advanceTime(19_999) + expect(message()).toContain("Your current token expires at") + advanceTime(1) + expect(message()).toContain( + "Queries are not being sent. Run them again after a short wait", + ) + expect(message()).not.toContain("Your current token expires at") + act(() => { + const { onClick } = renderer!.root.findByType("button").props as { + onClick: () => void + } + onClick() + }) + expect(onSignIn).toHaveBeenCalledOnce() + }) + + it.each([undefined, "invalid", "2025-12-31T23:59:59Z"])( + "reports unsent queries immediately for expiry %s", + (expiresAt) => { + mount(expiresAt) + expect(message()).toContain("Queries are not being sent") + expect(vi.getTimerCount()).toBe(0) + }, + ) + + it("cancels its expiry timer when the warning is dismissed", () => { + mount(new Date(Date.now() + 20_000).toISOString()) + expect(vi.getTimerCount()).toBe(1) + act(() => renderer!.unmount()) + renderer = undefined + expect(vi.getTimerCount()).toBe(0) + }) + + it("reschedules if the warning receives a later expiry", () => { + mount(new Date(Date.now() + 20_000).toISOString()) + act(() => { + renderer!.update( + + + , + ) + }) + advanceTime(20_000) + expect(message()).toContain("Your current token expires at") + advanceTime(20_000) + expect(message()).toContain("Queries are not being sent") + }) + + it("does not overflow the timer for distant expiry dates", () => { + mount(new Date(Date.now() + 2_147_483_648).toISOString()) + advanceTime(2_147_483_647) + expect(message()).toContain("Your current token expires at") + advanceTime(1) + expect(message()).toContain("Queries are not being sent") + }) +}) diff --git a/src/modules/OAuth2/views/refreshFailedWarning.tsx b/src/modules/OAuth2/views/refreshFailedWarning.tsx new file mode 100644 index 000000000..f1025e38b --- /dev/null +++ b/src/modules/OAuth2/views/refreshFailedWarning.tsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from "react" +import { Button } from "../../../components/Button" +import { Box } from "../../../components/Box" +import { Text } from "../../../components/Text" + +const MAX_TIMER_DELAY_MS = 2_147_483_647 + +export const RefreshFailedWarning = ({ + expiresAt, + onSignIn, +}: { + expiresAt?: string + onSignIn: () => void +}) => { + const expiry = expiresAt ? Date.parse(expiresAt) : NaN + const [expired, setExpired] = useState(() => !(expiry > Date.now())) + + useEffect(() => { + let timer: ReturnType | undefined + const updateExpiry = () => { + const remaining = expiry - Date.now() + setExpired(!(remaining > 0)) + if (remaining > 0) { + timer = setTimeout( + updateExpiry, + Math.min(remaining, MAX_TIMER_DELAY_MS), + ) + } + } + updateExpiry() + return () => clearTimeout(timer) + }, [expiry]) + + return ( + + + {expired + ? "Could not renew your SSO session. Queries are not being sent. Run them again after a short wait to retry the renewal, or sign in again." + : `Could not renew your SSO session. Your current token expires at ${new Date(expiry).toLocaleTimeString()}.`} + + + Signing in again ends this session and interrupts running queries. + Statements may have executed; verify their outcomes before retrying. + + + + ) +} diff --git a/src/modules/OAuth2/views/unfinishedWritesWarning.tsx b/src/modules/OAuth2/views/unfinishedWritesWarning.tsx new file mode 100644 index 000000000..456b45d34 --- /dev/null +++ b/src/modules/OAuth2/views/unfinishedWritesWarning.tsx @@ -0,0 +1,36 @@ +import React from "react" +import { Box } from "../../../components/Box" +import { Text } from "../../../components/Text" +import { useEndedSessionWriteCount } from "../../../hooks/useEndedSessionWriteCount" + +export const UNFINISHED_WRITES_TOAST_ID = "ended-session-writes" + +// Shown in the console after signing in again without leaving the page, while +// uploads or settings saves of the previous session are still being sent. +export const UnfinishedWritesWarning = () => { + const count = useEndedSessionWriteCount() + + // AuthProvider dismisses the warning once they settle, while this shows. + if (count === 0) { + return ( + + Your previous session finished sending its requests. + + ) + } + + return ( + + + {count === 1 + ? "Your previous session is still sending 1 request" + : `Your previous session is still sending ${count} requests`} + + + {count === 1 + ? "Keep this tab open until it finishes. Leaving the page, reloading it or logging out cancels it, which can leave a partially imported table. Importing the same file again meanwhile can import its rows twice." + : "Keep this tab open until they finish. Leaving the page, reloading it or logging out cancels them, which can leave a partially imported table. Importing the same files again meanwhile can import their rows twice."} + + + ) +} diff --git a/src/providers/AuthProvider.test.tsx b/src/providers/AuthProvider.test.tsx new file mode 100644 index 000000000..8c0b41aa4 --- /dev/null +++ b/src/providers/AuthProvider.test.tsx @@ -0,0 +1,1939 @@ +import "../test/stubBrowserGlobals" +import React from "react" +import { Base64 } from "js-base64" +import { + act, + create, + type ReactTestInstance, + ReactTestRenderer, +} from "react-test-renderer" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { AuthProvider, useAuth } from "./AuthProvider" +import { QuestProvider } from "./QuestProvider" +import { questClient } from "../utils/questdb/queryClient" +import { authSessionState } from "../modules/OAuth2/authSessionState" +import { SessionEndedError } from "../modules/OAuth2/session" +import { eventBus } from "../modules/EventBus" +import { EventType } from "../modules/EventBus/types" +import { StoreKey } from "../utils/localStorage/types" +import type { Settings } from "./SettingsProvider/types" +import type { TokenResponse } from "../modules/OAuth2/types" +import { isRefreshable, tokenRefresh } from "../utils/questdb/tokenRefresh" +import { SettingsProvider, useSettings } from "./SettingsProvider" +import { ThemeProvider } from "styled-components" +import { darkTheme } from "../theme" +import { UNFINISHED_WRITES_TOAST_ID } from "../modules/OAuth2/views/unfinishedWritesWarning" + +const mocks = vi.hoisted(() => ({ + settings: {} as Settings, + useRealSettings: false, + toastError: vi.fn< + [ + React.ReactElement, + { autoClose: boolean; closeOnClick: boolean; toastId: string }, + ], + void + >(), + toastWarning: vi.fn< + [ + React.ReactElement, + { autoClose: boolean; closeOnClick: boolean; toastId: string }, + ], + void + >(), + toastDismiss: vi.fn(), + reload: vi.fn(), + toastActive: vi.fn(() => false), +})) +vi.mock("./SettingsProvider", async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + useSettings: () => { + const context = actual.useSettings() + return mocks.useRealSettings ? context : { settings: mocks.settings } + }, + } +}) +vi.mock("../components/Toast", () => ({ + NOTIFICATION_SHORTCUT_LABEL: "Alt+N", + NOTIFICATION_SHORTCUT_SCOPE: "", + REFRESH_FAILED_TOAST_ID: "sso-refresh-failed", + toast: { + error: mocks.toastError, + warning: mocks.toastWarning, + dismiss: mocks.toastDismiss, + isActive: mocks.toastActive, + }, +})) +vi.mock("../components/Button", () => ({ Button: "button" })) +vi.mock("../components/Text", () => ({ Text: "span" })) +vi.mock("../components/Box", () => ({ Box: "div" })) +vi.mock("react-redux", () => ({ + useDispatch: () => vi.fn(), + useSelector: () => undefined, +})) +vi.mock("../store", () => ({ + actions: { telemetry: { start: () => ({ type: "start" }) } }, + selectors: { telemetry: { getConfig: vi.fn() } }, +})) +vi.mock("../modules/ConsoleEventTracker", () => ({ + start: vi.fn(), + stop: vi.fn(), +})) +vi.mock("../components/QueryExecutionConfirmDialog", () => ({ + QueryExecutionConfirmDialog: () => null, +})) +vi.mock("../components", () => ({ + Text: "span", + Button: "button", + Box: "div", + CenteredLayout: "section", +})) +vi.mock("../modules/OAuth2/views/login", () => ({ + Login: (props: Record) => + React.createElement("div", { + ...props, + "data-hook": "mock-login", + "data-sso-username": localStorage.getItem("sso.username.test-client"), + }), +})) + +let renderer: ReactTestRenderer | undefined +let auth: ReturnType +const initialToken = { + access_token: "initial", + id_token: "initial-id", + refresh_token: "initial-refresh", + expires_in: 3600, + token_type: "Bearer", +} +const textOf = (node: ReactTestInstance): string => + node.children + .map((child) => (typeof child === "string" ? child : textOf(child))) + .join("") +const refreshToken = ( + deadline = new AbortController().signal, + request = new AbortController().signal, +) => auth.refreshAuthToken(mocks.settings, "initial-refresh", deadline, request) +let signInToken: TokenResponse = initialToken +const tokenResponse = (body: TokenResponse) => + new Response(JSON.stringify(body), { status: 200 }) +// Unsigned, as only the claims are read: the server verifies the signature. +const idToken = (exp: number) => + `header.${Base64.encodeURI(JSON.stringify({ exp }))}.signature` + +beforeEach(() => { + localStorage.clear() + signInToken = initialToken + mocks.useRealSettings = false + mocks.toastActive.mockReturnValue(false) + // `window` is the global object here. AuthProvider asks before leaving the + // page while an ended session's writes are still being sent. + vi.stubGlobal("addEventListener", vi.fn()) + vi.stubGlobal("removeEventListener", vi.fn()) + const tabStorage = new Map() + vi.stubGlobal("sessionStorage", { + getItem: (key: string) => tabStorage.get(key) ?? null, + setItem: vi.fn((key: string, value: string) => tabStorage.set(key, value)), + removeItem: vi.fn((key: string) => tabStorage.delete(key)), + }) + vi.stubGlobal("location", { + origin: "http://localhost", + pathname: "/", + search: "?code=test", + href: "http://localhost/?code=test", + reload: mocks.reload, + }) + mocks.settings = { + "acl.enabled": true, + "acl.oidc.enabled": true, + "acl.oidc.client.id": "test-client", + "acl.oidc.token.endpoint": "/token", + "acl.oidc.groups.encoded.in.token": false, + } + vi.stubGlobal( + "fetch", + vi.fn((url: string) => + Promise.resolve( + url === "/token" ? tokenResponse(signInToken) : new Response(""), + ), + ), + ) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + authSessionState.endSession() + tokenRefresh.syncSession() + vi.unstubAllGlobals() + vi.restoreAllMocks() + vi.clearAllMocks() +}) + +const mount = async () => { + const Consumer = () => { + auth = useAuth() + return null + } + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + // Signed in, so `auth` comes from this render rather than an earlier test. + expect( + renderer?.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + expect(auth.sessionData?.access_token).toBe("initial") +} + +describe("AuthProvider token installation", () => { + const recoverDuringRefresh = async () => { + const Consumer = () => { + auth = useAuth() + return + } + const tree = () => ( + + + + + + ) + vi.mocked(fetch).mockImplementation((url) => + Promise.resolve( + url === "/token" + ? tokenResponse(initialToken) + : new Response(JSON.stringify({ ddl: "OK" })), + ), + ) + await act(async () => { + renderer = create(tree()) + await Promise.resolve() + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + const session = authSessionState.getAuthPayload()! + const generation = authSessionState.getSessionId() + vi.spyOn(Date, "now").mockReturnValue( + Date.parse(session.expires_at!) - 20_000, + ) + vi.mocked(fetch).mockClear() + let finishRefresh!: (response: Response) => void + const pending = new Promise((resolve) => { + finishRefresh = resolve + }) + vi.mocked(fetch).mockImplementation((url) => + url === "/token" + ? pending + : Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ) + const query = questClient + .queryRaw("SELECT 99") + .catch((error: unknown) => error) + await act(async () => { + await Promise.resolve() + }) + expect(tokenRefresh.hardDeadline).toBeDefined() + const requestSignal = vi.mocked(fetch).mock.lastCall?.[1]?.signal + + // Settings error/retry removes and replaces both providers without ending + // this tab's session or cancelling its in-flight rotating refresh. + act(() => renderer!.unmount()) + location.search = "" + await act(async () => { + renderer = create(tree()) + await Promise.resolve() + }) + expect(authSessionState.getAuthPayload()).toBe(session) + expect(authSessionState.getSessionId()).toBe(generation) + expect(requestSignal?.aborted).toBe(false) + expect(fetch).toHaveBeenCalledTimes(1) + return { finishRefresh, query } + } + + it.each(["failure while unmounted", "warning removed on unmount"])( + "restores the retained refresh warning after %s", + async (phase) => { + await mount() + if (phase === "warning removed on unmount") { + authSessionState.markRefreshFailed() + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + } + act(() => renderer!.unmount()) + authSessionState.markRefreshFailed() + eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED) + mocks.toastError.mockClear() + location.search = "" + await mount() + expect(mocks.toastError).toHaveBeenCalledOnce() + expect(mocks.toastError.mock.calls[0][1].toastId).toBe( + "sso-refresh-failed", + ) + }, + ) + + it("confirms interruption before signing in again and explains the ended work on Login", async () => { + await mount() + vi.spyOn(questClient, "hasActiveRequests").mockReturnValue(true) + vi.spyOn(questClient, "hasSentRequests").mockReturnValue(true) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining("statements may already have executed"), + ) + expect(authSessionState.isAuthenticated()).toBe(true) + confirm.mockReturnValue(true) + act(() => auth.signInAgain()) + const login = renderer!.root.findByProps({ "data-hook": "mock-login" }) + expect(login.props.errorMessage).toContain("Verify their outcomes") + expect(authSessionState.isAuthenticated()).toBe(false) + }) + + it("does not claim execution when signing in again cancels only unsent requests", async () => { + await mount() + // A refresh waiter is active work, but it never reached the server. + vi.spyOn(questClient, "hasActiveRequests").mockReturnValue(true) + vi.spyOn(questClient, "hasSentRequests").mockReturnValue(false) + const confirm = vi.fn(() => true) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledWith( + "Signing in again cancels requests that have not been sent yet. End this session?", + ) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toBe("Your previous session has ended. Please sign in again.") + expect(authSessionState.isAuthenticated()).toBe(false) + }) + + it("signs in again without a confirmation or warning while only background reads run", async () => { + await mount() + const fetchMock = vi.fn( + (_url: string, init?: RequestInit) => + new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + vi.stubGlobal("fetch", fetchMock) + const reads = [ + questClient.showTables(), + questClient.queryRaw("query_activity()", { background: true }), + ].map((read) => read.catch((error: unknown) => error)) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).not.toHaveBeenCalled() + const message = renderer!.root.findByProps({ "data-hook": "mock-login" }) + .props.errorMessage as string + expect(message).toBe( + "Your previous session has ended. Please sign in again.", + ) + expect(message).not.toContain("statements may have executed") + await Promise.all(reads) + }) + + it("keeps the SSO session that other tabs resume silently when signing in again", async () => { + // Given an SSO login, whose user the toolbar has stored + await mount() + localStorage.setItem("sso.username.test-client", "sso-user") + expect(localStorage.getItem(StoreKey.SSO_SESSION_ACTIVE)).toBe("true") + + // When this tab signs in again + act(() => auth.signInAgain()) + + // Then only this tab's session ends + expect(authSessionState.isAuthenticated()).toBe(false) + expect(localStorage.getItem(StoreKey.SSO_SESSION_ACTIVE)).toBe("true") + + // And another SSO tab that reloads still signs in silently + act(() => renderer!.unmount()) + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "sso") + location.search = "" + mocks.settings["acl.oidc.authorization.endpoint"] = + "https://identity.example/authorize" + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + expect(location.href).toContain("https://identity.example/authorize?") + expect(new URL(location.href).searchParams.get("prompt")).toBe("none") + }) + + it("confirms a real pending CSV upload with no SQL activity before signing in again, then lets it finish", async () => { + await mount() + expect(questClient.hasActiveRequests()).toBe(false) + let finishUpload!: () => void + const abortUpload = vi.fn() + class UploadRequest { + status = 200 + response = "{}" + abort = abortUpload + upload = { addEventListener: vi.fn() } + open = vi.fn() + setRequestHeader = vi.fn() + send = vi.fn(() => { + finishUpload = () => this.onload?.() + }) + onload?: () => void + } + vi.stubGlobal("XMLHttpRequest", UploadRequest) + const upload = questClient.uploadCSVFile({ + file: new File(["x\n1"], "probe.csv"), + name: "probe", + owner: "admin", + onProgress: vi.fn(), + }) + const rejected = expect(upload).rejects.toMatchObject({ + name: "SessionEndedError", + }) + await Promise.resolve() + await Promise.resolve() + expect(questClient.hasActiveRequests()).toBe(true) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledOnce() + expect(authSessionState.isAuthenticated()).toBe(true) + expect(abortUpload).not.toHaveBeenCalled() + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining( + "Uploads already being sent continue, but only while this tab stays open.", + ), + ) + confirm.mockReturnValue(true) + act(() => auth.signInAgain()) + const login = () => + renderer!.root.findByProps({ "data-hook": "mock-login" }) + expect(login().props.errorMessage).toContain("Verify their outcomes") + // Aborting mid-stream could leave a partially imported table. + expect(abortUpload).not.toHaveBeenCalled() + // The upload no longer counts as the next login's activity, but Login + // still reports it: leaving the page, even to sign in, would cancel it. + expect(questClient.hasActiveRequests()).toBe(false) + expect(login().props.unfinishedWrites).toBe(1) + await act(async () => { + finishUpload() + await rejected + }) + expect(login().props.unfinishedWrites).toBe(0) + expect(abortUpload).not.toHaveBeenCalled() + }) + + // Ends the session while a CSV upload is being sent, then signs in again + // with a password, which replaces Login without leaving the page. + const signInAgainWithPasswordDuringUpload = async () => { + const leaveListeners = new Set<(event: Event) => void>() + vi.stubGlobal( + "addEventListener", + vi.fn((type: string, listener: (event: Event) => void) => { + if (type === "beforeunload") leaveListeners.add(listener) + }), + ) + vi.stubGlobal( + "removeEventListener", + vi.fn((type: string, listener: (event: Event) => void) => { + if (type === "beforeunload") leaveListeners.delete(listener) + }), + ) + const leavePage = () => { + const event = { preventDefault: vi.fn(), returnValue: undefined } + leaveListeners.forEach((listener) => listener(event as unknown as Event)) + return event + } + await mount() + let finishUpload: (() => void) | undefined + class UploadRequest { + status = 200 + response = "{}" + abort = vi.fn() + upload = { addEventListener: vi.fn() } + open = vi.fn() + setRequestHeader = vi.fn() + send = vi.fn(() => { + finishUpload = () => this.onload?.() + }) + onload?: () => void + } + vi.stubGlobal("XMLHttpRequest", UploadRequest) + const upload = questClient.uploadCSVFile({ + file: new File(["x\n1"], "probe.csv"), + name: "probe", + owner: "admin", + onProgress: vi.fn(), + }) + const rejected = expect(upload).rejects.toMatchObject({ + name: "SessionEndedError", + }) + await vi.waitFor(() => expect(finishUpload).toBeDefined()) + vi.stubGlobal( + "confirm", + vi.fn(() => true), + ) + act(() => auth.signInAgain()) + const login = () => + renderer!.root.findByProps({ "data-hook": "mock-login" }) + expect(login().props.unfinishedWrites).toBe(1) + expect(leavePage().preventDefault).toHaveBeenCalledOnce() + // Login explains the upload itself. + expect(mocks.toastWarning).not.toHaveBeenCalled() + + const { onBasicAuthSuccess } = login().props as { + onBasicAuthSuccess: () => void + } + act(() => { + onBasicAuthSuccess() + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + return { + leavePage, + leaveListeners, + settleUpload: async () => { + await act(async () => { + finishUpload?.() + await rejected + }) + }, + } + } + + it("keeps asking before leaving, and warns in the console, after a password sign-in until a sent upload settles", async () => { + const { leavePage, leaveListeners, settleUpload } = + await signInAgainWithPasswordDuringUpload() + // Reloading or closing the tab would still cancel the upload. + const unload = leavePage() + expect(unload.preventDefault).toHaveBeenCalledOnce() + expect(unload.returnValue).toBe("") + expect(mocks.toastWarning).toHaveBeenCalledOnce() + const [content, options] = mocks.toastWarning.mock.calls[0] + expect(options).toEqual({ + autoClose: false, + closeOnClick: false, + toastId: UNFINISHED_WRITES_TOAST_ID, + }) + let warning!: ReactTestRenderer + act(() => { + warning = create(content) + }) + expect(textOf(warning.root)).toContain( + "Your previous session is still sending 1 request", + ) + expect(textOf(warning.root)).toContain( + "Importing the same file again meanwhile can import its rows twice.", + ) + + await settleUpload() + expect(leaveListeners.size).toBe(0) + expect(mocks.toastDismiss).toHaveBeenCalledWith(UNFINISHED_WRITES_TOAST_ID) + expect(textOf(warning.root)).toBe( + "Your previous session finished sending its requests.", + ) + act(() => warning.unmount()) + }) + + it("still reloads at once when logging out from the toolbar while an ended session's upload is sent", async () => { + const { leavePage, settleUpload } = + await signInAgainWithPasswordDuringUpload() + act(() => auth.logout({ reload: true, clearSSOSession: true })) + expect(mocks.reload).toHaveBeenCalledOnce() + // Asking could keep a console without a session: Login is not shown. + expect(leavePage().preventDefault).not.toHaveBeenCalled() + // Only signing in again asked: the console warns about the earlier + // session's upload, which this session's confirmation leaves out. + expect(vi.mocked(confirm)).toHaveBeenCalledOnce() + await settleUpload() + }) + + it("asks before logging out from the toolbar cancels this session's upload, and keeps the session if declined", async () => { + // Given a CSV upload of this session being sent + await mount() + let finishUpload!: () => void + const abortUpload = vi.fn() + class UploadRequest { + status = 200 + response = "{}" + abort = abortUpload + upload = { addEventListener: vi.fn() } + open = vi.fn() + setRequestHeader = vi.fn() + send = vi.fn(() => { + finishUpload = () => this.onload?.() + }) + onload?: () => void + } + vi.stubGlobal("XMLHttpRequest", UploadRequest) + const upload = questClient.uploadCSVFile({ + file: new File(["x\n1"], "probe.csv"), + name: "probe", + owner: "admin", + onProgress: vi.fn(), + }) + await vi.waitFor(() => expect(finishUpload).toBeDefined()) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + + // When the user declines logging out, which would reload the page + act(() => auth.logout({ reload: true, clearSSOSession: true })) + + // Then they were told the reload cancels the upload, and nothing changed + expect(confirm).toHaveBeenCalledWith( + expect.stringContaining("can leave a partially imported table"), + ) + expect(mocks.reload).not.toHaveBeenCalled() + expect(authSessionState.isAuthenticated()).toBe(true) + expect(questClient.hasActiveRequests()).toBe(true) + expect(abortUpload).not.toHaveBeenCalled() + + // When they confirm + confirm.mockReturnValue(true) + act(() => auth.logout({ reload: true, clearSSOSession: true })) + + // Then the page reloads at once + expect(confirm).toHaveBeenCalledTimes(2) + expect(mocks.reload).toHaveBeenCalledOnce() + expect(authSessionState.isAuthenticated()).toBe(false) + await act(async () => { + finishUpload() + await expect(upload).rejects.toMatchObject({ + name: "SessionEndedError", + }) + }) + }) + + it("says that logging out cancels only unsent requests when nothing was sent", async () => { + await mount() + vi.spyOn(questClient, "hasActiveRequests").mockReturnValue(true) + vi.spyOn(questClient, "hasSentRequests").mockReturnValue(false) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + + act(() => auth.logout({ reload: true, clearSSOSession: true })) + + expect(confirm).toHaveBeenCalledWith( + "Logging out cancels requests that have not been sent yet. Log out?", + ) + expect(mocks.reload).not.toHaveBeenCalled() + expect(authSessionState.isAuthenticated()).toBe(true) + }) + + it("confirms a real pending preferences write, lets it finish and explains its uncertainty on Login", async () => { + await mount() + expect(questClient.hasActiveRequests()).toBe(false) + let finish!: (response: Response) => void + const response = new Promise((resolve) => { + finish = resolve + }) + let writeSignal: AbortSignal | null | undefined + const fetchMock = vi.fn((url: string, init?: RequestInit) => { + if (url.startsWith("settings?")) { + writeSignal = init?.signal + return response + } + return Promise.resolve(new Response("{}")) + }) + vi.stubGlobal("fetch", fetchMock) + const write = questClient.savePreferences({ + version: 1, + instance_name: "Changed", + }) + const rejected = expect(write).rejects.toBeInstanceOf( + // The PUT may commit, but its old-session response must not be accepted. + SessionEndedError, + ) + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce()) + expect(questClient.hasActiveRequests()).toBe(true) + const confirm = vi.fn(() => false) + vi.stubGlobal("confirm", confirm) + act(() => auth.signInAgain()) + expect(confirm).toHaveBeenCalledOnce() + expect(authSessionState.isAuthenticated()).toBe(true) + expect(questClient.hasActiveRequests()).toBe(true) + expect(writeSignal?.aborted).toBe(false) + confirm.mockReturnValue(true) + act(() => auth.signInAgain()) + expect(writeSignal?.aborted).toBe(false) + expect(confirm).toHaveBeenCalledTimes(2) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toContain("Verify their outcomes") + expect(authSessionState.isAuthenticated()).toBe(false) + expect(questClient.hasActiveRequests()).toBe(false) + finish(new Response("{}")) + await rejected + }) + + it("shows the mounted login when a refresh started before recovery ends the session", async () => { + const { finishRefresh, query } = await recoverDuringRefresh() + await act(async () => { + finishRefresh( + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Revoked", + }), + { status: 400 }, + ), + ) + await query + }) + expect(authSessionState.isAuthenticated()).toBe(false) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props, + ).toMatchObject({ + errorTitle: "Your SSO session has expired", + errorMessage: "Revoked. Please sign in again.", + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(0) + }) + + it("keeps a rotated token when a refresh started before recovery succeeds", async () => { + const { finishRefresh, query } = await recoverDuringRefresh() + await act(async () => { + finishRefresh( + tokenResponse({ + ...initialToken, + access_token: "renewed", + refresh_token: "rotated", + }), + ) + await query + }) + expect(authSessionState.getAuthPayload()).toMatchObject({ + access_token: "renewed", + refresh_token: "rotated", + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer renewed", + }) + }) + + it.each([StoreKey.REST_TOKEN, StoreKey.BASIC_AUTH_HEADER])( + "resumes and refreshes retained SSO after recovery with another tab's %s", + async (credentialKey) => { + await mount() + const retained = auth.sessionData + const generation = authSessionState.getSessionId() + act(() => renderer?.unmount()) + localStorage.setItem(credentialKey, "another-tab") + location.search = "" + vi.mocked(fetch).mockImplementation((url) => + Promise.resolve( + url === "/token" + ? tokenResponse({ ...initialToken, access_token: "renewed" }) + : new Response(JSON.stringify({ ddl: "OK" })), + ), + ) + const Consumer = () => { + auth = useAuth() + return null + } + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(auth.sessionData).toBe(retained) + expect(authSessionState.getSessionId()).toBe(generation) + expect(questClient.refreshTokenMethod).toBeTypeOf("function") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer initial", + }) + + const expiry = Date.parse(retained!.expires_at!) + vi.spyOn(Date, "now").mockReturnValue(expiry + 1) + await act(async () => { + await questClient.queryRaw("select 1") + }) + expect(auth.sessionData?.access_token).toBe("renewed") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: "Bearer renewed", + }) + expect(localStorage.getItem(credentialKey)).toBe("another-tab") + expect(authSessionState.getSessionId()).toBe(generation) + }, + ) + + it.each([ + { + mode: "password", + key: StoreKey.REST_TOKEN, + value: "password-token", + authorization: "Bearer password-token", + }, + { + mode: "password", + key: StoreKey.BASIC_AUTH_HEADER, + value: "Basic password", + authorization: "Basic password", + }, + { + mode: "browser basic", + key: undefined, + value: undefined, + authorization: undefined, + }, + { + mode: "disabled ACL", + key: undefined, + value: undefined, + authorization: undefined, + }, + ])( + "ends retained SSO when settings recovery switches to $mode ($key)", + async ({ mode, key, value, authorization }) => { + await mount() + const generation = authSessionState.getSessionId() + const oldSession = authSessionState.capture() + act(() => renderer!.unmount()) + location.search = "" + mocks.settings = { + "acl.enabled": mode !== "disabled ACL", + "acl.oidc.enabled": false, + "acl.basic.auth.realm.enabled": mode === "browser basic", + } + if (key && value) localStorage.setItem(key, value) + vi.mocked(fetch).mockImplementation(() => + Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ) + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + expect(authSessionState.getSessionId()).toBeGreaterThan(generation) + expect(oldSession.isCurrent()).toBe(false) + expect(questClient.refreshTokenMethod).toBeUndefined() + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + if (key) expect(localStorage.getItem(key)).toBe(value) + await questClient.queryRaw("select 99") + const headers = vi.mocked(fetch).mock.lastCall?.[1]?.headers as Record< + string, + string + > + expect(headers.Authorization).toBe(authorization) + }, + ) + + it("cancels a retained rotating refresh when recovered settings disable ACL", async () => { + const { finishRefresh, query } = await recoverDuringRefresh() + const requestSignal = vi.mocked(fetch).mock.lastCall?.[1]?.signal + authSessionState.markRefreshFailed() + act(() => renderer!.unmount()) + mocks.settings = { "acl.enabled": false } + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(requestSignal?.aborted).toBe(true) + expect(tokenRefresh.hardDeadline).toBeUndefined() + expect(authSessionState.hasRefreshFailed()).toBe(false) + await act(async () => { + finishRefresh( + tokenResponse({ + ...initialToken, + access_token: "obsolete", + refresh_token: "obsolete", + }), + ) + await query + }) + expect(authSessionState.getAuthPayload()).toBeNull() + await questClient.queryRaw("select 99") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).not.toHaveProperty( + "Authorization", + ) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + }) + + it("clears the remembered identity before an asynchronous forced logout renders Login", async () => { + await mount() + localStorage.setItem("sso.username.test-client", "previous-user") + localStorage.setItem(StoreKey.OAUTH_REDIRECT_COUNT, "5") + // Deliberately outside act's outer batch: React 17 flushes the session-end + // listener synchronously when an asynchronous SQL 401 reaches logout. + await Promise.resolve().then(() => + eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED), + ) + expect(localStorage.getItem("sso.username.test-client")).toBeNull() + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props[ + "data-sso-username" + ], + ).toBeNull() + }) + + it("warns after expiry, avoids duplicates, and restores a dismissed warning", async () => { + await mount() + authSessionState.setAuthPayload({ + ...initialToken, + expires_at: new Date(Date.now() - 1).toISOString(), + }) + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledOnce() + const warning = create(mocks.toastError.mock.calls[0][0]) + expect(warning.root.findAllByType("span")[0].children).toEqual([ + expect.stringContaining("Queries are not being sent"), + ]) + mocks.toastActive.mockReturnValue(true) + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledOnce() + mocks.toastActive.mockReturnValue(false) + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledTimes(2) + act(() => { + ;( + warning.root.findByType("button").props as { onClick: () => void } + ).onClick() + }) + expect(authSessionState.isAuthenticated()).toBe(false) + warning.unmount() + }) + + it.each(["sso", "password"])( + "retains this tab's %s login choice on reload with shared credentials", + async (method) => { + location.search = "" + localStorage.setItem(StoreKey.REST_TOKEN, "password-tab-token") + localStorage.setItem("sso.username.test-client", "sso-user") + localStorage.setItem(StoreKey.SSO_SESSION_ACTIVE, "true") + sessionStorage.setItem(StoreKey.LOGIN_METHOD, method) + mocks.settings["acl.oidc.authorization.endpoint"] = + "https://identity.example/authorize" + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe( + "password-tab-token", + ) + if (method === "sso") { + expect(location.href).toContain("https://identity.example/authorize?") + expect(new URL(location.href).searchParams.get("prompt")).toBe("none") + } else { + expect(location.href).toBe("http://localhost/?code=test") + expect( + renderer!.root.findAllByProps({ "data-hook": "live" }), + ).toHaveLength(1) + expect(authSessionState.isSSOAuthenticated()).toBe(false) + } + }, + ) + + it("preserves another tab's password credentials on both sign-in and refresh", async () => { + localStorage.setItem(StoreKey.REST_TOKEN, "stale") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic stale") + await mount() + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe("stale") + expect(localStorage.getItem(StoreKey.BASIC_AUTH_HEADER)).toBe("Basic stale") + expect(sessionStorage.getItem(StoreKey.LOGIN_METHOD)).toBe("sso") + localStorage.setItem(StoreKey.REST_TOKEN, "another-tab") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic another-tab") + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ access_token: "renewed", expires_in: 3600 }), + ) + await act(async () => { + const result = await refreshToken() + expect(result?.access_token).toBe("renewed") + // A refresh reply that doesn't rotate the token keeps the original. + expect(result?.refresh_token).toBe("initial-refresh") + }) + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe("another-tab") + expect(localStorage.getItem(StoreKey.BASIC_AUTH_HEADER)).toBe( + "Basic another-tab", + ) + }) + + it("returns no token when the server requires an ID token omitted on refresh", async () => { + mocks.settings["acl.oidc.groups.encoded.in.token"] = true + vi.spyOn(console, "warn").mockImplementation(() => {}) + await mount() + const body = { access_token: "rejected", expires_in: 3600 } + vi.mocked(fetch).mockResolvedValueOnce(tokenResponse(body)) + await act(async () => { + await expect(refreshToken()).resolves.toBeUndefined() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + expect( + renderer?.root.findByProps({ "data-hook": "mock-login" }).props + .errorTitle, + ).toBe("Missing ID token") + expect(body).not.toHaveProperty("groups_encoded_in_token") + }) + + it.each([false, true])( + "synchronizes refresh teardown on logout (reload=%s)", + async (reload) => { + await mount() + const session = authSessionState.getAuthPayload() + if (!isRefreshable(session)) + throw new Error("Expected refreshable session") + const request = new AbortController() + let requestSignal: AbortSignal | undefined + tokenRefresh.syncSession() + tokenRefresh.start(session, (_deadline, signal) => { + requestSignal = signal + return new Promise((resolve) => + request.signal.addEventListener("abort", () => resolve(undefined)), + ) + }) + await Promise.resolve() + const wait = tokenRefresh.softDeadline + expect(wait).toBeDefined() + const sync = vi.spyOn(tokenRefresh, "syncSession") + act(() => auth.logout({ reload })) + expect(sync).toHaveBeenCalledOnce() + expect(mocks.reload).toHaveBeenCalledTimes(reload ? 1 : 0) + expect(authSessionState.getAuthPayload()).toBeNull() + expect(tokenRefresh.softDeadline).toBeUndefined() + expect(requestSignal?.aborted).toBe(true) + request.abort() + await wait + }, + ) + + it("ignores an initial code response after the mounted owner's session ends", async () => { + let finish!: (response: Response) => void + vi.mocked(fetch).mockImplementation( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + expect(finish).toBeDefined() + act(() => authSessionState.endSession()) + await act(async () => { + finish(tokenResponse(initialToken)) + await Promise.resolve() + }) + expect(authSessionState.isAuthenticated()).toBe(false) + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(1) + expect(fetch).toHaveBeenCalledOnce() + }) + + it("ignores a late success after logout instead of reinstalling the session", async () => { + await mount() + let resolve!: (response: Response) => void + vi.mocked(fetch).mockImplementationOnce( + () => + new Promise((done) => { + resolve = done + }), + ) + const refresh = refreshToken() + act(() => auth.logout()) + await act(async () => { + resolve(tokenResponse({ ...initialToken, access_token: "late" })) + await expect(refresh).resolves.toBeUndefined() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + }) + + it("keeps a late rotated token after the wait deadline without aborting fetch", async () => { + await mount() + const deadline = new AbortController() + deadline.abort() + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ + ...initialToken, + access_token: "late", + refresh_token: "rotated", + }), + ) + await act(async () => { + await expect(refreshToken(deadline.signal)).resolves.toMatchObject({ + refresh_token: "rotated", + }) + }) + const refreshCall = vi + .mocked(fetch) + .mock.calls.find( + ([url, init]) => + url === "/token" && + init?.body instanceof URLSearchParams && + init.body.get("grant_type") === "refresh_token", + ) + expect(refreshCall?.[1]?.signal?.aborted).toBe(false) + expect(authSessionState.getAuthPayload()?.refresh_token).toBe("rotated") + }) + + it.each([429, 500, 503])( + "keeps the session on JSON HTTP %s refresh failure", + async (status) => { + await mount() + const session = authSessionState.getAuthPayload() + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "temporarily_unavailable" }), { + status, + }), + ) + await expect(refreshToken()).rejects.toThrow( + `Identity provider unavailable (HTTP ${status})`, + ) + expect(authSessionState.getAuthPayload()).toBe(session) + expect( + renderer?.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(0) + }, + ) + + it.each(["response", "body"])( + "preserves write uncertainty when automatic renewal logs out during the SQL %s", + async (phase) => { + await mount() + vi.mocked(fetch).mockClear() + let writeSignal: AbortSignal | null | undefined + const readBody = vi.fn() + vi.mocked(fetch).mockImplementation((url, init) => { + if (init?.keepalive) return Promise.resolve(new Response("{}")) + if (url === "/token") { + return Promise.resolve( + new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Revoked", + }), + { status: 400 }, + ), + ) + } + writeSignal = init?.signal + const pending = new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + if (phase === "body") { + return Promise.resolve({ + ok: true, + status: 200, + text: () => { + readBody() + return pending + }, + } as Response) + } + return pending.then(() => new Response("{}")) + }) + questClient.refreshTokenMethod = (deadline, request) => + auth.refreshAuthToken( + mocks.settings, + "initial-refresh", + deadline, + request, + ) + const write = questClient + .queryRaw("INSERT INTO trades VALUES (1)") + .catch((error: unknown) => error) + await vi.waitFor(() => { + expect(questClient.hasSentRequests()).toBe(true) + if (phase === "body") expect(readBody).toHaveBeenCalledOnce() + }) + vi.spyOn(Date, "now").mockReturnValue( + Date.parse(authSessionState.getAuthPayload()!.expires_at!) - 20_000, + ) + await act(async () => { + await expect(questClient.queryRaw("tables()")).rejects.toMatchObject({ + sessionEnded: true, + requestNotSent: true, + }) + expect(await write).toMatchObject({ sessionEnded: true }) + }) + expect(writeSignal?.aborted).toBe(true) + expect(questClient.hasSentRequests()).toBe(false) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props, + ).toMatchObject({ + errorTitle: "Your SSO session has expired", + }) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toBe( + "Revoked. Please sign in again. Running requests were interrupted or may still be finishing; statements may have executed. Verify their outcomes before running them again.", + ) + expect( + vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + typeof url === "string" && + url.startsWith("exec?") && + !init?.keepalive, + ), + ).toHaveLength(1) + }, + ) + + it.each([0, 1])( + "does not warn about execution when renewal fails after %s definitively unexecuted SQL requests", + async (requests) => { + await mount() + if (!requests) { + vi.spyOn(Date, "now").mockReturnValue( + Date.parse(authSessionState.getAuthPayload()!.expires_at!) - 20_000, + ) + } + vi.mocked(fetch).mockClear() + vi.mocked(fetch).mockImplementation((url) => + Promise.resolve( + url === "/token" + ? new Response( + JSON.stringify({ + error: "invalid_grant", + error_description: "Revoked", + }), + { status: 400 }, + ) + : new Response("", { status: 401 }), + ), + ) + questClient.refreshTokenMethod = (deadline, request) => + auth.refreshAuthToken( + mocks.settings, + "initial-refresh", + deadline, + request, + ) + await act(async () => { + await expect( + questClient.queryRaw("INSERT INTO trades VALUES (1)"), + ).rejects.toMatchObject({ sessionEnded: true, requestNotSent: true }) + }) + expect( + renderer!.root.findByProps({ "data-hook": "mock-login" }).props + .errorMessage, + ).toBe("Revoked. Please sign in again.") + expect( + vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + typeof url === "string" && + url.startsWith("exec?") && + !init?.keepalive, + ), + ).toHaveLength(requests) + expect(questClient.hasSentRequests()).toBe(false) + }, + ) + + it.each([false, true])( + "warns after an HTTP permission rejection only if another write is in flight: %s", + async (otherWrite) => { + await mount() + vi.mocked(fetch).mockImplementation((url, init) => { + if (init?.keepalive) return Promise.resolve(new Response("{}")) + if (typeof url === "string" && url.includes("pending_write")) { + return new Promise((_, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }) + } + return Promise.resolve( + new Response( + JSON.stringify({ error: "Access denied to protocol [HTTP]" }), + { status: 403 }, + ), + ) + }) + const pending = otherWrite + ? questClient + .queryRaw("insert into pending_write values (1)") + .catch((error: unknown) => error) + : undefined + if (otherWrite) { + await vi.waitFor(() => expect(questClient.hasSentRequests()).toBe(true)) + } + await act(async () => { + await expect( + questClient.queryRaw("insert into trades values (1)"), + ).rejects.toMatchObject({ sessionEnded: true, requestNotSent: true }) + if (pending) expect(await pending).toMatchObject({ sessionEnded: true }) + }) + const message = renderer!.root.findByProps({ "data-hook": "mock-login" }) + .props.errorMessage as string + expect(message).toContain("You are not authorized to use the Web Console") + expect(message.includes("statements may have executed")).toBe(otherWrite) + expect(questClient.hasSentRequests()).toBe(false) + }, + ) + + it("ends the session for an explicit invalid_grant refresh error", async () => { + await mount() + vi.mocked(fetch).mockResolvedValueOnce( + new Response(JSON.stringify({ error: "invalid_grant" }), { status: 400 }), + ) + await act(async () => { + await expect(refreshToken()).resolves.toBeUndefined() + }) + expect(authSessionState.getAuthPayload()).toBeNull() + }) + + it("discards an aborted request's late body even before logout", async () => { + await mount() + const session = authSessionState.getAuthPayload() + const network = new AbortController() + const deadline = new AbortController() + let finishBody!: (body: TokenResponse) => void + const body = new Promise((resolve) => { + finishBody = resolve + }) + vi.mocked(fetch).mockResolvedValueOnce({ + status: 200, + json: () => body, + } as Response) + const result = auth.refreshAuthToken( + mocks.settings, + "initial-refresh", + deadline.signal, + network.signal, + ) + await Promise.resolve() + network.abort() + finishBody({ + ...initialToken, + access_token: "obsolete", + refresh_token: "obsolete", + }) + await expect(result).resolves.toBeUndefined() + expect(authSessionState.getAuthPayload()).toBe(session) + expect(vi.mocked(fetch).mock.lastCall?.[1]?.signal).toBe(network.signal) + }) + + it("keeps public callbacks stable after successful refresh", async () => { + await mount() + const before = auth + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ ...initialToken, access_token: "renewed" }), + ) + await act(async () => { + await refreshToken() + }) + expect(auth.refreshAuthToken).toBe(before.refreshAuthToken) + expect(auth.logout).toBe(before.logout) + expect(auth.redirectToAuthorizationUrl).toBe( + before.redirectToAuthorizationUrl, + ) + }) + + it("shows a persistent consequence-aware warning and dismisses it on success or logout", async () => { + await mount() + authSessionState.markRefreshFailed() + act(() => eventBus.publish(EventType.MSG_AUTH_REFRESH_FAILED)) + expect(mocks.toastError).toHaveBeenCalledWith(expect.any(Object), { + autoClose: false, + closeOnClick: false, + toastId: "sso-refresh-failed", + }) + const warning = create(mocks.toastError.mock.calls[0][0]) + expect(warning.root.findAllByType("span")[0].children).toEqual([ + expect.stringContaining("expires at"), + ]) + expect(warning.root.findByType("button").children).toEqual([ + "Sign in again", + ]) + expect(warning.root.findAllByType("span")[1].children).toEqual([ + expect.stringContaining("interrupts running queries"), + ]) + warning.unmount() + mocks.toastDismiss.mockClear() + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ ...initialToken, access_token: "renewed" }), + ) + await act(async () => { + await refreshToken() + }) + expect(mocks.toastDismiss).toHaveBeenCalledWith("sso-refresh-failed") + expect(authSessionState.hasRefreshFailed()).toBe(false) + mocks.toastDismiss.mockClear() + act(() => auth.logout()) + expect(mocks.toastDismiss).toHaveBeenCalledWith("sso-refresh-failed") + }) +}) + +describe.each([ + { + key: StoreKey.REST_TOKEN, + value: "recovered-password", + authorization: "Bearer recovered-password", + }, + { + key: StoreKey.BASIC_AUTH_HEADER, + value: "Basic recovered-password", + authorization: "Basic recovered-password", + }, +])( + "AuthProvider initial exchange ownership ($key)", + ({ key, value, authorization }) => { + it.each(["success", "OAuth error", "network error"])( + "ignores late %s after Settings Retry recovery and password login", + async (outcome) => { + mocks.useRealSettings = true + mocks.settings["acl.oidc.state.required"] = true + location.search = "?code=test&state=expected" + localStorage.setItem(StoreKey.OAUTH_STATE, "expected") + vi.stubGlobal("history", { + replaceState: (_state: unknown, _title: string, url: string) => { + location.search = new URL(url, "http://localhost").search + }, + }) + let settingsCalls = 0 + const settingsGates: Array<(response: Response) => void> = [] + let finishCode!: (response: Response) => void + let rejectCode!: (error: Error) => void + const config = () => + new Response( + JSON.stringify({ + config: mocks.settings, + preferences: {}, + "preferences.version": 1, + }), + ) + vi.mocked(fetch).mockImplementation((url) => { + if (url === "settings") { + settingsCalls++ + if (settingsCalls === 1) + return Promise.resolve(new Response("", { status: 503 })) + if (settingsCalls === 2 || settingsCalls === 3) + return new Promise((resolve) => settingsGates.push(resolve)) + return Promise.resolve(config()) + } + if (url === "warnings") return Promise.resolve(new Response("[]")) + if (url === "assets/console-configuration.json") + return Promise.resolve(new Response("{}")) + if (url === "/token") + return new Promise((resolve, reject) => { + finishCode = resolve + rejectCode = reject + }) + return Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))) + }) + const Consumer = () => { + auth = useAuth() + return + } + let reloadSettings!: ReturnType< + typeof useSettings + >["refreshSettingsAndPreferences"] + const SettingsDriver = () => { + reloadSettings = useSettings().refreshSettingsAndPreferences + return null + } + await act(async () => { + renderer = create( + + + + + + + + + + , + ) + await Promise.resolve() + }) + const retry = () => + renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Retry"))! + // A later settings refresh can still remove the Auth owner while its + // initial code exchange is pending. + await act(async () => { + retry().props.onClick() + await Promise.resolve() + }) + expect(settingsGates).toHaveLength(1) + await act(async () => { + settingsGates.shift()!(config()) + await Promise.resolve() + }) + expect(finishCode).toBeDefined() + expect(location.search).toBe("") + let settingsRefresh!: Promise + await act(async () => { + settingsRefresh = reloadSettings() + await Promise.resolve() + }) + await act(async () => { + settingsGates.shift()!(new Response("", { status: 503 })) + await settingsRefresh + await Promise.resolve() + }) + await act(async () => { + retry().props.onClick() + await Promise.resolve() + }) + const login = renderer!.root.findByProps({ "data-hook": "mock-login" }) + await act(async () => { + localStorage.setItem(key, value) + login.props.onBasicAuthSuccess() + await Promise.resolve() + }) + const recoveredSession = authSessionState.capture() + const callsBefore = vi.mocked(fetch).mock.calls.length + await act(async () => { + if (outcome === "network error") rejectCode(new Error("Offline")) + else + finishCode( + outcome === "success" + ? tokenResponse(initialToken) + : new Response(JSON.stringify({ error: "invalid_grant" }), { + status: 400, + }), + ) + await Promise.resolve() + }) + expect(localStorage.getItem(key)).toBe(value) + expect(authSessionState.getAuthPayload()).toBeNull() + expect(recoveredSession.isCurrent()).toBe(true) + expect(fetch).toHaveBeenCalledTimes(callsBefore) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + await questClient.queryRaw("SELECT 987") + expect(vi.mocked(fetch).mock.lastCall?.[1]?.headers).toMatchObject({ + Authorization: authorization, + }) + }, + ) + }, +) + +describe("AuthProvider password login after settings recovery", () => { + it.each([ + { server: "a password-only", oidc: false }, + { server: "an OIDC", oidc: true }, + ])( + "ends the login before showing Login when another tab removed its credentials, on $server server", + async ({ oidc }) => { + // Given a password login in this tab, with a statement still running + location.search = "" + mocks.settings["acl.oidc.enabled"] = oidc + localStorage.setItem(StoreKey.REST_TOKEN, "first-user") + vi.mocked(fetch).mockImplementation( + (_url, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => + reject(new DOMException("Aborted", "AbortError")), + ) + }), + ) + const providers = () => ( + + + + ) + await act(async () => { + renderer = create(providers()) + await Promise.resolve() + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + const firstLogin = authSessionState.capture() + const statement = questClient + .queryRaw("INSERT INTO trades VALUES (1)") + .catch((error: unknown) => error) + await vi.waitFor(() => expect(questClient.hasSentRequests()).toBe(true)) + const ended = vi.fn() + const stopListening = authSessionState.onEnd(ended) + try { + // When a failed settings refresh unmounts the console, another tab + // logs out, and Settings Retry mounts the providers again + act(() => renderer!.unmount()) + localStorage.removeItem(StoreKey.REST_TOKEN) + await act(async () => { + renderer = create(providers()) + await Promise.resolve() + }) + + // Then Login replaces the console only after the login has ended, so + // the statement can no longer complete into the next login + const login = renderer!.root.findByProps({ "data-hook": "mock-login" }) + expect(ended).toHaveBeenCalledOnce() + expect(firstLogin.isCurrent()).toBe(false) + expect(authSessionState.isAuthenticated()).toBe(false) + expect(await statement).toMatchObject({ sessionEnded: true }) + expect(login.props.errorMessage).toContain( + "Running requests were interrupted", + ) + + // And the next password sign-in starts a session of its own + const atLogin = authSessionState.capture() + await act(async () => { + localStorage.setItem(StoreKey.REST_TOKEN, "second-user") + ;( + login.props as { onBasicAuthSuccess: () => void } + ).onBasicAuthSuccess() + await Promise.resolve() + }) + expect(authSessionState.isAuthenticated()).toBe(true) + expect(atLogin.isCurrent()).toBe(false) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + } finally { + stopListening() + } + }, + ) +}) + +describe("AuthProvider logout and the password credentials tabs share", () => { + const storePasswordLogin = () => { + localStorage.setItem(StoreKey.REST_TOKEN, "password-tab") + localStorage.setItem(StoreKey.BASIC_AUTH_HEADER, "Basic password-tab") + } + const consoleShown = () => + renderer!.root.findAllByProps({ "data-hook": "console-live" }).length > 0 + const reload = async () => { + act(() => renderer!.unmount()) + location.search = "" + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + } + + it.each([ + { + exit: "signs in again", + end: () => auth.signInAgain(), + resumesSSO: true, + }, + { + exit: "is signed out by a 401", + end: () => eventBus.publish(EventType.MSG_CONNECTION_UNAUTHORIZED), + resumesSSO: true, + }, + { + exit: "logs out from the toolbar", + end: () => auth.logout({ reload: true, clearSSOSession: true }), + resumesSSO: false, + }, + ])( + "keeps another tab's password login when an SSO tab $exit, and does not take it over on reload", + async ({ end, resumesSSO }) => { + // Given an SSO login in this tab, and a password login in another + await mount() + localStorage.setItem("sso.username.test-client", "sso-user") + storePasswordLogin() + + // When this tab's session ends + act(() => end()) + + // Then the other tab stays signed in + expect(authSessionState.isAuthenticated()).toBe(false) + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBe("password-tab") + expect(localStorage.getItem(StoreKey.BASIC_AUTH_HEADER)).toBe( + "Basic password-tab", + ) + + // And reloading this tab resumes its SSO session if it can, or shows + // Login, without signing in with the other tab's credentials + mocks.settings["acl.oidc.authorization.endpoint"] = + "https://identity.example/authorize" + await reload() + expect(consoleShown()).toBe(false) + expect(authSessionState.isAuthenticated()).toBe(false) + if (resumesSSO) { + expect(location.href).toContain("https://identity.example/authorize?") + expect(new URL(location.href).searchParams.get("prompt")).toBe("none") + } else { + expect(location.href).toBe("http://localhost/?code=test") + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(1) + } + }, + ) + + it("removes the password credentials when a password login logs out", async () => { + // Given a password login in this tab + location.search = "" + storePasswordLogin() + const Consumer = () => { + auth = useAuth() + return + } + await act(async () => { + renderer = create( + + + , + ) + await Promise.resolve() + }) + expect(consoleShown()).toBe(true) + expect(sessionStorage.getItem(StoreKey.LOGIN_METHOD)).toBe("password") + + // When it logs out + act(() => auth.logout()) + + // Then they are removed with the login, and reloading shows Login + expect(localStorage.getItem(StoreKey.REST_TOKEN)).toBeNull() + expect(localStorage.getItem(StoreKey.BASIC_AUTH_HEADER)).toBeNull() + expect(sessionStorage.getItem(StoreKey.LOGIN_METHOD)).toBeNull() + await reload() + expect(consoleShown()).toBe(false) + expect( + renderer!.root.findAllByProps({ "data-hook": "mock-login" }), + ).toHaveLength(1) + }) +}) + +describe("AuthProvider bearer expiry", () => { + it.each([0, "0"])( + "renews a zero-lifetime response (%j) before sending SQL", + async (expiresIn) => { + vi.mocked(fetch).mockImplementation((url, init) => { + const grant = (init?.body as URLSearchParams | undefined)?.get( + "grant_type", + ) + return Promise.resolve( + url === "/token" + ? new Response( + JSON.stringify({ + ...initialToken, + expires_in: expiresIn, + ...(grant === "refresh_token" + ? { + access_token: "renewed", + refresh_token: "rotated", + expires_in: 3600, + } + : {}), + }), + ) + : new Response(JSON.stringify({ ddl: "OK" })), + ) + }) + const Consumer = () => { + auth = useAuth() + return + } + await act(async () => { + renderer = create( + + + + + , + ) + await Promise.resolve() + }) + expect(auth.sessionData).toMatchObject({ + access_token: "renewed", + refresh_token: "rotated", + }) + const refreshes = vi + .mocked(fetch) + .mock.calls.filter( + ([url, init]) => + url === "/token" && + (init?.body as URLSearchParams).get("grant_type") === + "refresh_token", + ) + expect(refreshes).toHaveLength(1) + expect( + (refreshes[0][1]?.body as URLSearchParams).get("refresh_token"), + ).toBe("initial-refresh") + const sqlCalls = vi + .mocked(fetch) + .mock.calls.filter( + ([url]) => + typeof url === "string" && + url.startsWith("exec?") && + !url.includes("session=true"), + ) + expect(sqlCalls.length).toBeGreaterThan(0) + for (const [, init] of sqlCalls) + expect(init?.headers).toMatchObject({ Authorization: "Bearer renewed" }) + expect( + renderer!.root.findAllByProps({ "data-hook": "console-live" }), + ).toHaveLength(1) + }, + ) + + it("installs an ID token bearer without expires_in at sign-in and on refresh", async () => { + // OAuth only recommends expires_in, the access token's lifetime. + mocks.settings["acl.oidc.groups.encoded.in.token"] = true + const signInExpiry = Math.floor(Date.now() / 1000) + 600 + signInToken = { + ...initialToken, + id_token: idToken(signInExpiry), + expires_in: undefined, + } + await mount() + expect(auth.sessionData?.expires_at).toBe( + new Date(signInExpiry * 1000).toISOString(), + ) + + const refreshExpiry = signInExpiry + 600 + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ + access_token: "renewed", + id_token: idToken(refreshExpiry), + token_type: "Bearer", + }), + ) + await act(async () => { + await expect(refreshToken()).resolves.toMatchObject({ + id_token: idToken(refreshExpiry), + expires_at: new Date(refreshExpiry * 1000).toISOString(), + }) + }) + expect(authSessionState.getAuthPayload()?.id_token).toBe( + idToken(refreshExpiry), + ) + }) + + it.each([ + { bearer: "access token", groupsInToken: false, id_token: "initial-id" }, + { bearer: "ID token", groupsInToken: true, id_token: "not-a-jwt" }, + ])( + "signs in with an $bearer bearer of unknown expiry, leaving it unscheduled", + async ({ groupsInToken, id_token }) => { + // A provider without lifetimes wants the token used until it is rejected. + mocks.settings["acl.oidc.groups.encoded.in.token"] = groupsInToken + // A provider's extra field is deliberately outside TokenResponse. + const wireResponse = { + ...initialToken, + id_token, + expires_in: undefined, + expires_at: "2000-01-01T00:00:00.000Z", + } + signInToken = wireResponse + await mount() + expect(auth.sessionData?.expires_at).toBeUndefined() + expect(authSessionState.getAuthPayload()?.expires_at).toBeUndefined() + }, + ) + + it("installs a refreshed token of unknown expiry", async () => { + await mount() + vi.mocked(fetch).mockResolvedValueOnce( + tokenResponse({ access_token: "renewed", token_type: "Bearer" }), + ) + await act(async () => { + await expect(refreshToken()).resolves.toMatchObject({ + access_token: "renewed", + expires_at: undefined, + }) + }) + expect(authSessionState.getAuthPayload()?.access_token).toBe("renewed") + expect(authSessionState.getAuthPayload()?.expires_at).toBeUndefined() + }) +}) + +describe("AuthProvider cookie session at sign-in", () => { + const startSession = "exec?query=select 2&session=true" + const asksForSession = ([url]: unknown[]) => + url !== startSession && + String(url).startsWith("exec?") && + new URLSearchParams(String(url).split("?")[1]).get("session") === "true" + + it.each([ + ["is rejected", () => Promise.resolve(new Response("", { status: 401 }))], + ["cannot be sent", () => Promise.reject(new TypeError("Failed to fetch"))], + [ + "opens it", + () => Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ], + ] as const)( + "asks the next SQL request for a session only if the sign-in request %s", + async (outcome, answer) => { + // Given a sign-in whose own session request gets this answer + vi.mocked(fetch).mockImplementation((url) => + url === "/token" + ? Promise.resolve(tokenResponse(initialToken)) + : url === startSession + ? answer() + : Promise.resolve(new Response(JSON.stringify({ ddl: "OK" }))), + ) + await mount() + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + expect(fetch).toHaveBeenCalledWith(startSession, expect.anything()) + + // When the console sends its next SQL request + await act(async () => { + await questClient.queryRaw("SELECT 99") + }) + + // Then it asks for the session that downloads need, unless one was opened + expect(vi.mocked(fetch).mock.calls.filter(asksForSession)).toHaveLength( + outcome === "opens it" ? 0 : 1, + ) + expect(authSessionState.isSSOAuthenticated()).toBe(true) + }, + ) +}) diff --git a/src/providers/AuthProvider.tsx b/src/providers/AuthProvider.tsx index bbb86c27d..11342ba44 100644 --- a/src/providers/AuthProvider.tsx +++ b/src/providers/AuthProvider.tsx @@ -2,18 +2,30 @@ import React, { createContext, useContext, useEffect, + useMemo, useReducer, + useRef, useState, } from "react" -import { AuthPayload } from "../modules/OAuth2/types" +import { unstable_batchedUpdates } from "react-dom" +import { + AuthPayload, + LogoutOptions, + TokenResponse, +} from "../modules/OAuth2/types" import { StoreKey } from "../utils/localStorage/types" import { getValue, removeValue, setValue } from "../utils/localStorage" import { getAuthorisationURL, getAuthToken, + exchangeAuthorizationCode, + readAuthTokenResponse, getSSOUserNameWithClientID, - getTokenExpirationDate, + buildInstalledPayload, + getBearerAuthorization, removeSSOUserNameWithClientID, + GrantType, + SIGN_IN_ERROR_TITLE, } from "../modules/OAuth2/utils" import { generateCodeChallenge, @@ -26,29 +38,27 @@ import { ErrorResult } from "../utils" import { Login } from "../modules/OAuth2/views/login" import { Settings } from "./SettingsProvider/types" import { useSettings } from "./SettingsProvider" -import { ssoAuthState } from "../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../modules/OAuth2/authSessionState" +import { handleSSOUnauthorized } from "../modules/OAuth2/handleUnauthorized" +import { toast, REFRESH_FAILED_TOAST_ID } from "../components/Toast" +import { questClient } from "../utils/questdb/queryClient" +import { RefreshFailedWarning } from "../modules/OAuth2/views/refreshFailedWarning" +import { + UNFINISHED_WRITES_TOAST_ID, + UnfinishedWritesWarning, +} from "../modules/OAuth2/views/unfinishedWritesWarning" +import { useEndedSessionWriteCount } from "../hooks/useEndedSessionWriteCount" type ContextProps = { - sessionData?: Partial - logout: ({ - promptForLogin, - errorTitle, - errorMessage, - isDisconnection, - reload, - clearSSOSession, - }?: { - promptForLogin?: boolean - errorTitle?: string - errorMessage?: string - isDisconnection?: boolean - reload?: boolean - clearSSOSession?: boolean - }) => void + sessionData?: AuthPayload + logout: (options?: LogoutOptions) => void + signInAgain: () => void refreshAuthToken: ( settings: Settings, refreshToken: string | undefined, - ) => Promise + deadlineSignal: AbortSignal, + requestSignal: AbortSignal, + ) => Promise redirectToAuthorizationUrl: (options?: { prompt?: "login" | "none" }) => void } @@ -77,7 +87,8 @@ const initialState: { const defaultValues: ContextProps = { sessionData: undefined, logout: () => {}, - refreshAuthToken: () => Promise.resolve({} as AuthPayload), + signInAgain: () => {}, + refreshAuthToken: () => Promise.resolve(undefined), redirectToAuthorizationUrl: () => {}, } @@ -106,72 +117,195 @@ const stripOAuthCallbackParams = (search: string) => { return params } +// Replaces the callback params with the ones saved before the redirect to the +// provider, whatever the outcome of the sign-in. Otherwise a reload resubmits +// the used code, and its invalid_grant error replaces the actual sign-in error. +const restoreUrlAfterOAuthCallback = () => { + if (!history.replaceState) return + const restored = stripOAuthCallbackParams(window.location.search) + const savedParams = sessionStorage.getItem(StoreKey.OAUTH_RETURN_PARAMS) + sessionStorage.removeItem(StoreKey.OAUTH_RETURN_PARAMS) + if (savedParams) { + new URLSearchParams(savedParams).forEach((value, key) => { + if (!restored.has(key)) restored.set(key, value) + }) + } + const queryString = restored.toString() + history.replaceState( + null, + "", + location.pathname + (queryString ? `?${queryString}` : ""), + ) +} + const getDefaultRedirectUri = () => window.location.origin + window.location.pathname +const INTERRUPTED_REQUESTS_MESSAGE = + "Running requests were interrupted or may still be finishing; statements may have executed. Verify their outcomes before running them again." + +// Capture uncertainty before session-end listeners abort and forget work. +// Sent queries are aborted, but uploads already being sent still finish. +const explainInterruptedRequests = (options: LogoutOptions): LogoutOptions => + questClient.hasSentRequests() + ? { + ...options, + errorMessage: [options.errorMessage, INTERRUPTED_REQUESTS_MESSAGE] + .filter(Boolean) + .join(" "), + } + : options + +// Logging out from the toolbar reloads the page at once, which cancels this +// session's running work, including, unlike signing in again, uploads already +// being sent. Ask before the session ends, so that declining keeps it. +const confirmReloadingLogout = () => + !questClient.hasActiveRequests() || + window.confirm( + questClient.hasSentRequests() + ? "Logging out reloads the page and cancels everything still running: statements may already have executed, and a cancelled CSV upload can leave a partially imported table. Log out?" + : "Logging out cancels requests that have not been sent yet. Log out?", + ) + export const AuthContext = createContext(defaultValues) -const reducer = (s: State, n: Partial) => ({ ...s, ...n }) +const reducer = (s: State, n: Partial) => + Object.entries(n).every(([key, value]) => s[key as keyof State] === value) + ? s + : { ...s, ...n } export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const { settings } = useSettings() + const callbacks = useRef< + Omit & { + redirectToAuthorizationUrl: (options?: { + prompt?: "login" | "none" + isRetry?: boolean + }) => void + } + >(defaultValues) const [sessionData, setSessionData] = useState(undefined) const [state, dispatch] = useReducer(reducer, initialState) + // Uploads and settings saves keep running after their session ends, until + // they settle. Leaving the page cancels them, so ask before leaving in every + // view: signing in again with a password replaces Login without leaving it. + const unfinishedWrites = useEndedSessionWriteCount() + const hasUnfinishedWrites = unfinishedWrites > 0 + // Logging out from the toolbar still reloads at once. + const reloadingRef = useRef(false) + useEffect(() => { + if (!hasUnfinishedWrites) return + const confirmLeave = (event: BeforeUnloadEvent) => { + if (reloadingRef.current) return + event.preventDefault() + // Browsers without preventDefault() support only prompt for this. + event.returnValue = "" + } + window.addEventListener("beforeunload", confirmLeave) + return () => window.removeEventListener("beforeunload", confirmLeave) + }, [hasUnfinishedWrites]) + // Login explains them itself. After signing in again, the console does. + const isReady = state.view === View.ready + useEffect(() => { + if (!isReady || !hasUnfinishedWrites) return + toast.warning(, { + autoClose: false, + closeOnClick: false, + toastId: UNFINISHED_WRITES_TOAST_ID, + }) + return () => toast.dismiss(UNFINISHED_WRITES_TOAST_ID) + }, [isReady, hasUnfinishedWrites]) + const unsubscribeRef = useRef void>>([]) + const subscribe = (type: EventType, handler: (payload?: T) => void) => { + eventBus.subscribe(type, handler) + unsubscribeRef.current.push(() => eventBus.unsubscribe(type, handler)) + } - const setAuthToken = (tokenResponse: AuthPayload, settings: Settings) => { - if (tokenResponse.access_token) { - tokenResponse.groups_encoded_in_token = - settings["acl.oidc.groups.encoded.in.token"] - tokenResponse.expires_at = getTokenExpirationDate( - tokenResponse.expires_in, - ).toString() // convert from the sec offset - ssoAuthState.setAuthPayload(tokenResponse) - setSessionData(tokenResponse) - setValue(StoreKey.SSO_SESSION_ACTIVE, "true") - // Remove the code from the URL - if (history.replaceState) { - const restored = stripOAuthCallbackParams(window.location.search) - const savedParams = sessionStorage.getItem(StoreKey.OAUTH_RETURN_PARAMS) - sessionStorage.removeItem(StoreKey.OAUTH_RETURN_PARAMS) - if (savedParams) { - new URLSearchParams(savedParams).forEach((value, key) => { - if (!restored.has(key)) restored.set(key, value) - }) - } - const queryString = restored.toString() - history.replaceState( - null, - "", - location.pathname + (queryString ? `?${queryString}` : ""), - ) - } - } else { - const error = tokenResponse as unknown as OAuth2Error - // display error message - logout({ - errorTitle: "Something went wrong.", - errorMessage: - error.error_description ?? "Error logging in. Please try again.", - }) + // Returns only a fully installed token, never an error or rejected bearer. + const setAuthToken = ( + tokenResponse: TokenResponse, + settings: Settings, + grantType: GrantType, + ): AuthPayload | undefined => { + const result = buildInstalledPayload( + tokenResponse, + settings["acl.oidc.groups.encoded.in.token"], + grantType, + authSessionState.getAuthPayload(), + ) + if ("error" in result) { + callbacks.current.logout(result.error) + return undefined } + const installed = result.payload + toast.dismiss(REFRESH_FAILED_TOAST_ID) + authSessionState.setAuthPayload(installed) + // Login choice is tab-local; never erase another tab's password credentials. + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "sso") + // React 17 does not batch promise callbacks automatically. + unstable_batchedUpdates(() => { + setSessionData(installed) + dispatch({ view: View.ready }) + }) + setValue(StoreKey.SSO_SESSION_ACTIVE, "true") + return installed } const refreshAuthToken = async ( settings: Settings, refreshToken: string | undefined, + deadlineSignal: AbortSignal, + requestSignal: AbortSignal, ) => { - const response = await getAuthToken(settings, { - grant_type: "refresh_token", - refresh_token: refreshToken, - client_id: settings["acl.oidc.client.id"], - }) - const tokenResponse = (await response.json()) as AuthPayload - setAuthToken(tokenResponse, settings) - return tokenResponse + const payloadAtStart = authSessionState.getAuthPayload() + const response = await getAuthToken( + settings, + { + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: settings["acl.oidc.client.id"], + }, + requestSignal, + ) + // Transient HTTP failures take the same backoff path as transport failures, + // regardless of whether their bodies are JSON or HTML. + if (response.status === 429 || response.status >= 500) { + throw new Error(`Identity provider unavailable (HTTP ${response.status})`) + } + // The wait signal marks late errors, not network cancellation. Preserve a + // late rotated token until the separate request deadline expires. + const tokenResponse = await readAuthTokenResponse(response, deadlineSignal) + if ( + requestSignal.aborted || + !payloadAtStart || + authSessionState.getAuthPayload() !== payloadAtStart + ) { + return undefined + } + return setAuthToken(tokenResponse, settings, "refresh_token") } - const setupOAuth2 = async (settings: Settings) => { + const showRefreshFailedWarning = () => { + const payload = authSessionState.getAuthPayload() + if (!payload || toast.isActive(REFRESH_FAILED_TOAST_ID)) return + toast.error( + callbacks.current.signInAgain()} + />, + { + autoClose: false, + closeOnClick: false, + toastId: REFRESH_FAILED_TOAST_ID, + }, + ) + } + + const setupOAuth2 = async ( + settings: Settings, + isCurrentOwner: () => boolean, + ) => { if (!settings["acl.enabled"]) { dispatch({ view: View.ready }) return @@ -179,36 +313,14 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { // Proceed with the OAuth2 flow only if it is enabled on the server if (settings["acl.oidc.enabled"]) { - // Subscribe for any subsequent REST 401 responses (incorrect token, etc) - eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { - const oauthRedirectCount = getValue(StoreKey.OAUTH_REDIRECT_COUNT) - // If any prior 401 when oauth2 is enabled has been received - if (oauthRedirectCount) { - const count = parseInt(oauthRedirectCount) - // Something is wrong with the backend, it is consistently rejecting the token. - // Redirect to a dedicated logout page instead to break the loop. - if (!isNaN(count) && count >= 5) { - // redirect to /logout and force user authentication to avoid infinite loop - removeValue(StoreKey.OAUTH_REDIRECT_COUNT) - logout({ promptForLogin: true, clearSSOSession: true }) - } else { - setValue( - StoreKey.OAUTH_REDIRECT_COUNT, - JSON.stringify( - oauthRedirectCount ? parseInt(oauthRedirectCount) + 1 : 1, - ), - ) - logout() - } - // First time 401 - } else { - setValue(StoreKey.OAUTH_REDIRECT_COUNT, JSON.stringify(1)) - logout() - } + subscribe(EventType.MSG_AUTH_REFRESH_FAILED, showRefreshFailedWarning) + + subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { + handleSSOUnauthorized((options) => callbacks.current.logout(options)) }) // Clear the 401 auth redirect loop in case the connection is back to normal - eventBus.subscribe(EventType.MSG_CONNECTION_OK, () => { + subscribe(EventType.MSG_CONNECTION_OK, () => { removeValue(StoreKey.OAUTH_REDIRECT_COUNT) }) @@ -217,6 +329,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { : "" const urlParams = new URLSearchParams(window.location.search) const code = urlParams.get("code") + const retainedPayload = authSessionState.getAuthPayload() const oauth2Error = new OAuth2Error( urlParams.get("error"), urlParams.get("error_description"), @@ -227,18 +340,20 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const state = getValue(StoreKey.OAUTH_STATE) const stateParam = urlParams.get("state") removeValue(StoreKey.OAUTH_STATE) + restoreUrlAfterOAuthCallback() if ( settings["acl.oidc.state.required"] && (!state || state !== stateParam) ) { // the callback cannot be tied to a login this browser started - logout({ promptForLogin: true }) + callbacks.current.logout({ promptForLogin: true }) return } const code_verifier = getValue(StoreKey.PKCE_CODE_VERIFIER) - const response = await getAuthToken(settings, { + const session = authSessionState.capture() + const exchange = await exchangeAuthorizationCode(settings, { grant_type: "authorization_code", code, code_verifier, @@ -246,9 +361,23 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { redirect_uri: settings["acl.oidc.redirect.uri"] || getDefaultRedirectUri(), }) - const tokenResponse = (await response.json()) as AuthPayload - setAuthToken(tokenResponse, settings) - await startServerSession(tokenResponse) + // Unlike rotating refreshes, an initial exchange belongs only to the + // provider and login that started it. Settings recovery or logout may + // have replaced that owner while its token response was pending. + if (!isCurrentOwner() || !session.isCurrent()) return + if ("signInError" in exchange) { + callbacks.current.logout(exchange.signInError) + return + } + const { tokenResponse } = exchange + const installed = setAuthToken( + tokenResponse, + settings, + "authorization_code", + ) + if (installed) { + await startServerSession(installed) + } } else if (oauth2Error.error) { // User has just been redirected back from the OAuth2 provider and there is an error const previousPrompt = getValue(StoreKey.OAUTH_PROMPT) @@ -256,33 +385,47 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { if (previousPrompt === "none") { // If we requested authorization code silently (prompt=none), it could be that the user // does not have an active SSO session, so let's send the user to re-authenticate (prompt=login) - redirectToAuthorizationUrl({ isRetry: true }) + callbacks.current.redirectToAuthorizationUrl({ isRetry: true }) } else { // If the error is not in response for a silent authorization code request, display the error - logout({ - errorTitle: "Something went wrong.", + restoreUrlAfterOAuthCallback() + callbacks.current.logout({ + errorTitle: SIGN_IN_ERROR_TITLE, errorMessage: oauth2Error.error + ": " + oauth2Error.error_description, }) } + } else if (retainedPayload) { + // Settings recovery can remount the providers without ending this + // tab's SSO session. Resume its refresh capability as well as its bearer. + unstable_batchedUpdates(() => { + setSessionData(retainedPayload) + dispatch({ view: View.ready }) + }) + if (authSessionState.hasRefreshFailed()) showRefreshFailedWarning() } else if ( + sessionStorage.getItem(StoreKey.LOGIN_METHOD) !== "password" && ssoUsername && getValue(StoreKey.SSO_SESSION_ACTIVE) && - !getValue(StoreKey.REST_TOKEN) + (sessionStorage.getItem(StoreKey.LOGIN_METHOD) === "sso" || + (!getValue(StoreKey.REST_TOKEN) && + !getValue(StoreKey.BASIC_AUTH_HEADER))) ) { - // No REST token, so it is a page reload for an SSO user - // who didn't explicitly log out — try to request a token silently - redirectToAuthorizationUrl({ prompt: "none" }) + // Resume this tab's SSO choice even if another tab stored password + // credentials. Otherwise only try silent SSO when none are stored. + callbacks.current.redirectToAuthorizationUrl({ prompt: "none" }) } else { - // Stop loading and display the login state - uiAuthLogin() + // Stop loading and display the login state. A tab that signs in with + // SSO but has no SSO session to resume, e.g. after logging out, shows + // Login rather than another tab's password login. + uiAuthLogin(sessionStorage.getItem(StoreKey.LOGIN_METHOD) !== "sso") } } else if (settings["acl.basic.auth.realm.enabled"]) { await basicAuthLogin() } else { // Subscribe for any subsequent REST 401 responses (incorrect token, etc) - eventBus.subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { - logout() + subscribe(EventType.MSG_CONNECTION_UNAUTHORIZED, () => { + if (authSessionState.isAuthenticated()) callbacks.current.logout() }) //username + pwd via login page @@ -294,6 +437,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { // run a simple query to force basic auth by browser const response = await fetch(`exec?query=select 42`) if (response.status === 200) { + authSessionState.markNonSSOAuthenticated() dispatch({ view: View.ready }) } else { await basicAuthLogin() @@ -302,11 +446,18 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const startServerSession = async (tokenResponse: AuthPayload) => { // execute a simple query with session=true - await fetch(`exec?query=select 2&session=true`, { - headers: { - Authorization: `Bearer ${tokenResponse.groups_encoded_in_token ? tokenResponse.id_token : tokenResponse.access_token}`, - }, - }) + const authorization = getBearerAuthorization(tokenResponse) + if (!authorization) return + const session = authSessionState.capture() + // Downloads authenticate only with this cookie session. The server opens + // it once it has authorized the request, even if the SQL then fails. + const opened = await fetch(`exec?query=select 2&session=true`, { + headers: { Authorization: authorization }, + }).then( + (response) => response.ok || response.status === 400, + () => false, + ) + if (!opened) questClient.noteCookieSessionNotOpened(session) } const destroyServerSession = () => { @@ -317,13 +468,25 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { ) } - const uiAuthLogin = () => { + const uiAuthLogin = (useStoredCredentials = true) => { // Check if user is authenticated already with basic auth const token = getValue(StoreKey.REST_TOKEN) const basicAuthHeader = getValue(StoreKey.BASIC_AUTH_HEADER) - if (token || basicAuthHeader) { + if (useStoredCredentials && (token || basicAuthHeader)) { + authSessionState.markNonSSOAuthenticated() + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "password") dispatch({ view: View.ready }) } else { + // Settings recovery remounts this provider without ending the tab's + // login. If it cannot go on, e.g. another tab has removed the shared + // password credentials since, end that login first: whoever signs in + // next must start a new session, without the previous login's requests + // or last result. + if (authSessionState.isAuthenticated()) { + unstable_batchedUpdates(() => + authSessionState.endSession(explainInterruptedRequests({})), + ) + } // Stop loading and display the login state dispatch({ view: View.login }) } @@ -360,58 +523,86 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }) } - const logout = ({ - promptForLogin, - errorTitle, - errorMessage, - isDisconnection, - reload, - clearSSOSession, - }: { - promptForLogin?: boolean - errorTitle?: string - errorMessage?: string - isDisconnection?: boolean - reload?: boolean - clearSSOSession?: boolean - } = {}) => { - ssoAuthState.clearAuthPayload() - setSessionData(undefined) + const logout = (options: LogoutOptions = {}) => { + const { promptForLogin, reload, clearSSOSession } = options + if (reload && !confirmReloadingLogout()) return + const logoutOptions = explainInterruptedRequests(options) + toast.dismiss(REFRESH_FAILED_TOAST_ID) + // Finish storage cleanup before session-end listeners can render Login. removeValue(StoreKey.OAUTH_PROMPT) sessionStorage.removeItem(StoreKey.OAUTH_RETURN_PARAMS) - removeValue(StoreKey.REST_TOKEN) - removeValue(StoreKey.BASIC_AUTH_HEADER) + // The login method is tab-local, but password credentials are shared. + // Only a tab signed in with a password removes them. An SSO tab leaves + // them to the tabs that signed in with them, and keeps its method, so + // that reloading it does not sign in with them either. + if (sessionStorage.getItem(StoreKey.LOGIN_METHOD) === "password") { + sessionStorage.removeItem(StoreKey.LOGIN_METHOD) + removeValue(StoreKey.REST_TOKEN) + removeValue(StoreKey.BASIC_AUTH_HEADER) + } if (clearSSOSession) { removeValue(StoreKey.SSO_SESSION_ACTIVE) } if (promptForLogin && settings["acl.oidc.client.id"]) { removeSSOUserNameWithClientID(settings["acl.oidc.client.id"]) } + // Session-end listeners clear client credentials, pending confirmations, + // refresh work and telemetry timers, and, unless this document reloads, + // the last query result. React 17 does not batch promise continuations: + // their updates and showing Login must be one commit, before console + // effects can run again. + unstable_batchedUpdates(() => authSessionState.endSession(logoutOptions)) destroyServerSession() if (reload) { + // This session's running work was confirmed above. An earlier session's + // writes are not asked about again: the console's warning says that + // logging out cancels them, and staying would now keep a console without + // a session, because Login is not shown before the reload. + reloadingRef.current = true window.location.reload() - return } - dispatch({ view: View.login, errorTitle, errorMessage, isDisconnection }) } useEffect(() => { - if (sessionData) { - dispatch({ view: View.ready }) + let active = true + // Settings recovery may change the server's authentication mode. End the + // old SSO lifecycle before subscribing this owner or reading password + // credentials, without deleting those credentials through logout(). + if ( + authSessionState.isSSOAuthenticated() && + (!settings["acl.enabled"] || !settings["acl.oidc.enabled"]) + ) { + unstable_batchedUpdates(() => authSessionState.endSession()) } - }, [sessionData]) - - useEffect(() => { + // A rotating refresh can outlive Settings recovery and its original auth + // owner. Shared teardown must notify the mounted owner, not only the + // provider instance that started the refresh. Do not recursively log out. + unsubscribeRef.current.push( + authSessionState.onEnd( + ({ errorTitle, errorMessage, isDisconnection, reload } = {}) => { + if (reload) return + unstable_batchedUpdates(() => { + setSessionData(undefined) + dispatch({ + view: View.login, + errorTitle, + errorMessage, + isDisconnection, + }) + }) + }, + ), + ) // If user has no access to the HTTP protocol, they will receive a 403 error response // containing a specific message type. If that happens, we display a full screen error page. - eventBus.subscribe( + subscribe( EventType.MSG_CONNECTION_FORBIDDEN, (errorPayload) => { if ( errorPayload && errorPayload.error.match(/Access denied.* \[HTTP]/gm) ) { - logout({ + callbacks.current.logout({ errorTitle: "Oops. You've been disconnected.", errorMessage: "You are not authorized to use the Web Console. You may try login again or contact your account administrator.", @@ -421,20 +612,66 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }, ) - void setupOAuth2(settings) + void setupOAuth2(settings, () => active) + return () => { + active = false + unsubscribeRef.current.forEach((unsubscribe) => unsubscribe()) + unsubscribeRef.current = [] + toast.dismiss(REFRESH_FAILED_TOAST_ID) + } }, []) + const signInAgain = () => { + // Requests still waiting for renewal were never sent, so cancelling them + // cannot leave a statement half-done. Only a sent request is uncertain. + const sent = questClient.hasSentRequests() + if ( + questClient.hasActiveRequests() && + !window.confirm( + sent + ? "Signing in again interrupts running queries, and statements may already have executed. Uploads already being sent continue, but only while this tab stays open. End this session?" + : "Signing in again cancels requests that have not been sent yet. End this session?", + ) + ) + return + // logout() explains interrupted sent requests itself. It keeps the SSO + // session flag: other tabs share it to sign in silently when they reload, + // and this tab still signs in again through the identity provider. + callbacks.current.logout({ + errorTitle: "Sign in again", + errorMessage: sent + ? "Your previous session has ended." + : "Your previous session has ended. Please sign in again.", + }) + } + + // Stable public callbacks delegate to this render's implementation. + callbacks.current = { + logout, + signInAgain, + refreshAuthToken, + redirectToAuthorizationUrl, + } + const stableCallbacks = useMemo>( + () => ({ + logout: (options) => callbacks.current.logout(options), + signInAgain: () => callbacks.current.signInAgain(), + refreshAuthToken: (...args) => + callbacks.current.refreshAuthToken(...args), + redirectToAuthorizationUrl: (options) => + callbacks.current.redirectToAuthorizationUrl(options), + }), + [], + ) + const contextValue = useMemo( + () => ({ sessionData, ...stableCallbacks }), + [sessionData, stableCallbacks], + ) + const views: { [key in View]: () => React.ReactNode } = { [View.loading]: () => null, [View.ready]: () => ( - + {children} ), @@ -446,6 +683,8 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { }) }} onBasicAuthSuccess={() => { + sessionStorage.setItem(StoreKey.LOGIN_METHOD, "password") + authSessionState.markNonSSOAuthenticated() dispatch({ view: View.ready }) }} resetErrors={() => @@ -458,6 +697,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { errorTitle={state.errorTitle} errorMessage={state.errorMessage} isDisconnection={state.isDisconnection ?? false} + unfinishedWrites={unfinishedWrites} /> ), } diff --git a/src/providers/QuestProvider/AuthCheckRecovery.tsx b/src/providers/QuestProvider/AuthCheckRecovery.tsx new file mode 100644 index 000000000..e06779c15 --- /dev/null +++ b/src/providers/QuestProvider/AuthCheckRecovery.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useEffect, useRef, useState } from "react" +import { ConnectionErrorView } from "../../components/ConnectionErrorView" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { + endedFor, + isAuthRefreshUnavailable, +} from "../../modules/OAuth2/session" +import { isCancelledByUser } from "../../utils/questdb/cancellation" +import { questClient } from "../../utils/questdb/queryClient" +import type { ErrorResult } from "../../utils/questdb/types" + +export type AuthCheckFailure = { message: string; renewalUnavailable: boolean } + +export const useAuthCheck = (required: boolean) => { + const [finished, setFinished] = useState(!required) + const [error, setError] = useState() + const [pending, setPending] = useState(false) + const inFlight = useRef(false) + const mounted = useRef(false) + const consoleRef = useRef(null) + const focusAfterRetry = useRef(false) + + useEffect(() => { + mounted.current = true + return () => { + mounted.current = false + } + }, []) + + useEffect(() => { + if (!finished || !focusAfterRetry.current || !consoleRef.current) return + focusAfterRetry.current = false + const main = consoleRef.current + // A permanent tabindex makes blank clicks anywhere in the console focus + // main, breaking native keyboard scrolling in nested, non-focusable panels. + main.setAttribute("tabindex", "-1") + const releaseFocusTarget = () => main.removeAttribute("tabindex") + main.addEventListener("blur", releaseFocusTarget, { once: true }) + main.focus() + return () => { + main.removeEventListener("blur", releaseFocusTarget) + releaseFocusTarget() + } + }, [finished]) + + const finish = useCallback(async (userRetry = false) => { + if (inFlight.current) return + inFlight.current = true + const session = authSessionState.capture() + setPending(true) + try { + // Retry renews the session at once, or waits for a renewal still in + // flight, instead of failing with the same error during the backoff. + if (userRetry) { + await questClient.prepareRenewalRetry() + if (!mounted.current || !session.isCurrent()) return + } + await questClient.queryRaw("SELECT 1", { background: true }) + if (mounted.current && session.isCurrent()) setFinished(true) + } catch (failure) { + if ( + mounted.current && + !endedFor(session, failure) && + !isCancelledByUser(failure) + ) { + const renewalUnavailable = isAuthRefreshUnavailable(failure) + setError({ + renewalUnavailable, + message: renewalUnavailable + ? "The identity provider could not renew your SSO session. Retry to renew it, or sign in again." + : (failure as Partial)?.error || + "Could not connect to QuestDB.", + }) + } + } finally { + inFlight.current = false + if (mounted.current && session.isCurrent()) setPending(false) + } + }, []) + + const retry = () => { + if (pending) return + focusAfterRetry.current = true + void finish(true) + } + + return { finished, error, pending, finish, retry, consoleRef } +} + +export const AuthCheckRecovery = ({ + error, + pending, + onRetry, + onSignIn, +}: { + error: AuthCheckFailure + pending: boolean + onRetry: () => void + onSignIn: () => void +}) => ( + +) diff --git a/src/providers/QuestProvider/index.test.tsx b/src/providers/QuestProvider/index.test.tsx new file mode 100644 index 000000000..e335890df --- /dev/null +++ b/src/providers/QuestProvider/index.test.tsx @@ -0,0 +1,630 @@ +import "../../test/stubBrowserGlobals" +import React, { useContext } from "react" +import { ThemeProvider } from "styled-components" +import { darkTheme } from "../../theme" +import { AuthRefreshUnavailableError } from "../../modules/OAuth2/session" +import { act, create, ReactTestRenderer } from "react-test-renderer" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { StoreKey } from "../../utils/localStorage/types" +import type { AuthPayload } from "../../modules/OAuth2/types" +import type { Settings } from "../SettingsProvider/types" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { questClient } from "../../utils/questdb/queryClient" + +const mocks = vi.hoisted(() => ({ + sessionData: undefined as AuthPayload | undefined, + refresh: vi.fn(), + queryRaw: vi.fn(), + prepareRenewalRetry: vi.fn(), + headers: vi.fn<[Record], void>(), + dispatch: vi.fn(), + headingFocus: vi.fn(), + mainFocus: vi.fn(), + signInAgain: vi.fn(), + mainSetAttribute: vi.fn(), + mainRemoveAttribute: vi.fn(), + mainAddEventListener: vi.fn<[string, () => void, unknown?], void>(), + settings: { + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + } as Settings, +})) +vi.mock("../AuthProvider", () => ({ + useAuth: () => ({ + sessionData: mocks.sessionData, + refreshAuthToken: mocks.refresh, + signInAgain: mocks.signInAgain, + }), +})) +vi.mock("../SettingsProvider", () => ({ + useSettings: () => ({ settings: mocks.settings }), +})) +vi.mock("../../utils/questdb/queryClient", () => ({ + questClient: { + queryRaw: mocks.queryRaw, + prepareRenewalRetry: mocks.prepareRenewalRetry, + setCommonHeaders: mocks.headers, + resetCredentials: () => { + mocks.headers({}) + questClient.refreshTokenMethod = undefined + }, + refreshTokenMethod: undefined, + }, +})) +vi.mock("../../utils/questdb", () => ({ + QueryExecutionManager: vi.fn(), + Type: { DQL: "dql" }, +})) +vi.mock("react-redux", () => ({ + useDispatch: () => mocks.dispatch, + useSelector: () => undefined, +})) +vi.mock("../../store", () => ({ + actions: { telemetry: { start: () => ({ type: "start" }) } }, + selectors: { telemetry: { getConfig: vi.fn() } }, +})) +vi.mock("../../modules/ConsoleEventTracker", () => ({ + start: vi.fn(), + stop: vi.fn(), +})) +vi.mock("../../components/QueryExecutionConfirmDialog", () => ({ + QueryExecutionConfirmDialog: () => null, +})) +vi.mock("../../components", () => ({ + Text: "span", + Button: "button", + CenteredLayout: "section", + Box: "div", +})) +vi.mock("../../utils/questdb/queryExecution", () => ({ questExecution: {} })) +vi.mock("../../components/Box", () => ({ + Box: ({ children }: React.PropsWithChildren) => ( +
{children}
+ ), +})) + +import { QuestContext, QuestProvider as ActualQuestProvider } from "." +const QuestProvider = ({ children }: React.PropsWithChildren) => ( + + {children} + +) + +let renderer: ReactTestRenderer | undefined +beforeEach(() => { + mocks.sessionData = undefined + mocks.settings = { + "acl.enabled": true, + "acl.basic.auth.realm.enabled": false, + } + mocks.queryRaw.mockReset().mockResolvedValue({ type: "dml" }) + mocks.prepareRenewalRetry.mockReset().mockResolvedValue(undefined) + mocks.headers.mockClear() + localStorage.clear() + authSessionState.endSession() +}) +afterEach(() => { + act(() => renderer?.unmount()) + vi.clearAllMocks() + vi.useRealTimers() +}) +const mount = async (children =
) => { + await act(async () => { + renderer = create({children}, { + createNodeMock: (element) => ({ + focus: element.type === "h1" ? mocks.headingFocus : mocks.mainFocus, + setAttribute: mocks.mainSetAttribute, + removeAttribute: mocks.mainRemoveAttribute, + addEventListener: mocks.mainAddEventListener, + removeEventListener: vi.fn(), + }), + }) + await Promise.resolve() + }) +} + +describe("QuestProvider authentication gate", () => { + it.each([ + new AuthRefreshUnavailableError(), + { + error: "renewal unavailable", + authRefreshUnavailable: true, + requestNotSent: true, + }, + ])( + "explains an IdP renewal failure and offers sign-in independently of the toast", + async (failure) => { + mocks.queryRaw.mockRejectedValue(failure) + await mount() + expect(renderer!.root.findByType("h1").children).toEqual([ + "Could not renew your SSO session", + ]) + const signIn = renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Sign in again"))! + const onSignIn = signIn.props.onClick as () => void + act(onSignIn) + expect(mocks.signInAgain).toHaveBeenCalledOnce() + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toEqual(["Renewal failed. Retry connection, or sign in again."]) + }, + ) + + it("renews before a Retry, but not before the automatic check", async () => { + // Given a startup check that failed because renewal failed + mocks.queryRaw.mockRejectedValue(new AuthRefreshUnavailableError()) + await mount() + expect(mocks.prepareRenewalRetry).not.toHaveBeenCalled() + const checks = () => + mocks.queryRaw.mock.calls.filter(([query]) => query === "SELECT 1").length + const failedChecks = checks() + let renewed!: () => void + mocks.prepareRenewalRetry.mockImplementation( + () => new Promise((resolve) => (renewed = resolve)), + ) + + // When the user retries, while a renewal is still being answered + const retry = renderer!.root + .findAllByType("button") + .find((button) => button.children.includes("Retry connection"))! + await act(async () => { + retry.props.onClick() + await Promise.resolve() + }) + + // Then it shows that it is reconnecting, and checks only once renewed + expect(mocks.prepareRenewalRetry).toHaveBeenCalledOnce() + expect(checks()).toBe(failedChecks) + expect(retry.props["aria-busy"]).toBe(true) + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toEqual(["Reconnecting…"]) + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + renewed() + await Promise.resolve() + }) + expect(checks()).toBe(failedChecks + 1) + expect( + renderer!.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + }) + + it("shows a delayed status while an auth check waits, then removes it on completion", async () => { + vi.useFakeTimers() + let finish!: (result: { type: string }) => void + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? new Promise((resolve) => { + finish = resolve + }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0) + act(() => { + vi.advanceTimersByTime(500) + }) + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toEqual(["Checking your session…"]) + await act(async () => { + finish({ type: "dml" }) + await Promise.resolve() + }) + expect(renderer!.root.findAllByProps({ role: "status" })).toHaveLength(0) + expect(renderer!.root.findByType("main").props.tabIndex).toBeUndefined() + expect(vi.getTimerCount()).toBe(0) + }) + it("reads a rotated refresh token when the installed refresh method runs", async () => { + mocks.sessionData = { + access_token: "old", + refresh_token: "r1", + token_type: "Bearer", + } + authSessionState.setAuthPayload(mocks.sessionData) + await mount() + const refresh = questClient.refreshTokenMethod! + authSessionState.setAuthPayload({ + ...mocks.sessionData, + refresh_token: "r2", + }) + const deadline = new AbortController().signal + const network = new AbortController().signal + await refresh(deadline, network) + expect(mocks.refresh).toHaveBeenLastCalledWith( + mocks.settings, + "r2", + deadline, + network, + ) + }) + + it("releases password headers and refresh callbacks on unmount", async () => { + localStorage.setItem(StoreKey.REST_TOKEN, "private") + await mount() + act(() => renderer?.unmount()) + expect(mocks.headers).toHaveBeenLastCalledWith({}) + expect(questClient.refreshTokenMethod).toBeUndefined() + }) + + it("focuses recovery and then the console after a successful Retry", async () => { + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "Offline" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + expect(mocks.headingFocus).toHaveBeenCalledOnce() + mocks.mainFocus.mockClear() + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + renderer!.root.findByType("button").props.onClick() + await Promise.resolve() + }) + expect(mocks.mainFocus).toHaveBeenCalledOnce() + expect(mocks.mainSetAttribute).toHaveBeenCalledWith("tabindex", "-1") + expect(renderer!.root.findByType("main").props.tabIndex).toBeUndefined() + const release = mocks.mainAddEventListener.mock.calls.find( + ([event]) => event === "blur", + )![1] + release() + expect(mocks.mainRemoveAttribute).toHaveBeenCalledWith("tabindex") + }) + + it.each([StoreKey.REST_TOKEN, StoreKey.BASIC_AUTH_HEADER])( + "does not let retained %s overwrite SSO credentials", + async (key) => { + // These are deliberately NOT cleared by AuthProvider in this test. + localStorage.setItem(key, "other-tab") + mocks.sessionData = { access_token: "sso", token_type: "Bearer" } + await mount() + expect(localStorage.getItem(key)).toBe("other-tab") + expect( + mocks.headers.mock.calls.every( + ([headers]) => headers.Authorization === undefined, + ), + ).toBe(true) + }, + ) + + describe.each([ + { + key: StoreKey.REST_TOKEN, + credential: "first-user", + authorization: "Bearer first-user", + replacement: "other-user", + replacementAuthorization: "Bearer other-user", + }, + { + key: StoreKey.BASIC_AUTH_HEADER, + credential: "Basic first-user", + authorization: "Basic first-user", + replacement: "Basic other-user", + replacementAuthorization: "Basic other-user", + }, + ])( + "$key session snapshot", + ({ + key, + credential, + authorization, + replacement, + replacementAuthorization, + }) => { + it.each(["replacement", "removal"])( + "keeps its credentials after another tab's %s during the initial check", + async (change) => { + let finishAuthCheck!: (result: { type: string }) => void + const authCheck = new Promise<{ type: string }>((resolve) => { + finishAuthCheck = resolve + }) + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" ? authCheck : Promise.resolve({ type: "dml" }), + ) + localStorage.setItem(key, credential) + await mount() + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: authorization, + }) + expect( + renderer?.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(0) + expect(mocks.dispatch).not.toHaveBeenCalled() + + if (change === "replacement") { + localStorage.setItem(key, replacement) + } else { + localStorage.removeItem(key) + } + await act(async () => { + finishAuthCheck({ type: "dml" }) + await Promise.resolve() + }) + + expect( + renderer?.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: authorization, + }) + expect(mocks.dispatch).toHaveBeenCalledTimes(1) + expect(mocks.dispatch).toHaveBeenCalledWith({ type: "start" }) + expect(mocks.headers.mock.invocationCallOrder[0]).toBeLessThan( + mocks.dispatch.mock.invocationCallOrder[0], + ) + }, + ) + + it("keeps its credentials when settings change", async () => { + localStorage.setItem(key, credential) + await mount() + localStorage.setItem(key, replacement) + mocks.settings = { ...mocks.settings, "release.version": "updated" } + await act(async () => { + renderer?.update( + +
+ , + ) + await Promise.resolve() + }) + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: authorization, + }) + }) + + it("captures the current credentials on a new provider mount", async () => { + localStorage.setItem(key, credential) + await mount() + localStorage.setItem(key, replacement) + act(() => renderer?.unmount()) + await mount() + expect(mocks.headers).toHaveBeenLastCalledWith({ + Authorization: replacementAuthorization, + }) + }) + }, + ) + + it("fetches build information after Retry succeeds", async () => { + const versions: string[] = [] + const Consumer = () => { + versions.push(useContext(QuestContext).buildVersion.version ?? "") + return null + } + mocks.queryRaw.mockRejectedValue({ error: "Offline" }) + await mount() + expect(mocks.queryRaw).not.toHaveBeenCalledWith( + "select build", + expect.anything(), + ) + mocks.queryRaw.mockImplementation((query: string) => + Promise.resolve( + query === "select build" + ? { + type: "dql", + count: 1, + dataset: [ + [ + "Build Information: QuestDB Enterprise 9.0.0, Commit Hash abc123", + ], + ], + } + : { type: "dml" }, + ), + ) + await act(async () => { + renderer!.root.findByType("button").props.onClick() + await Promise.resolve() + }) + expect(mocks.queryRaw).toHaveBeenCalledWith("select build", { + limit: "0,1000", + background: true, + }) + expect(versions.some((version) => version.length > 0)).toBe(true) + }) + + it("renders a recoverable error when SELECT 1 fails", async () => { + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "QuestDB is not reachable [502]" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + expect( + renderer?.root.findByProps({ role: "alert" }).findByType("span").children, + ).toEqual(["QuestDB is not reachable [502]"]) + expect( + renderer?.root.findByProps({ role: "alert" }).findByType("span").props + .color, + ).toBe("statusDanger") + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + renderer?.root.findByType("button").props.onClick() + await Promise.resolve() + }) + expect( + renderer?.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + }) + + it("keeps the recovery panel and retry button mounted while retrying", async () => { + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "QuestDB is not reachable [502]" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + const button = renderer!.root.findByType("button") + let failRetry!: (error: { error: string }) => void + const retry = new Promise((_, reject) => { + failRetry = reject + }) + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" ? retry : Promise.resolve({ type: "dml" }), + ) + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + + expect(renderer!.root.findByType("button")).toBe(button) + expect(button.props.disabled).not.toBe(true) + expect(button.props["aria-disabled"]).toBe(true) + expect(button.props["aria-busy"]).toBe(true) + expect(renderer!.root.findByProps({ role: "alert" })).toBeDefined() + expect( + renderer!.root.findByProps({ role: "status" }).findByType("span") + .children, + ).toContain("Reconnecting…") + const attempts = mocks.queryRaw.mock.calls.length + act(() => { + button.props.onClick() + }) + expect(mocks.queryRaw).toHaveBeenCalledTimes(attempts) + expect( + renderer!.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(0) + + await act(async () => { + failRetry({ error: "Still unavailable" }) + await Promise.resolve() + }) + expect(renderer!.root.findByType("button")).toBe(button) + expect(button.props["aria-disabled"]).toBe(false) + expect(button.props["aria-busy"]).toBe(false) + expect( + renderer!.root.findByProps({ role: "alert" }).findByType("span").children, + ).toEqual(["Still unavailable"]) + + mocks.queryRaw.mockResolvedValue({ type: "dml" }) + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + expect( + renderer!.root.findAllByProps({ "data-hook": "ready" }), + ).toHaveLength(1) + }) + + it("does not start another probe when credentials refresh during a retry", async () => { + mocks.sessionData = { access_token: "old", token_type: "Bearer" } + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject({ error: "QuestDB is not reachable [502]" }) + : Promise.resolve({ type: "dml" }), + ) + await mount() + let failRetry!: (error: { error: string }) => void + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? new Promise((_, reject) => { + failRetry = reject + }) + : Promise.resolve({ type: "dml" }), + ) + const button = renderer!.root.findByType("button") + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + mocks.sessionData = { access_token: "refreshed", token_type: "Bearer" } + await act(async () => { + renderer!.update( + +
+ , + ) + await Promise.resolve() + }) + expect( + mocks.queryRaw.mock.calls.filter(([query]) => query === "SELECT 1"), + ).toHaveLength(2) + expect(button.props["aria-busy"]).toBe(true) + expect(mocks.headers).toHaveBeenLastCalledWith({}) + await act(async () => { + failRetry({ error: "QuestDB is not reachable [502]" }) + await Promise.resolve() + }) + expect(button.props["aria-busy"]).toBe(false) + }) + + it("announces consecutive identical retry failures", async () => { + const error = { error: "QuestDB is not reachable [502]" } + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? Promise.reject(error) + : Promise.resolve({ type: "dml" }), + ) + await mount() + const alert = renderer!.root.findByProps({ role: "alert" }) + const button = renderer!.root.findByType("button") + let reject!: (error: { error: string }) => void + mocks.queryRaw.mockImplementation((query: string) => + query === "SELECT 1" + ? new Promise((_, rejectQuery) => { + reject = rejectQuery + }) + : Promise.resolve({ type: "dml" }), + ) + const status = renderer!.root.findByProps({ role: "status" }) + for (let attempt = 0; attempt < 2; attempt++) { + await act(async () => { + button.props.onClick() + await Promise.resolve() + }) + expect(status.findByType("span").children).toEqual(["Reconnecting…"]) + await act(async () => { + reject(error) + await Promise.resolve() + }) + expect(renderer!.root.findByProps({ role: "alert" })).toBe(alert) + expect(alert.findByType("span").children).toEqual([error.error]) + expect(renderer!.root.findByProps({ role: "status" })).toBe(status) + expect(status.findByType("span").children).toEqual([ + "Connection failed. Retry connection.", + ]) + expect(renderer!.root.findByType("button")).toBe(button) + expect(button.props["aria-busy"]).toBe(false) + } + }) + + it("does not persist session-ended errors into the authentication gate", async () => { + mocks.queryRaw.mockRejectedValue({ + error: "Your session has ended", + sessionEnded: true, + }) + await mount() + expect(renderer?.root.findAllByProps({ role: "alert" })).toHaveLength(0) + }) + + it("keeps QuestContext stable and skips SELECT 1 on token refresh", async () => { + const values: Array> = [] + const Consumer = () => { + values.push(useContext(QuestContext)) + return null + } + mocks.sessionData = { access_token: "first", token_type: "Bearer" } + const child = + await mount(child) + const value = values[values.length - 1] + const count = mocks.queryRaw.mock.calls.filter( + ([q]) => q === "SELECT 1", + ).length + mocks.sessionData = { access_token: "refreshed", token_type: "Bearer" } + await act(async () => { + renderer?.update({child}) + await Promise.resolve() + }) + expect(values[values.length - 1]).toBe(value) + expect( + mocks.queryRaw.mock.calls.filter(([q]) => q === "SELECT 1"), + ).toHaveLength(count) + expect(mocks.headers).toHaveBeenLastCalledWith({}) + }) +}) diff --git a/src/providers/QuestProvider/index.tsx b/src/providers/QuestProvider/index.tsx index c06971be3..1c74e8d4c 100644 --- a/src/providers/QuestProvider/index.tsx +++ b/src/providers/QuestProvider/index.tsx @@ -22,15 +22,20 @@ * ******************************************************************************/ -import React, { createContext, useEffect, useState } from "react" +import React, { createContext, useEffect, useMemo, useState } from "react" +import styled from "styled-components" import * as QuestDB from "../../utils/questdb" import { useAuth } from "../AuthProvider" -import { AuthPayload } from "../../modules/OAuth2/types" -import { getValue } from "../../utils/localStorage" -import { StoreKey } from "../../utils/localStorage/types" +import { questClient } from "../../utils/questdb/queryClient" +import { authSessionState } from "../../modules/OAuth2/authSessionState" +import { questExecution } from "../../utils/questdb/queryExecution" +import { AuthCheckRecovery, useAuthCheck } from "./AuthCheckRecovery" import { formatCommitHash, formatVersion } from "./services" import { Versions } from "./types" -import { hasUIAuth } from "../../modules/OAuth2/utils" +import { + getStoredCredentialAuthorization, + hasUIAuth, +} from "../../modules/OAuth2/utils" import { useSettings } from "../SettingsProvider" import { useDispatch, useSelector } from "react-redux" import { actions, selectors } from "../../store" @@ -39,9 +44,15 @@ import { stop as stopConsoleEvents, } from "../../modules/ConsoleEventTracker" import { QueryExecutionConfirmDialog } from "../../components/QueryExecutionConfirmDialog" +import { CenteredLayout, Text } from "../../components" -const questClient = new QuestDB.Client() -const questExecution = new QuestDB.QueryExecutionManager() +const ConsoleMain = styled.main` + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + outline: none; +` type ContextProps = { quest: QuestDB.Client @@ -65,72 +76,80 @@ export const QuestContext = createContext(defaultValues) export const QuestProvider: React.FC = ({ children }) => { const dispatch = useDispatch() const { settings } = useSettings() - const { sessionData, refreshAuthToken } = useAuth() - const telemetryConfig = useSelector(selectors.telemetry.getConfig) - const [authCheckFinished, setAuthCheckFinished] = useState( - !hasUIAuth(settings), + const { sessionData, refreshAuthToken, signInAgain } = useAuth() + // Password credentials are shared across browser tabs; retain this tab's login. + const [nonSSOAuthorization] = useState(() => + getStoredCredentialAuthorization(), ) + const telemetryConfig = useSelector(selectors.telemetry.getConfig) + const { + finished: authCheckFinished, + error: authCheckError, + pending: authCheckPending, + finish: finishAuthCheck, + retry, + consoleRef, + } = useAuthCheck(!!hasUIAuth(settings)) + const [showAuthCheckStatus, setShowAuthCheckStatus] = useState(false) + useEffect(() => { + setShowAuthCheckStatus(false) + if (!authCheckPending) return + const timer = setTimeout(() => setShowAuthCheckStatus(true), 500) + return () => clearTimeout(timer) + }, [authCheckPending]) const [buildVersion, setBuildVersion] = useState( defaultValues.buildVersion, ) const [commitHash, setCommitHash] = useState("") - const finishAuthCheck = async () => { - // The initial check tells us if the user has permission to use the HTTP protocol. - // If not, this will trigger a full-screen error page, preventing the subsequent requests to be made. - await questClient.queryRaw("SELECT 1") - - setAuthCheckFinished(true) - } - - const setupClient = (sessionData: Partial) => { - questClient.setCommonHeaders({ - Authorization: `Bearer ${sessionData.groups_encoded_in_token ? sessionData.id_token : sessionData.access_token}`, - }) - - questClient.refreshTokenMethod = () => { - return refreshAuthToken(settings, sessionData.refresh_token) - } - - void finishAuthCheck() - } + useEffect( + () => () => { + questClient.resetCredentials() + }, + [], + ) - // User has been logged in with OAuth2 useEffect(() => { - if (sessionData) { - setupClient(sessionData) - } - }, [sessionData]) + // SSO bearer headers are derived from the installed payload at fetch time. + const authorization = sessionData ? undefined : nonSSOAuthorization + questClient.setCommonHeaders( + authorization ? { Authorization: authorization } : {}, + ) + questClient.refreshTokenMethod = sessionData + ? (deadlineSignal, requestSignal) => + refreshAuthToken( + settings, + authSessionState.getAuthPayload()?.refresh_token, + deadlineSignal, + requestSignal, + ) + : undefined + if (!authCheckFinished) void finishAuthCheck() + }, [ + sessionData, + nonSSOAuthorization, + settings, + refreshAuthToken, + authCheckFinished, + finishAuthCheck, + ]) useEffect(() => { - const restToken = getValue(StoreKey.REST_TOKEN) - // User has provided the basic auth credentials - if (restToken) { - questClient.setCommonHeaders({ - Authorization: `Bearer ${restToken}`, - }) - void finishAuthCheck() - } else { - const basicAuth = getValue(StoreKey.BASIC_AUTH_HEADER) - if (basicAuth) { - questClient.setCommonHeaders({ - Authorization: basicAuth, - }) - void finishAuthCheck() - } - } - + if (!authCheckFinished) return // TODO: Remove this, use info from `/settings` (`type` and `version`) and run this hook on `settings` dep // Get the build version info void questClient - .queryRaw("select build", { limit: "0,1000" }) + .queryRaw("select build", { limit: "0,1000", background: true }) .then((result) => { if (result.type === QuestDB.Type.DQL && result.count === 1) { setBuildVersion(formatVersion(result.dataset[0][0] as string)) setCommitHash(formatCommitHash(result.dataset[0][0] as string)) } }) - }, []) + .catch(() => { + // Authentication can end while the initial build query is waiting. + }) + }, [authCheckFinished]) // Telemetry queries use SQL, and therefore need to have auth header set if needed. // Defer starting until the authorization is properly set for all HTTP requests. @@ -149,19 +168,38 @@ export const QuestProvider: React.FC = ({ children }) => { } }, [telemetryConfig?.enabled, telemetryConfig?.id]) - if (!authCheckFinished) return null + const contextValue = useMemo( + () => ({ + quest: questClient, + questExecution, + buildVersion, + commitHash, + }), + [buildVersion, commitHash], + ) + + if (!authCheckFinished) + return authCheckError ? ( + + ) : showAuthCheckStatus ? ( + +
+ Checking your session… +
+
+ ) : null return ( - - {children} - + + + {children} + + ) } diff --git a/src/scenes/Editor/Metrics/metric.tsx b/src/scenes/Editor/Metrics/metric.tsx index e51c4063c..c70b6bc44 100644 --- a/src/scenes/Editor/Metrics/metric.tsx +++ b/src/scenes/Editor/Metrics/metric.tsx @@ -125,6 +125,7 @@ export const Metric = ({ to: toIso, }), ), + { background: true }, ), ]) diff --git a/src/scenes/Editor/Monaco/index.tsx b/src/scenes/Editor/Monaco/index.tsx index b8ea0a5c8..1022ba36d 100644 --- a/src/scenes/Editor/Monaco/index.tsx +++ b/src/scenes/Editor/Monaco/index.tsx @@ -89,6 +89,11 @@ import { LiteEditor } from "../../../components/LiteEditor" import { copyToClipboard } from "../../../utils/copyToClipboard" import { trackEvent } from "../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" +import { + isAuthFailure, + isSessionEnded, + sessionEndedQueryMessage, +} from "../../../modules/OAuth2/session" import ButtonBar from "../ButtonBar" import { QueryDropdown } from "./QueryDropdown" import { @@ -1580,23 +1585,33 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { } catch (_error: unknown) { const error = _error as ErrorResult - const bufferIdStr = activeBufferId.toString() - if (!executionRefs.current[bufferIdStr]) { - executionRefs.current[bufferIdStr] = {} - } + // A session end or failed renewal is no SQL error: it has no position + // to mark in the query. + if (!isAuthFailure(error)) { + const bufferIdStr = activeBufferId.toString() + if (!executionRefs.current[bufferIdStr]) { + executionRefs.current[bufferIdStr] = {} + } - const startOffset = getQueryStartOffset(editor, query) - executionRefs.current[bufferIdStr][queryKey] = { - error, - queryText: query.query, - startOffset, - endOffset: startOffset + normalizeQueryText(query.query).length, - selection: query.selection, + const startOffset = getQueryStartOffset(editor, query) + executionRefs.current[bufferIdStr][queryKey] = { + error, + queryText: query.query, + startOffset, + endOffset: startOffset + normalizeQueryText(query.query).length, + selection: query.selection, + } } notification = { query: queryKey, - content: {error.error}, + content: ( + + {isSessionEnded(error) + ? sessionEndedQueryMessage(error) + : error.error} + + ), sideContent: , type: NotificationType.ERROR, } @@ -1744,6 +1759,9 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { const startTime = Date.now() for (let i = 0; i < queries.length; i++) { + // The run can also be stopped while it waits to start, e.g. by the end + // of the session. No statement may be sent after that. + if (scriptStopRef.current) break const query = queries[i] const lineNumber = query.row + 1 editor.revealPositionInCenterIfOutsideViewport({ @@ -1824,7 +1842,10 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { ) } editor.focus() - editor.revealPositionInCenterIfOutsideViewport(editor.getPosition()!) + // Ending the session unmounts the editor while the script settles, and an + // editor without a model has no position. The run must still be settled. + const position = editor.getPosition() + if (position) editor.revealPositionInCenterIfOutsideViewport(position) const completedGracefully = queries.length === individualQueryResults.length if ( @@ -1850,11 +1871,21 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { query: `${activeBufferRef.current.label}@${LINE_NUMBER_HARD_LIMIT + 1}-${LINE_NUMBER_HARD_LIMIT + 1}`, content: ( - {notificationPrefix} - {successfulQueries > 0 ? `${successfulQueries} successful` : ""} - {successfulQueries > 0 && failedQueries > 0 ? " and " : ""} - {failedQueries > 0 ? `${failedQueries} failed` : ""}{" "} - {failedQueries + successfulQueries > 1 ? " queries" : " query"} + {!completedGracefully && individualQueryResults.length === 0 ? ( + "Stopped before running any query" + ) : ( + <> + {notificationPrefix} + {successfulQueries > 0 + ? `${successfulQueries} successful` + : ""} + {successfulQueries > 0 && failedQueries > 0 ? " and " : ""} + {failedQueries > 0 ? `${failedQueries} failed` : ""}{" "} + {failedQueries + successfulQueries > 1 + ? " queries" + : " query"} + + )} ), type: completedGracefully @@ -2175,6 +2206,32 @@ const MonacoEditor = ({ hidden = false }: { hidden?: boolean }) => { .catch((error: ErrorResult) => { const { inflightQuery, currentQueryKey } = settleExecution() + if (isAuthFailure(error)) { + // A session end or failed renewal is no SQL error, and has no + // position to mark: only replace the "Running..." notification. + // Do so even if a session end is unmounting the editor: the next + // login reads it, and must not be asked to sign in again. + dispatch( + actions.query.addNotification( + { + query: currentQueryKey, + isExplain: isRunningExplain, + content: ( + + {isSessionEnded(error) + ? sessionEndedQueryMessage(error) + : error.error} + + ), + sideContent: , + type: NotificationType.ERROR, + }, + targetBufferId, + ), + ) + return + } + if (editorRef?.current && monacoRef?.current) { const model = editorRef.current.getModel() const anchoredQuery = getAnchoredInflightQuery( diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts index ee988e222..d1fe10d20 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.test.ts @@ -8,6 +8,11 @@ import type { } from "../../../../store/notebook" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { authSessionState } from "../../../../modules/OAuth2/authSessionState" +import { + AuthRefreshUnavailableError, + SESSION_ENDED_MESSAGE, +} from "../../../../modules/OAuth2/session" import { deleteCellSnapshot } from "../../../../store/notebookResults" import { persistCellSnapshot } from "../persistCellSnapshot" import type { CellResultStatus } from "../resultHydration/cellResultHydration" @@ -230,6 +235,7 @@ describe("CellRefreshEngine", () => { "select 1", expect.any(AbortSignal), 10_000, + { background: true }, ) const state = engine.getState("c1") expect(state?.settledKey).toBe(state?.queriesKey) @@ -694,6 +700,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.anything(), expect.anything(), + { background: true }, ) expect(engine.getState("c1")?.fetchCancelled).toBe(false) }) @@ -1247,6 +1254,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) }) @@ -1745,6 +1753,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) }) @@ -2566,6 +2575,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) }) @@ -2610,6 +2620,7 @@ describe("CellRefreshEngine", () => { "select 2", expect.any(AbortSignal), 10_000, + { background: true }, ) // And the debounce expiry does not fetch a second time @@ -2639,6 +2650,299 @@ describe("CellRefreshEngine", () => { }) }) + describe("session end", () => { + // What an aborted request settles with once logout ended the session. + const sessionEnded = (query: string): QueryExecResult => ({ + type: "error", + query, + columns: [], + dataset: [], + count: 0, + error: SESSION_ENDED_MESSAGE, + sessionEnded: true, + }) + const settleWithSessionEnd = () => { + const settles: (() => void)[] = [] + deps.executeSingle = vi.fn( + (sql: string) => + new Promise((resolve) => { + settles.push(() => resolve(sessionEnded(sql))) + }), + ) + return () => settles.forEach((settle) => settle()) + } + + it("drops a chart round in flight instead of writing or deleting its frame", async () => { + // Given a draw cell whose fetch is in flight + const settle = settleWithSessionEnd() + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledOnce() + + // When logout ends the session before React unmounts the notebook, and + // the aborted request settles + authSessionState.endSession() + settle() + await flushAsync() + + // Then the round is dropped: no frame is written and no snapshot deleted + expect(deps.setCellResult).not.toHaveBeenCalled() + expect(persistCellSnapshot).not.toHaveBeenCalled() + expect(deleteCellSnapshot).not.toHaveBeenCalled() + }) + + it("drops a grid round in flight instead of saving its error", async () => { + // Given a run cell with rows whose refresh is in flight + const frame: CellResult = { + results: [ + { + type: "dql", + query: "select 1", + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + timestamp: 0, + }, + ], + activeResultIndex: 0, + timestamp: 0, + } + cellResults.set("g1", frame) + const settle = settleWithSessionEnd() + syncOnScreen([ + { + id: "g1", + position: 0, + value: "select 1", + autoRefresh: false, + result: frame, + }, + ]) + await flushAsync() + void engine.refresh("g1") + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledOnce() + + // When logout ends the session and the aborted request settles + authSessionState.endSession() + settle() + await flushAsync() + + // Then the rows stay and no refresh error is saved for the next login + expect(cellResults.get("g1")).toBe(frame) + expect(persistCellSnapshot).not.toHaveBeenCalled() + }) + }) + + // E.g. after a failed SSO renewal, while the user is still signed in. + describe("statements the server never executed", () => { + const notExecuted = (query: string): QueryExecResult => ({ + type: "error", + query, + columns: [], + dataset: [], + count: 0, + error: new AuthRefreshUnavailableError().message, + requestNotSent: true, + }) + const failToExecute = () => + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve(notExecuted(sql)), + ) + + it("keeps a chart's frame and saved snapshot while its refreshes cannot be executed", async () => { + // Given a polling chart that settled and saved a frame + syncOnScreen([drawCell("c1", "select 1", "1s")]) + await flushAsync() + const frame = cellResults.get("c1") + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When its next polls are not executed + failToExecute() + await vi.advanceTimersByTimeAsync(3000) + + // Then the frame on screen and the snapshot on disk stay + expect(deps.executeSingle.mock.calls.length).toBeGreaterThan(1) + expect(cellResults.get("c1")).toBe(frame) + expect(deleteCellSnapshot).not.toHaveBeenCalled() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + }) + + it("shows the failure of a chart with no frame, without deleting its saved snapshot", async () => { + // Given a chart whose first fetch is not executed + failToExecute() + + // When it fetches + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + + // Then it shows why, and a snapshot from an earlier session survives + expect(cellResults.get("c1")?.results[0]).toMatchObject({ + type: "error", + requestNotSent: true, + }) + expect(deleteCellSnapshot).not.toHaveBeenCalled() + expect(persistCellSnapshot).not.toHaveBeenCalled() + }) + + it.each(["polls", "is refreshed again"])( + "keeps the saved snapshot of a chart with no frame while it %s without being executed", + async (trigger) => { + // Given a chart that shows the failure of a round not executed + failToExecute() + syncOnScreen([ + drawCell("c1", "select 1", trigger === "polls" ? "1s" : false), + ]) + await flushAsync() + expect(cellResults.get("c1")?.results[0]).toMatchObject({ + type: "error", + requestNotSent: true, + }) + + // When its next rounds are not executed either, and so match the + // failure on screen + if (trigger === "polls") { + await vi.advanceTimersByTimeAsync(3000) + } else { + await engine.refresh("c1") + await flushAsync() + } + + // Then it stays settled on the failure, and the snapshot on disk stays + // until a round reaches the server + expect(deps.executeSingle.mock.calls.length).toBeGreaterThan(1) + const state = engine.getState("c1")! + expect(state.settledKey).toBe(state.queriesKey) + expect(cellResults.get("c1")?.results[0]).toMatchObject({ + type: "error", + requestNotSent: true, + }) + expect(deleteCellSnapshot).not.toHaveBeenCalled() + expect(persistCellSnapshot).not.toHaveBeenCalled() + }, + ) + + it("shows the failure of an edited chart instead of keeping the frame of its old SQL", async () => { + // Given a chart that settled and saved a frame + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + + // When its SQL is edited, and the edited statement is not executed + failToExecute() + engine.sync([drawCell("c1", "select 2", false)]) + await vi.advanceTimersByTimeAsync(301) + await flushAsync() + expect(deps.executeSingle).toHaveBeenLastCalledWith( + "select 2", + expect.any(AbortSignal), + 10_000, + { background: true }, + ) + + // Then it settles on the failure instead of "Drawing" forever: the old + // frame was never on screen for the new SQL + const state = engine.getState("c1") + expect(state?.settledKey).toBe(state?.queriesKey) + expect(cellResults.get("c1")?.results).toEqual([ + expect.objectContaining({ + type: "error", + query: "select 2", + requestNotSent: true, + }), + ]) + expect( + toChartResult(cellResults.get("c1"), state!.queries), + ).toMatchObject({ kind: "settled", hadError: true }) + + // And the saved snapshot stays until a round reaches the server + await vi.advanceTimersByTimeAsync(15_000) + expect(deleteCellSnapshot).not.toHaveBeenCalled() + expect(persistCellSnapshot).toHaveBeenCalledTimes(1) + }) + + it("shows the failure of a chart whose frame for its SQL has nothing to draw", async () => { + // Given a chart that kept the error of its last run when it became one + cellResults.set("c1", { + results: [ + { type: "error", query: "select 1", error: "table does not exist" }, + ], + activeResultIndex: 0, + timestamp: 0, + }) + + // When its fetch is not executed + failToExecute() + syncOnScreen([drawCell("c1", "select 1", false)]) + await flushAsync() + expect(deps.executeSingle).toHaveBeenCalledOnce() + + // Then it settles on the failure instead of "Drawing" forever + const state = engine.getState("c1")! + const result = cellResults.get("c1") + expect(state.settledKey).toBe(state.queriesKey) + expect(result?.results).toEqual([ + expect.objectContaining({ type: "error", requestNotSent: true }), + ]) + expect( + deriveChartLoading(state, toChartResult(result, state.queries), false), + ).toBe(false) + expect(persistCellSnapshot).not.toHaveBeenCalled() + expect(deleteCellSnapshot).not.toHaveBeenCalled() + }) + + it("keeps a grid's rows without showing or saving an error, until a refresh is executed", async () => { + // Given a run cell with rows + const frame: CellResult = { + results: [ + { + type: "dql", + query: "select 1", + columns: [{ name: "x", type: "INT" }], + dataset: [[1]], + count: 1, + timestamp: 0, + }, + ], + activeResultIndex: 0, + timestamp: 0, + } + cellResults.set("g1", frame) + syncOnScreen([ + { + id: "g1", + position: 0, + value: "select 1", + autoRefresh: false, + result: frame, + }, + ]) + await flushAsync() + + // When a refresh is not executed + failToExecute() + void engine.refresh("g1") + await flushAsync() + + // Then the rows stay, and no error is shown or saved for the next login + expect(deps.executeSingle).toHaveBeenCalledOnce() + expect(cellResults.get("g1")).toBe(frame) + expect(engine.getState("g1")?.slotErrors.size).toBe(0) + expect(persistCellSnapshot).not.toHaveBeenCalled() + + // And a refresh that is executed saves the frame as usual + deps.executeSingle.mockImplementation((sql: string) => + Promise.resolve(dqlResult(sql)), + ) + void engine.refresh("g1") + await flushAsync() + expect(persistCellSnapshot).toHaveBeenCalledOnce() + expect( + vi.mocked(persistCellSnapshot).mock.calls[0][0].refreshErrors, + ).toBeUndefined() + }) + }) + describe("grid entries", () => { const gridFrame = (queries: string[]): CellResult => ({ results: queries.map((q) => ({ diff --git a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts index 9c41a0574..2daf30f81 100644 --- a/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts +++ b/src/scenes/Editor/Notebook/cellRefresh/cellRefreshEngine.ts @@ -19,6 +19,7 @@ import { } from "../../../../utils/questdb/requestLimiter" import { eventBus } from "../../../../modules/EventBus" import { EventType } from "../../../../modules/EventBus/types" +import { authSessionState } from "../../../../modules/OAuth2/authSessionState" import { getQueriesFromText, normalizeQueryText } from "../../Monaco/utils" import { autoRefreshIntervalMs, @@ -97,11 +98,15 @@ export type CellFetchState = { fetchCancelled: boolean } +// Refreshes only run statements the write gate classified as reads. +const REFRESH_READ = { background: true } + export type CellRefreshDeps = { executeSingle: ( sql: string, signal?: AbortSignal, limit?: number, + options?: { background?: boolean }, ) => Promise validateWithGlobals: ( sql: string, @@ -210,6 +215,11 @@ const errorMessage = (cause: unknown): string => { return "Query failed" } +// The server never executed the statement, e.g. because the SSO session could +// not be renewed. Like an aborted request, it says nothing about the data. +const isNotExecuted = (result: QueryExecResult): boolean => + result.type === "error" && result.requestNotSent === true + const errorExecResult = (query: string, cause: unknown): QueryExecResult => ({ type: "error", query, @@ -346,6 +356,7 @@ export class CellRefreshEngine { // replacement still runs. Keyed outside the entries so a run on a cell whose // entry appears mid-run (first run of an editor-only cell) is still covered. private pendingRunCounts = new Map() + private stopOnSessionEnd: (() => void) | null = null private refreshHandler = (payload?: { cellId?: string }) => { if (payload?.cellId) void this.refresh(payload.cellId) @@ -377,6 +388,11 @@ export class CellRefreshEngine { EventType.NOTEBOOK_CELL_REFRESH_CHART, this.refreshHandler, ) + // Session end aborts every request before React unmounts this engine. + // Rounds settling with those aborts would be recorded as refresh errors + // or delete chart snapshots, so tear down first and drop them. + this.stopOnSessionEnd?.() + this.stopOnSessionEnd = authSessionState.beforeEnd(() => this.destroy()) if (typeof document !== "undefined") { this.documentHidden = document.hidden document.addEventListener( @@ -387,6 +403,8 @@ export class CellRefreshEngine { } destroy() { + this.stopOnSessionEnd?.() + this.stopOnSessionEnd = null eventBus.unsubscribe( EventType.NOTEBOOK_CELL_REFRESH_CHART, this.refreshHandler, @@ -1264,18 +1282,31 @@ export class CellRefreshEngine { const previous = carriedResults[index] if (previous) return Promise.resolve(toExecResult(previous)) return this.limitRequest( - () => deps.executeSingle(q, ac.signal, NOTEBOOK_ROW_CAP), + () => + deps.executeSingle(q, ac.signal, NOTEBOOK_ROW_CAP, REFRESH_READ), ac.signal, ).catch((e) => errorExecResult(q, e)) }), ) const fetchDurationMs = Date.now() - fetchStartedAt if (ac.signal.aborted || !deps.isDrawCell(entry.cellId)) return + // A round with a statement that was never executed keeps the chart drawn + // on screen and the saved snapshot until a round reaches the server. With + // no chart drawn for these statements (no frame yet, one left from edited + // SQL, or one without rows), it shows the failure, but still leaves the + // snapshot. + const notExecuted = out.some(isNotExecuted) // Compare against the CURRENT cell.result, not a retained copy — the // hydration engine may have released or replaced it since the last tick, // and an unchanged frame must still be re-written in that case. const currentSqlHash = sqlHash(entry.sql) const current = this.getDeps().getCellResult(entry.cellId) + // Only a settled frame with rows for these statements is drawn, as in + // deriveChartLoading. Keeping any other would leave the cell on + // "Drawing…", e.g. a run's error kept when the cell became a chart. + const kept = toChartResult(current, queries) + if (notExecuted && kept.kind === "settled" && kept.results.length > 0) + return if ( current != null && resultsEquivalent(current.results.map(toExecResult), out) @@ -1285,6 +1316,10 @@ export class CellRefreshEngine { slotVerifiedAt: verifiedSlots(entry, out, carriedResults), }) this.deriveChartSlotErrors(entry) + // This round repeats the failure an earlier unexecuted round put on + // screen. Like that round, it leaves the snapshot until a round + // reaches the server. + if (notExecuted) return if (successResults(out).length === 0) { this.clearSnapshot(entry) return @@ -1313,6 +1348,7 @@ export class CellRefreshEngine { }) this.setState(entry, { settledKey: queriesKey }) this.deriveChartSlotErrors(entry) + if (notExecuted) return // The disk copy follows the frame on screen, carried rows included. if (written.some(isChartableResult)) { this.queueSnapshot(entry, written, fetchDurationMs) @@ -1392,6 +1428,7 @@ export class CellRefreshEngine { launchPatch.slotFetching = slotFetching } this.setState(entry, launchPatch) + const notExecutedSlots = new Set() await Promise.all( launchSlots.map(async ({ key, index }) => { if (round.signal.aborted) return @@ -1404,10 +1441,17 @@ export class CellRefreshEngine { queries[index], slotAbort.signal, NOTEBOOK_ROW_CAP, + REFRESH_READ, ), slotAbort.signal, ) if (slotAbort.signal.aborted || round.signal.aborted) return + // Like a cancelled slot, it keeps its rows, and no error is shown + // or saved for it. + if (isNotExecuted(exec)) { + notExecutedSlots.add(key) + return + } this.batchUpdates(() => { if (exec.type === "error") { this.setSlotError(entry, key, exec.error ?? "Query failed") @@ -1442,7 +1486,8 @@ export class CellRefreshEngine { ) if (round.signal.aborted) return this.setState(entry, { settledKey: queriesKey }) - this.persistGridFrame(entry, manual) + // A round that could not execute a statement changes nothing on disk. + if (notExecutedSlots.size === 0) this.persistGridFrame(entry, manual) } finally { this.finishRound(entry, round) } diff --git a/src/scenes/Editor/Notebook/notebookUtils.test.ts b/src/scenes/Editor/Notebook/notebookUtils.test.ts index 2f79255b4..c977b2bd2 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.test.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.test.ts @@ -271,6 +271,23 @@ describe("isUnverifiableExecError", () => { } }) + it("flags session-ended errors independently of their message", () => { + expect( + isUnverifiableExecError({ + type: "error", + error: "The query confirmation belongs to an ended session", + sessionEnded: true, + }), + ).toBe(true) + expect( + isUnverifiableExecError({ + type: "error", + error: "table does not exist", + sessionEnded: false, + }), + ).toBe(false) + }) + it("does NOT flag a real server error (definitively did not commit)", () => { expect( isUnverifiableExecError({ diff --git a/src/scenes/Editor/Notebook/notebookUtils.ts b/src/scenes/Editor/Notebook/notebookUtils.ts index fd3301cf4..a813f8b22 100644 --- a/src/scenes/Editor/Notebook/notebookUtils.ts +++ b/src/scenes/Editor/Notebook/notebookUtils.ts @@ -1,4 +1,11 @@ import type { QueryExecResult } from "../../../hooks/useQueryExecution" +import { + isSessionEnded, + sessionEndedQueryMessage, + sessionFailureOf, + type SessionFailure, +} from "../../../modules/OAuth2/session" +import { CANCELLED_BY_USER } from "../../../utils/questdb/cancellation" import type { AgentCellView, AutoRefresh, @@ -286,7 +293,17 @@ export const singleResultFromExec = ( fetchedAt: Date.now(), } case "error": - return errorResult(query, exec.error ?? "Unknown error") + return { + // A cell keeps its result after the session ends, so it is read after + // signing in again: say what the end of the session did to the query. + ...errorResult( + query, + isSessionEnded(exec) + ? sessionEndedQueryMessage(exec) + : (exec.error ?? "Unknown error"), + ), + ...sessionFailureOf(exec), + } default: return { type: exec.type, query, fetchedAt: Date.now() } } @@ -346,20 +363,24 @@ export const capResultBytes = ( export { sqlHash } const UNVERIFIABLE_ERROR_MARKERS = [ - "Cancelled by user", + CANCELLED_BY_USER, "An error occurred, please try again", "Failed to read response", "Invalid JSON response from the server", "QuestDB is not reachable", ] -export const isUnverifiableExecError = (exec: { - type: string - error?: string -}): boolean => +export const isUnverifiableExecError = ( + exec: { + type: string + error?: string + } & SessionFailure, +): boolean => exec.type === "error" && - exec.error !== undefined && - UNVERIFIABLE_ERROR_MARKERS.some((m) => exec.error?.includes(m) ?? false) + !exec.requestNotSent && + (isSessionEnded(exec) || + (exec.error !== undefined && + UNVERIFIABLE_ERROR_MARKERS.some((m) => exec.error?.includes(m) ?? false))) export const UNVERIFIED_RUN_NOTE = "Run outcome unverified: the request did not return a confirmation, so the " + diff --git a/src/scenes/Editor/Notebook/useCellExecution.test.ts b/src/scenes/Editor/Notebook/useCellExecution.test.ts index 6f1168ed5..3bd2ccab2 100644 --- a/src/scenes/Editor/Notebook/useCellExecution.test.ts +++ b/src/scenes/Editor/Notebook/useCellExecution.test.ts @@ -1,10 +1,93 @@ +import { createElement } from "react" +import { act, create } from "react-test-renderer" import { describe, expect, it, vi } from "vitest" import type { QueryExecResult } from "../../../hooks/useQueryExecution" +import { authSessionState } from "../../../modules/OAuth2/authSessionState" +import { SESSION_ENDED_MESSAGE } from "../../../modules/OAuth2/session" +import type { CellResult, NotebookCell } from "../../../store/notebook" import { MAX_ACTIVE_STATEMENT_REQUESTS, statementRequestLimiter, } from "../../../utils/questdb/requestLimiter" -import { abortCellRunsOnUnmount, launchStatement } from "./useCellExecution" +import { persistCellSnapshot } from "./persistCellSnapshot" +import { + abortCellRunsOnUnmount, + launchStatement, + useCellExecution, +} from "./useCellExecution" + +vi.mock("./persistCellSnapshot", () => ({ + persistCellSnapshot: vi.fn().mockResolvedValue(true), +})) + +describe("useCellExecution at session end", () => { + it("drops a run aborted by the session end instead of recording it", async () => { + // Given a cell with a last good result, re-run with a statement in flight + const sql = "insert into trades values (1)" + const lastGood: CellResult = { + results: [{ type: "dml", query: sql, fetchedAt: 1 }], + activeResultIndex: 0, + timestamp: 1, + } + const cellsRef = { + current: [ + { id: "c1", position: 0, value: sql, result: lastGood }, + ] as NotebookCell[], + } + let finish!: (exec: QueryExecResult) => void + const executeSingle = vi.fn( + () => + new Promise((resolve) => { + finish = resolve + }), + ) + const updateCell = vi.fn( + (cellId: string, updates: Partial) => { + cellsRef.current = cellsRef.current.map((cell) => + cell.id === cellId ? { ...cell, ...updates } : cell, + ) + }, + ) + let runCell!: ReturnType["runCell"] + const Harness = () => { + runCell = useCellExecution({ + bufferId: 1, + cellsRef, + executeSingle, + validateWithGlobals: vi.fn(), + updateCellResult: vi.fn(), + updateCell, + updateCells: vi.fn(), + setScriptSummary: vi.fn(), + onSnapshotPersisted: vi.fn(), + }).runCell + return null + } + const renderer = create(createElement(Harness)) + const run = runCell("c1") + await vi.waitFor(() => expect(executeSingle).toHaveBeenCalledOnce()) + expect(updateCell).toHaveBeenCalledOnce() + + // When logout ends the session before React unmounts the notebook, and + // the aborted request settles + act(() => authSessionState.endSession()) + finish({ + type: "error", + query: sql, + columns: [], + dataset: [], + count: 0, + error: SESSION_ENDED_MESSAGE, + sessionEnded: true, + }) + + // Then the run is superseded: the last good result and run history stay + await expect(run).resolves.toMatchObject({ superseded: true }) + expect(updateCell).toHaveBeenCalledOnce() + expect(persistCellSnapshot).not.toHaveBeenCalled() + act(() => renderer.unmount()) + }) +}) describe("abortCellRunsOnUnmount", () => { it("aborts validation and request phases before clearing their registries", () => { diff --git a/src/scenes/Editor/Notebook/useCellExecution.ts b/src/scenes/Editor/Notebook/useCellExecution.ts index f23fe8c28..473c7f0f7 100644 --- a/src/scenes/Editor/Notebook/useCellExecution.ts +++ b/src/scenes/Editor/Notebook/useCellExecution.ts @@ -38,6 +38,7 @@ import { import { hasPendingResult, statementKeysFor } from "./statementIdentity" import { persistCellSnapshot } from "./persistCellSnapshot" import { updateCellSnapshotActiveIndex } from "../../../store/notebookResults" +import { authSessionState } from "../../../modules/OAuth2/authSessionState" export type DrawGateOutcome = | { granted: true } @@ -1031,15 +1032,22 @@ export const useCellExecution = ({ const barrierControllersMap = barrierAbortsRef.current const controllersMap = abortControllersRef.current const runGenerations = runGenerationRef.current - return () => { - // Supersede before aborting so both validation- and request-phase - // continuations bail instead of launching after teardown or overwriting - // the cell's last good snapshot with an abort result. + // Supersede before aborting so both validation- and request-phase + // continuations bail instead of launching after teardown or overwriting + // the cell's last good snapshot with an abort result. + const abortRuns = () => abortCellRunsOnUnmount( barrierControllersMap, controllersMap, runGenerations, ) + // Session end aborts every request before React 17 runs this cleanup, so + // the aborted results would still count as current runs. Supersede them + // as soon as the session ends instead. + const stopOnSessionEnd = authSessionState.beforeEnd(abortRuns) + return () => { + stopOnSessionEnd() + abortRuns() } }, []) diff --git a/src/scenes/Import/ImportCSVFiles/dropbox.test.tsx b/src/scenes/Import/ImportCSVFiles/dropbox.test.tsx new file mode 100644 index 000000000..b3f616eca --- /dev/null +++ b/src/scenes/Import/ImportCSVFiles/dropbox.test.tsx @@ -0,0 +1,132 @@ +import React from "react" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { ThemeProvider } from "styled-components" +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { darkTheme } from "../../../theme" +import type { ProcessedFile } from "./types" + +vi.mock("../../../components", () => ({ Box: "div" })) + +import { DropBox } from "./dropbox" + +let renderer: ReactTestRenderer | undefined + +beforeEach(() => { + // Paste is listened for on the window. + vi.stubGlobal("window", { + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + }) +}) + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + vi.unstubAllGlobals() +}) + +const csv = (name: string) => ({ name }) as File +const fileList = (...files: File[]) => files as unknown as FileList +const listed = (name: string) => ({ table_name: name }) as ProcessedFile + +const mount = () => { + // As ImportCSVFiles does, count a dropped file as unlisted until its table + // name check answers. + const unlisted: string[] = [] + const onFilesDropped = vi.fn((files: File[]) => { + unlisted.push(...files.map((file) => file.name)) + }) + const box: { + addToQueue: (files: FileList) => void + duplicates: File[] + } = { addToQueue: () => {}, duplicates: [] } + const element = (files: ProcessedFile[]) => ( + + unlisted} + onFilesDropped={onFilesDropped} + dialogOpen={false} + render={({ addToQueue, duplicates }) => { + box.addToQueue = addToQueue + box.duplicates = duplicates + return null + }} + /> + + ) + act(() => { + renderer = create(element([])) + }) + const setFiles = (files: ProcessedFile[]) => + act(() => renderer!.update(element(files))) + return { + onFilesDropped, + box, + setFiles, + // The check answered: the file is listed, so no longer unlisted. + list: (files: ProcessedFile[]) => { + setFiles(files) + files.forEach(({ table_name }) => { + const index = unlisted.indexOf(table_name) + if (index !== -1) unlisted.splice(index, 1) + }) + }, + } +} + +// A dropped file is listed once its table name is checked, which can wait up +// to ten seconds for a token refresh. +describe("DropBox", () => { + it("reports a file dropped again before its first drop is listed", () => { + const { onFilesDropped, box } = mount() + const first = csv("trades.csv") + act(() => box.addToQueue(fileList(first))) + + const again = csv("trades.csv") + act(() => box.addToQueue(fileList(again))) + + expect(onFilesDropped.mock.calls).toEqual([[[first]]]) + expect(box.duplicates).toEqual([again]) + }) + + it("reports a file dropped again while the list changes before the first drop is listed", () => { + // Given a dropped file whose table name check is pending + const { onFilesDropped, box, setFiles } = mount() + const first = csv("trades.csv") + act(() => box.addToQueue(fileList(first))) + + // When the list changes meanwhile, e.g. with an upload's progress + setFiles([listed("quotes.csv")]) + const again = csv("trades.csv") + act(() => box.addToQueue(fileList(again))) + + // Then the file is still reported as already queued + expect(onFilesDropped.mock.calls).toEqual([[[first]]]) + expect(box.duplicates).toEqual([again]) + }) + + it("reports a file dropped again once its first drop is listed", () => { + const { onFilesDropped, box, list } = mount() + act(() => box.addToQueue(fileList(csv("trades.csv")))) + list([listed("trades.csv")]) + + const again = csv("trades.csv") + act(() => box.addToQueue(fileList(again))) + + expect(onFilesDropped).toHaveBeenCalledOnce() + expect(box.duplicates).toEqual([again]) + }) + + it("accepts a file again once it was removed from the list", () => { + const { onFilesDropped, box, setFiles, list } = mount() + act(() => box.addToQueue(fileList(csv("trades.csv")))) + list([listed("trades.csv")]) + setFiles([]) + + act(() => box.addToQueue(fileList(csv("trades.csv")))) + + expect(onFilesDropped).toHaveBeenCalledTimes(2) + expect(box.duplicates).toEqual([]) + }) +}) diff --git a/src/scenes/Import/ImportCSVFiles/dropbox.tsx b/src/scenes/Import/ImportCSVFiles/dropbox.tsx index 48ccaab96..dc090f4ee 100644 --- a/src/scenes/Import/ImportCSVFiles/dropbox.tsx +++ b/src/scenes/Import/ImportCSVFiles/dropbox.tsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useEffect } from "react" +import React, { useRef, useState, useEffect, useLayoutEffect } from "react" import styled from "styled-components" import { Box } from "../../../components" import { ProcessedFile } from "./types" @@ -30,6 +30,9 @@ const Root = styled(Box).attrs({ flexDirection: "column" })<{ type Props = { files: ProcessedFile[] + // Names of dropped files that are not listed yet, which count as queued. + // onFilesDropped adds the files it is given at once, until it lists them. + unlistedNames: () => string[] onFilesDropped: (files: File[]) => void dialogOpen: boolean render: (props: { @@ -40,6 +43,7 @@ type Props = { export const DropBox = ({ files, + unlistedNames, onFilesDropped, dialogOpen, render, @@ -63,11 +67,15 @@ export const DropBox = ({ } const addToQueue = (inputFiles: FileList) => { - const duplicates = getFileDuplicates(inputFiles, filenames.current) + const duplicates = getFileDuplicates(inputFiles, [ + ...filenames.current, + ...unlistedNames(), + ]) setDuplicates(duplicates) - onFilesDropped( - Array.from(inputFiles).filter((f) => !duplicates.includes(f)), + const accepted = Array.from(inputFiles).filter( + (f) => !duplicates.includes(f), ) + if (accepted.length > 0) onFilesDropped(accepted) uploadInputRef.current?.setAttribute("value", "") } @@ -93,7 +101,9 @@ export const DropBox = ({ } }, [dialogOpen]) - useEffect(() => { + // Updated as the list is rendered, before another drop can be handled: a + // dropped file stops counting as unlisted once it is listed. + useLayoutEffect(() => { filenames.current = files.map((f) => f.table_name) }, [files]) diff --git a/src/scenes/Import/ImportCSVFiles/file-status.test.tsx b/src/scenes/Import/ImportCSVFiles/file-status.test.tsx new file mode 100644 index 000000000..8f783e627 --- /dev/null +++ b/src/scenes/Import/ImportCSVFiles/file-status.test.tsx @@ -0,0 +1,57 @@ +import React from "react" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { afterEach, describe, expect, it, vi } from "vitest" +import { FileCheckStatus } from "../../../utils/questdb/types" +import { TableNameCheck, type ProcessedFile } from "./types" + +vi.mock("../../../components", () => ({ + Box: "div", + Badge: ({ type, children }: { type: string; children: React.ReactNode }) => ( + {children} + ), +})) +vi.mock("../../../components/icons", () => ({ CheckboxCircle: () => null })) + +import { FileStatus } from "./file-status" + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) + +const badge = (status: string) => { + const file = { + status, + isUploading: false, + uploaded: false, + uploadProgress: 0, + } as ProcessedFile + act(() => { + renderer = create() + }) + const root = renderer!.root.findByType("span") + return { + type: root.props["data-type"] as string, + label: JSON.stringify(renderer!.toJSON()), + } +} + +describe("import file status", () => { + it.each([ + [FileCheckStatus.DOES_NOT_EXIST, "success", "Ready to upload"], + [FileCheckStatus.EXISTS, "warning", "Table already exists"], + [TableNameCheck.PENDING, "info", "Checking table name…"], + [TableNameCheck.FAILED, "warning", "Could not check table name"], + ])("shows %s with the %s badge", (status, type, label) => { + expect(badge(status)).toEqual({ + type, + label: expect.stringContaining(label) as string, + }) + }) + + it("never shows a pending name as ready to upload", () => { + expect(badge(TableNameCheck.PENDING).label).not.toContain("Ready to upload") + }) +}) diff --git a/src/scenes/Import/ImportCSVFiles/file-status.tsx b/src/scenes/Import/ImportCSVFiles/file-status.tsx index 602d7565d..4050c47ff 100644 --- a/src/scenes/Import/ImportCSVFiles/file-status.tsx +++ b/src/scenes/Import/ImportCSVFiles/file-status.tsx @@ -1,7 +1,7 @@ import React from "react" import { FileCheckStatus as FileStatusType } from "../../../utils" import { Box, Badge } from "../../../components" -import { BadgeType, ProcessedFile } from "./types" +import { BadgeType, ProcessedFile, TableNameCheck } from "./types" import styled from "styled-components" import { CheckboxCircle } from "../../../components/icons" @@ -66,6 +66,16 @@ const mapStatusToLabel = ( label: "Ready to upload", type: BadgeType.SUCCESS, } + case TableNameCheck.PENDING: + return { + label: "Checking table name…", + type: BadgeType.INFO, + } + case TableNameCheck.FAILED: + return { + label: "Could not check table name", + type: BadgeType.WARNING, + } } } diff --git a/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx b/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx index e71d47f69..67c9e778d 100644 --- a/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx +++ b/src/scenes/Import/ImportCSVFiles/files-to-upload.tsx @@ -77,6 +77,7 @@ const AddFilesRow = styled.div` type Props = { files: ProcessedFile[] + unlistedNames: () => string[] onDialogToggle: (open: boolean) => void onFileRemove: (id: string) => void onFileUpload: (id: string) => void @@ -89,6 +90,7 @@ type Props = { export const FilesToUpload = ({ files, + unlistedNames, onDialogToggle, onFileRemove, onFilePropertyChange, @@ -381,6 +383,7 @@ export const FilesToUpload = ({ return ( ( diff --git a/src/scenes/Import/ImportCSVFiles/index.test.tsx b/src/scenes/Import/ImportCSVFiles/index.test.tsx new file mode 100644 index 000000000..d280579f7 --- /dev/null +++ b/src/scenes/Import/ImportCSVFiles/index.test.tsx @@ -0,0 +1,378 @@ +import React from "react" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { afterEach, describe, expect, it, vi } from "vitest" +import { + FileCheckStatus, + type UploadResult, +} from "../../../utils/questdb/types" +import { TableNameCheck, type ProcessedFile } from "./types" +import { AuthRefreshUnavailableError } from "../../../modules/OAuth2/session" + +type Queue = { + files: ProcessedFile[] + unlistedNames: () => string[] + onFilesDropped: (files: File[]) => Promise + onFileUpload: (id: string) => Promise + onFilePropertyChange: ( + id: string, + file: Partial, + ) => Promise +} + +const mocks = vi.hoisted(() => ({ + visible: false, + quest: { + checkCSVFile: vi.fn(), + uploadCSVFile: vi.fn(), + query: vi.fn(), + showColumns: vi.fn(), + }, + upload: vi.fn<[Pick], void>(), + queue: vi.fn<[Queue], void>(), +})) + +vi.mock("./upload", () => ({ + Upload: (props: Pick) => { + mocks.upload(props) + return null + }, +})) +vi.mock("./files-to-upload", () => ({ + FilesToUpload: (props: Queue) => { + mocks.queue(props) + return null + }, +})) +vi.mock("../../../providers", async () => { + const { createContext } = + await vi.importActual("react") + return { QuestContext: createContext({ quest: mocks.quest }) } +}) +vi.mock("react-redux", () => ({ useSelector: () => [] })) +vi.mock("../../../store", () => ({ + selectors: { query: { getTables: () => [] } }, +})) +vi.mock("../../../components", () => ({ Box: "div" })) +vi.mock("../../../hooks", () => ({ useIsVisible: () => mocks.visible })) +// OSS: no table owner lookup +vi.mock("../../../utils/localStorage", () => ({ getValue: () => "" })) +vi.mock("../../../modules/ConsoleEventTracker", () => ({ trackEvent: vi.fn() })) + +import { ImportCSVFiles } from "." + +const deferred = () => { + let resolve!: (value: T) => void + let reject!: (reason: unknown) => void + const promise = new Promise((settle, fail) => { + resolve = settle + reject = fail + }) + return { promise, resolve, reject } +} + +const csv = (name: string) => ({ name, size: 7 }) as unknown as File +const element = () => +const queue = () => mocks.queue.mock.lastCall![0] +const file = (index = 0) => queue().files[index] +const settle = (check: { promise: Promise }) => + act(async () => { + await check.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + +let renderer: ReactTestRenderer | undefined + +// Drops trades.csv, whose first check finds no table. +const mountWithFile = async () => { + act(() => { + renderer = create(element()) + }) + mocks.quest.checkCSVFile.mockResolvedValueOnce({ + status: FileCheckStatus.DOES_NOT_EXIST, + }) + const { onFilesDropped } = mocks.upload.mock.lastCall![0] + await act(() => onFilesDropped([csv("trades.csv")])) + expect(file()).toMatchObject({ + table_name: "trades.csv", + status: FileCheckStatus.DOES_NOT_EXIST, + }) +} + +// The Import view becomes visible, and its status check waits for a refresh. +const showWithPendingCheck = () => { + const check = deferred<{ status: string }>() + mocks.quest.checkCSVFile.mockReturnValueOnce(check.promise) + mocks.visible = true + act(() => renderer!.update(element())) + return check +} + +const startUpload = () => { + mocks.quest.uploadCSVFile.mockReturnValueOnce( + deferred().promise, + ) + act(() => { + void queue().onFileUpload(file().id) + }) +} + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined + mocks.visible = false + vi.resetAllMocks() +}) + +// E.g. after a failed SSO renewal, while the user is still signed in. +describe("Import file checks that fail", () => { + const drop = (name: string) => { + const { onFilesDropped } = mocks.upload.mock.lastCall![0] + return act(() => onFilesDropped([csv(name)])) + } + + it("lists a dropped file whose table name check fails, and lets it be uploaded", async () => { + act(() => { + renderer = create(element()) + }) + mocks.quest.checkCSVFile.mockRejectedValueOnce( + new AuthRefreshUnavailableError(), + ) + + await drop("trades.csv") + + expect(file()).toMatchObject({ + table_name: "trades.csv", + status: TableNameCheck.FAILED, + exists: false, + }) + startUpload() + expect(mocks.quest.uploadCSVFile).toHaveBeenCalledWith( + expect.objectContaining({ name: "trades.csv" }), + ) + }) + + it("lists a file dropped for an existing table whose schema cannot be read", async () => { + act(() => { + renderer = create(element()) + }) + mocks.quest.checkCSVFile.mockResolvedValueOnce({ + status: FileCheckStatus.EXISTS, + }) + mocks.quest.showColumns.mockRejectedValueOnce( + new AuthRefreshUnavailableError(), + ) + + await drop("trades.csv") + + expect(file()).toMatchObject({ + table_name: "trades.csv", + status: FileCheckStatus.EXISTS, + schema: [], + }) + }) + + it("keeps the status of a file whose visibility check fails, and merges the others", async () => { + await mountWithFile() + mocks.quest.checkCSVFile.mockResolvedValueOnce({ + status: FileCheckStatus.DOES_NOT_EXIST, + }) + await act(() => queue().onFilesDropped([csv("quotes.csv")])) + mocks.quest.checkCSVFile + .mockRejectedValueOnce(new AuthRefreshUnavailableError()) + .mockResolvedValueOnce({ status: FileCheckStatus.EXISTS }) + + mocks.visible = true + await act(async () => { + renderer!.update(element()) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect( + queue().files.map(({ table_name, status }) => [table_name, status]), + ).toEqual([ + ["trades.csv", FileCheckStatus.DOES_NOT_EXIST], + ["quotes.csv", FileCheckStatus.EXISTS], + ]) + }) +}) + +// A status check can wait up to ten seconds for a token refresh. +describe("Import file checks that finish after other changes", () => { + it("keeps an upload running when the visibility check returns during it", async () => { + await mountWithFile() + const check = showWithPendingCheck() + startUpload() + expect(file().isUploading).toBe(true) + + check.resolve({ status: FileCheckStatus.EXISTS }) + await settle(check) + + // Only the status is merged: the upload is not reported as stopped + expect(file()).toMatchObject({ + isUploading: true, + status: FileCheckStatus.EXISTS, + }) + // and a second click does not start another import + act(() => { + void queue().onFileUpload(file().id) + }) + expect(mocks.quest.uploadCSVFile).toHaveBeenCalledOnce() + }) + + it("keeps a file dropped while the visibility check is pending", async () => { + await mountWithFile() + const check = showWithPendingCheck() + mocks.quest.checkCSVFile.mockResolvedValueOnce({ + status: FileCheckStatus.DOES_NOT_EXIST, + }) + await act(() => queue().onFilesDropped([csv("quotes.csv")])) + + check.resolve({ status: FileCheckStatus.EXISTS }) + await settle(check) + + expect(queue().files.map(({ table_name }) => table_name)).toEqual([ + "trades.csv", + "quotes.csv", + ]) + expect(file().status).toBe(FileCheckStatus.EXISTS) + }) + + it("shows no status for a new name until its check returns, and uploads under it after", async () => { + await mountWithFile() + const check = deferred<{ status: string }>() + mocks.quest.checkCSVFile.mockReturnValueOnce(check.promise) + act(() => { + void queue().onFilePropertyChange(file().id, { table_name: "renamed" }) + }) + // trades.csv did not exist, which says nothing about "renamed" + expect(file()).toMatchObject({ + table_name: "renamed", + status: TableNameCheck.PENDING, + }) + + // An upload cannot skip the warning the check may still show + startUpload() + expect(mocks.quest.uploadCSVFile).not.toHaveBeenCalled() + + check.resolve({ status: FileCheckStatus.EXISTS }) + await settle(check) + expect(file()).toMatchObject({ + table_name: "renamed", + status: FileCheckStatus.EXISTS, + }) + + startUpload() + expect(mocks.quest.uploadCSVFile).toHaveBeenCalledWith( + expect.objectContaining({ name: "renamed" }), + ) + }) + + it("reports a failed check of a new name instead of keeping the old status", async () => { + await mountWithFile() + mocks.quest.checkCSVFile.mockRejectedValueOnce( + new TypeError("Failed to fetch"), + ) + + await act(() => + queue().onFilePropertyChange(file().id, { table_name: "renamed" }), + ) + + expect(file()).toMatchObject({ + table_name: "renamed", + status: TableNameCheck.FAILED, + }) + // The user is warned, and may still upload + startUpload() + expect(mocks.quest.uploadCSVFile).toHaveBeenCalledWith( + expect.objectContaining({ name: "renamed" }), + ) + }) + + it("keeps the status another check found when the rename's own check fails", async () => { + await mountWithFile() + const renameCheck = deferred<{ status: string }>() + mocks.quest.checkCSVFile.mockReturnValueOnce(renameCheck.promise) + let renamed!: Promise + act(() => { + renamed = queue().onFilePropertyChange(file().id, { + table_name: "renamed", + }) + }) + // The Import view becomes visible and checks the new name, too + const visibilityCheck = showWithPendingCheck() + visibilityCheck.resolve({ status: FileCheckStatus.EXISTS }) + await settle(visibilityCheck) + expect(file().status).toBe(FileCheckStatus.EXISTS) + + renameCheck.reject(new TypeError("Failed to fetch")) + await act(() => renamed) + + expect(file()).toMatchObject({ + table_name: "renamed", + status: FileCheckStatus.EXISTS, + }) + }) + + it("counts dropped files as queued until they are listed, also once the list is shown", async () => { + // Given two drops on the upload view whose table name checks are pending + act(() => { + renderer = create(element()) + }) + const tradesCheck = deferred<{ status: string }>() + const quotesCheck = deferred<{ status: string }>() + mocks.quest.checkCSVFile + .mockReturnValueOnce(tradesCheck.promise) + .mockReturnValueOnce(quotesCheck.promise) + const { onFilesDropped, unlistedNames } = mocks.upload.mock.lastCall![0] + let quotesDrop!: Promise + act(() => { + void onFilesDropped([csv("trades.csv")]) + quotesDrop = onFilesDropped([csv("quotes.csv")]) + }) + expect(unlistedNames()).toEqual(["trades.csv", "quotes.csv"]) + + // When the first check answers, and the list replaces the upload view + tradesCheck.resolve({ status: FileCheckStatus.DOES_NOT_EXIST }) + await settle(tradesCheck) + + // Then the file still being checked counts as queued in the list's drop box + expect(queue().files.map(({ table_name }) => table_name)).toEqual([ + "trades.csv", + ]) + expect(queue().unlistedNames()).toEqual(["quotes.csv"]) + + // And no longer once it is listed + quotesCheck.resolve({ status: FileCheckStatus.DOES_NOT_EXIST }) + await act(() => quotesDrop) + expect(queue().files.map(({ table_name }) => table_name)).toEqual([ + "trades.csv", + "quotes.csv", + ]) + expect(queue().unlistedNames()).toEqual([]) + }) + + it("ignores the check of a name that was changed again", async () => { + await mountWithFile() + const first = deferred<{ status: string }>() + const second = deferred<{ status: string }>() + mocks.quest.checkCSVFile + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + act(() => { + void queue().onFilePropertyChange(file().id, { table_name: "first" }) + }) + act(() => { + void queue().onFilePropertyChange(file().id, { table_name: "second" }) + }) + + second.resolve({ status: FileCheckStatus.EXISTS }) + await settle(second) + first.resolve({ status: FileCheckStatus.RESERVED_NAME }) + await settle(first) + + expect(file()).toMatchObject({ + table_name: "second", + status: FileCheckStatus.EXISTS, + }) + }) +}) diff --git a/src/scenes/Import/ImportCSVFiles/index.tsx b/src/scenes/Import/ImportCSVFiles/index.tsx index f70e8b97d..d0398bf83 100644 --- a/src/scenes/Import/ImportCSVFiles/index.tsx +++ b/src/scenes/Import/ImportCSVFiles/index.tsx @@ -1,7 +1,7 @@ -import React, { useEffect, useRef, useState } from "react" +import React, { useCallback, useEffect, useRef, useState } from "react" import styled from "styled-components" import { FilesToUpload } from "./files-to-upload" -import { ProcessedFile } from "./types" +import { ProcessedFile, TableNameCheck } from "./types" import { SchemaColumn } from "../../../components/TableSchemaDialog/types" import { useContext } from "react" import { QuestContext } from "../../../providers" @@ -22,7 +22,7 @@ import { import { Upload } from "./upload" import { getValue } from "../../../utils/localStorage" import { StoreKey } from "../../../utils/localStorage/types" -import { ssoAuthState } from "../../../modules/OAuth2/ssoAuthState" +import { authSessionState } from "../../../modules/OAuth2/authSessionState" import { trackEvent } from "../../../modules/ConsoleEventTracker" import { ConsoleEvent } from "../../../modules/ConsoleEventTracker/events" @@ -46,6 +46,15 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { const isVisible = useIsVisible(rootRef) const [state, setState] = useState("upload") const [ownedByList, setOwnedByList] = useState([]) + // Dropped files are listed once their table names are checked, which can + // wait for a token refresh. Until then their names count as queued, so that + // dropping one again is reported as a duplicate instead of importing it + // twice. Kept here: the drop box is replaced when the list is first shown. + const unlistedDrops = useRef(new Set()) + const unlistedNames = useCallback( + () => Array.from(unlistedDrops.current).flat(), + [], + ) const getOwnedByList = async () => { const isEE = getValue(StoreKey.RELEASE_TYPE) === "EE" @@ -56,11 +65,13 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { try { let ownedByNames: string[] = [] - const userResult = await quest.query(`select current_user()`) + const userResult = await quest.query(`select current_user()`, { + background: true, + }) if (userResult.type === "dql" && userResult.count > 0) { const username = Object.values(userResult.data[0])[0] as string if (username) { - const ssoAuthenticated = ssoAuthState.isSSOAuthenticated() + const ssoAuthenticated = authSessionState.isSSOAuthenticated() if (!ssoAuthenticated) { // no OAuth2 payload, non-SSO users can set themselves as owner ownedByNames.push(username) @@ -68,6 +79,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { const result = await quest.query( `show groups '${username}'`, + { background: true }, ) if (result.type === "dql" && result.count > 0) { const groups = result.data.map( @@ -106,35 +118,52 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { setFileProperties(file.id, { isUploading }) } + // The checks below can wait for a token refresh, and then fail. A failed + // check must not lose the dropped file: list it with the failure shown, as + // for a renamed file, and let the user still upload it. + const checkTableName = async (name: string) => { + try { + return (await quest.checkCSVFile(name)).status + } catch { + return TableNameCheck.FAILED + } + } + + // The schema of an existing table only pre-fills the schema dialog. + // Without it, QuestDB infers the schema from the file, as for a new table. + const getExistingTableSchema = async ( + name: string, + ): Promise => { + try { + const columnResponse = await quest.showColumns(name) + if (columnResponse && columnResponse.type === QuestDB.Type.DQL) { + return columnResponse.data.map((column) => ({ + name: column.column, + type: mapColumnTypeToUI(column.type), + pattern: getTimestampFormat(column.type), + precision: isGeoHash(column.type) + ? extractPrecionFromGeohash(column.type) + : "", + })) + } + } catch { + // Keep the file, without a pre-filled schema. + } + return [] + } + const getFileConfigs = async (files: File[]): Promise => { return await Promise.all( files.map(async (file) => { - const result = await quest.checkCSVFile(file.name) + const status = await checkTableName(file.name) const schema = - result.status === FileCheckStatus.EXISTS - ? await (async () => { - const columnResponse = await quest.showColumns(file.name) - if ( - columnResponse && - columnResponse.type === QuestDB.Type.DQL - ) { - // Find a table schema - return columnResponse.data.map((column) => ({ - name: column.column, - type: mapColumnTypeToUI(column.type), - pattern: getTimestampFormat(column.type), - precision: isGeoHash(column.type) - ? extractPrecionFromGeohash(column.type) - : "", - })) - } - return [] - })() + status === FileCheckStatus.EXISTS + ? await getExistingTableSchema(file.name) : [] const partitionBy = - result.status === FileCheckStatus.EXISTS && tables + status === FileCheckStatus.EXISTS && tables ? (() => { const table = tables.find((t) => t.table_name === file.name) return table?.partitionBy ?? "NONE" @@ -142,7 +171,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { : "NONE" const ttlValue = - result.status === FileCheckStatus.EXISTS && tables + status === FileCheckStatus.EXISTS && tables ? (() => { const table = tables.find((t) => t.table_name === file.name) return table?.ttlValue ?? 0 @@ -150,7 +179,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { : 0 const ttlUnit = - result.status === FileCheckStatus.EXISTS && tables + status === FileCheckStatus.EXISTS && tables ? (() => { const table = tables.find((t) => t.table_name === file.name) return table?.ttlUnit ?? "HOURS" @@ -158,7 +187,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { : "HOURS" const timestamp = - result.status === FileCheckStatus.EXISTS && tables + status === FileCheckStatus.EXISTS && tables ? (() => { const table = tables.find((t) => t.table_name === file.name) return table?.designatedTimestamp ?? "" @@ -170,8 +199,8 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { fileObject: file, table_name: file.name, table_owner: ownedByList[0], - status: result.status, - exists: result.status === FileCheckStatus.EXISTS, + status, + exists: status === FileCheckStatus.EXISTS, schema, partitionBy, ttlValue, @@ -195,22 +224,43 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { } const handleDrop = async (files: File[]) => { - const fileConfigs = await getFileConfigs(files) - setFilesDropped((filesDropped) => [...filesDropped, ...fileConfigs]) - setState("list") + const names = files.map((file) => file.name) + unlistedDrops.current.add(names) + try { + const fileConfigs = await getFileConfigs(files) + setFilesDropped((filesDropped) => [...filesDropped, ...fileConfigs]) + setState("list") + } finally { + // React 17 has rendered the list already, as it renders updates outside + // event handlers at once: the drop box counts the files as listed. + unlistedDrops.current.delete(names) + } } const handleVisible = async () => { - const fileStatusList = await Promise.all( - filesDropped.map(async (file) => { - const result = await quest.checkCSVFile(file.table_name) - return { - ...file, - status: result.status, + const checks = await Promise.all( + filesDropped.map(async ({ id, table_name }) => { + try { + const { status } = await quest.checkCSVFile(table_name) + return [[id, { table_name, status }] as const] + } catch { + // A failed recheck says nothing new: keep the file's status. + return [] } }), ) - setFilesDropped(fileStatusList) + const checked = new Map(checks.flat()) + // The checks can wait for a token refresh. Merge only their statuses into + // the current list: files may have been uploaded, edited, added or removed + // meanwhile, and a status is outdated once its file has been renamed. + setFilesDropped((files) => + files.map((file) => { + const check = checked.get(file.id) + return check && check.table_name === file.table_name + ? { ...file, status: check.status } + : file + }), + ) } useEffect(() => { @@ -224,6 +274,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { {state === "upload" && ( @@ -233,6 +284,7 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { { onFileUpload={async (id) => { const file = filesDropped.find((f) => f.id === id) as ProcessedFile - if (file.isUploading) { + if (file.isUploading || file.status === TableNameCheck.PENDING) { return } void trackEvent(ConsoleEvent.IMPORT_FILE_UPLOAD, { @@ -310,25 +362,41 @@ export const ImportCSVFiles = ({ onViewData, onUpload }: Props) => { ) }} onFilePropertyChange={async (id, partialFile) => { - const processedFiles = await Promise.all( - filesDropped.map(async (file) => { - if (file.id === id) { - // Only check for file existence if table name is changed - const result = partialFile.table_name - ? await quest.checkCSVFile(partialFile.table_name) - : await Promise.resolve({ status: file.status }) - return { - ...file, + const tableName = partialFile.table_name + // Apply the edit at once: the name check below can wait for a + // token refresh. The previous status described another table, so + // the new name has no status, and can't be uploaded to, until its + // check answers. A new name also resets the prior error. + setFileProperties( + id, + tableName + ? { ...partialFile, - status: result.status, - error: partialFile.table_name ? undefined : file.error, // reset prior error if table name is changed + error: undefined, + status: TableNameCheck.PENDING, } - } else { - return file - } - }), + : partialFile, + ) + // Only check for file existence if table name is changed + if (!tableName) return + let status: string + try { + status = (await quest.checkCSVFile(tableName)).status + } catch { + status = TableNameCheck.FAILED + } + setFilesDropped((files) => + files.map((file) => + // Ignore the result if the file has been renamed again. A + // failure doesn't replace a status found by another check. + file.id === id && + file.table_name === tableName && + (status !== TableNameCheck.FAILED || + file.status === TableNameCheck.PENDING) + ? { ...file, status } + : file, + ), ) - setFilesDropped(processedFiles) }} /> )} diff --git a/src/scenes/Import/ImportCSVFiles/types.ts b/src/scenes/Import/ImportCSVFiles/types.ts index f43e5609c..25ddca5bf 100644 --- a/src/scenes/Import/ImportCSVFiles/types.ts +++ b/src/scenes/Import/ImportCSVFiles/types.ts @@ -21,6 +21,13 @@ export type ProcessedFile = { exists: boolean } +// Statuses of a table name that the server has not answered for. Unlike +// FileCheckStatus, they never come from the server. +export enum TableNameCheck { + PENDING = "Checking", + FAILED = "Check failed", +} + export enum BadgeType { SUCCESS = "success", INFO = "info", diff --git a/src/scenes/Import/ImportCSVFiles/upload-actions.test.tsx b/src/scenes/Import/ImportCSVFiles/upload-actions.test.tsx new file mode 100644 index 000000000..557d307e1 --- /dev/null +++ b/src/scenes/Import/ImportCSVFiles/upload-actions.test.tsx @@ -0,0 +1,66 @@ +import React from "react" +import { act, create, type ReactTestRenderer } from "react-test-renderer" +import { afterEach, describe, expect, it, vi } from "vitest" +import { FileCheckStatus } from "../../../utils/questdb/types" +import { TableNameCheck, type ProcessedFile } from "./types" + +vi.mock("../../../components", () => ({ + Box: "div", + Button: "button", + Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}, +})) +vi.mock("../../../components/icons", () => ({ + Close: () => null, + Upload2: () => null, +})) +vi.mock("./upload-settings-dialog", () => ({ + UploadSettingsDialog: () => null, +})) + +import { UploadActions } from "./upload-actions" + +let renderer: ReactTestRenderer | undefined + +afterEach(() => { + act(() => renderer?.unmount()) + renderer = undefined +}) + +const uploadButton = (file: Partial) => { + act(() => { + renderer = create( + , + ) + }) + return renderer!.root.findByProps({ "data-hook": "import-upload-button" }) +} + +describe("import upload button", () => { + it("waits for the check of a new table name", () => { + expect( + uploadButton({ status: TableNameCheck.PENDING }).props.disabled, + ).toBe(true) + }) + + it.each([ + FileCheckStatus.DOES_NOT_EXIST, + FileCheckStatus.EXISTS, + TableNameCheck.FAILED, + ])("allows an upload once the name is %s", (status) => { + expect(uploadButton({ status }).props.disabled).toBe(false) + }) + + it("does not start a second upload", () => { + expect( + uploadButton({ + status: FileCheckStatus.DOES_NOT_EXIST, + isUploading: true, + }).props.disabled, + ).toBe(true) + }) +}) diff --git a/src/scenes/Import/ImportCSVFiles/upload-actions.tsx b/src/scenes/Import/ImportCSVFiles/upload-actions.tsx index 3fda1e874..c9b31415e 100644 --- a/src/scenes/Import/ImportCSVFiles/upload-actions.tsx +++ b/src/scenes/Import/ImportCSVFiles/upload-actions.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react" -import { ProcessedFile } from "./types" +import { ProcessedFile, TableNameCheck } from "./types" import { Box, Button, Tooltip } from "../../../components" import { Close, Upload2 } from "../../../components/icons" import { UploadSettingsDialog } from "./upload-settings-dialog" @@ -28,7 +28,8 @@ export const UploadActions = ({ file={file} />