From 986227c95d47fd25eac607ce459e07755421c807 Mon Sep 17 00:00:00 2001 From: "dawid.wolosz" Date: Wed, 4 Nov 2026 11:01:23 +0100 Subject: [PATCH 1/2] feat: refactor MFA challenge handling and add BackToLoginButton component feat: implement MFA challenge handling with verification code management and UI components --- ngui/ui/.claude/skills/ngui-ui/SKILL.md | 159 +++++---- ngui/ui/AGENTS.md | 14 - .../themes/finops/api/auth/signIn/README.md | 91 ----- .../api/auth/signIn/completeLoginChallenge.ts | 12 + .../finops/api/auth/signIn/constants.ts | 13 +- .../finops/api/auth/signIn/createToken.ts | 3 +- .../finops/api/auth/signIn/errors.test.ts | 37 +- .../themes/finops/api/auth/signIn/errors.ts | 50 ++- .../themes/finops/api/auth/signIn/index.ts | 17 +- .../finops/api/auth/signIn/loginOptions.ts | 4 +- .../finops/api/auth/signIn/request.test.ts | 12 + .../themes/finops/api/auth/signIn/request.ts | 10 +- .../themes/finops/api/auth/signIn/types.ts | 30 +- .../ui/src/themes/finops/api/mocked/README.md | 79 ----- .../api/mocked/auth/signIn/challengeStore.ts | 32 ++ .../signIn/completeLoginChallengeMock.test.ts | 70 ++++ .../auth/signIn/completeLoginChallengeMock.ts | 36 ++ .../auth/signIn/createTokenMock.test.ts | 101 ++++-- .../api/mocked/auth/signIn/createTokenMock.ts | 134 +++----- .../api/mocked/auth/signIn/fixtures.test.ts | 19 ++ .../finops/api/mocked/auth/signIn/fixtures.ts | 141 +++++--- .../finops/api/mocked/auth/signIn/index.ts | 5 +- .../auth/signIn/loginOptionsMock.test.ts | 2 +- .../mocked/auth/signIn/loginOptionsMock.ts | 10 +- .../api/mocked/auth/signIn/scenarios.ts | 35 ++ .../themes/finops/api/mocked/config.test.ts | 17 + .../ui/src/themes/finops/api/mocked/config.ts | 40 +++ .../devPanel/MockedApiDevPanel.styles.ts | 54 +++ .../devPanel/MockedApiDevPanel.test.tsx | 145 ++++++++ .../api/mocked/devPanel/MockedApiDevPanel.tsx | 188 ++++++++++ .../api/mocked/devPanel/describeStage.test.ts | 35 ++ .../api/mocked/devPanel/describeStage.ts | 30 ++ .../finops/api/mocked/devPanel/index.ts | 3 + .../finops/api/mocked/http/handlers.test.ts | 47 ++- .../themes/finops/api/mocked/http/handlers.ts | 21 +- .../finops/api/mocked/http/mockedApiPlugin.ts | 19 +- .../mocked/http/signInScenarioCookie.test.ts | 34 ++ .../api/mocked/http/signInScenarioCookie.ts | 17 + .../src/themes/finops/api/mocked/mockDelay.ts | 3 - .../responses/auth/v2/login_options/200.json | 6 + .../responses/auth/v2/tokens/201-token.json | 9 + .../v2/tokens/202-enrollment-required.json | 10 + .../auth/v2/tokens/202-mfa-email-default.json | 25 ++ .../tokens/202-mfa-no-confirmed-methods.json | 18 + .../auth/v2/tokens/202-mfa-no-default.json | 25 ++ .../auth/v2/tokens/202-mfa-totp-default.json | 25 ++ .../v2/tokens/403-email-not-verified.json | 8 + .../v2/tokens/403-invalid-credentials.json | 8 + .../auth/v2/tokens/403-invalid-mfa-code.json | 8 + .../v2/tokens/403-mfa-attempts-exceeded.json | 8 + .../tokens/404-login-challenge-not-found.json | 8 + .../auth/v2/tokens/500-internal-error.json | 8 + .../finops/components/Greeter/Greeter.tsx | 16 +- .../LoginChallengeStep.test.tsx | 81 ++--- .../MfaChallengeStep/BackToLoginButton.tsx | 16 + .../MfaChallengeStep/ChallengeStatus.tsx | 30 ++ .../MfaChallengeStep/MfaChallengeEnded.tsx | 34 ++ .../MfaChallengeStep.styles.ts | 49 +++ .../MfaChallengeStep.test.tsx | 322 ++++++++++++++++++ .../MfaChallengeStep/MfaChallengeStep.tsx | 164 +++++++++ .../MfaChallengeStep/MfaCodeEntry.tsx | 103 ++++++ .../MfaChallengeStep/MfaMethodIcon.tsx | 21 ++ .../MfaMethodInstructions.tsx | 38 +++ .../MfaChallengeStep/MfaMethodPicker.tsx | 111 ++++++ .../components/MfaChallengeStep/constants.ts | 12 + .../components/MfaChallengeStep/fixtures.ts | 19 ++ .../components/MfaChallengeStep/index.ts | 8 + .../mfaChallengeStageStore.ts | 33 ++ .../components/MfaChallengeStep/types.ts | 12 + .../components/MfaChallengeStep/utils.test.ts | 87 +++++ .../components/MfaChallengeStep/utils.ts | 52 +++ .../VerificationCodeInput.styles.ts | 25 ++ .../VerificationCodeInput.test.tsx | 136 ++++++++ .../VerificationCodeInput.tsx | 110 ++++++ .../VerificationCodeInput/constants.ts | 1 + .../components/VerificationCodeInput/index.ts | 3 + .../AuthorizationContainer.tsx | 41 +-- .../finops/hooks/useCompleteLoginChallenge.ts | 9 + .../themes/finops/hooks/useCompleteSignIn.ts | 19 ++ .../themes/finops/hooks/useLoginChallenge.ts | 55 ++- .../themes/finops/hooks/useSecondsUntil.ts | 15 + .../AuthenticatorAppIcon.tsx | 24 ++ .../icons/AuthenticatorAppIcon/index.ts | 3 + .../pages/MfaChallenge/MfaChallenge.tsx | 6 +- .../finops/tests/renderWithProviders.tsx | 63 ++++ .../themes/finops/translations/de-DE/app.json | 21 +- .../translations/en-US/app-override.json | 21 +- .../themes/finops/translations/es-ES/app.json | 21 +- .../themes/finops/translations/fr-FR/app.json | 21 +- .../themes/finops/translations/ja-JP/app.json | 21 +- .../themes/finops/translations/nl-NL/app.json | 21 +- .../themes/finops/translations/pl-PL/app.json | 21 +- ngui/ui/src/themes/finops/urls.ts | 6 + ngui/ui/vite.config.mts | 12 +- 94 files changed, 3250 insertions(+), 568 deletions(-) delete mode 100644 ngui/ui/src/themes/finops/api/auth/signIn/README.md create mode 100644 ngui/ui/src/themes/finops/api/auth/signIn/completeLoginChallenge.ts delete mode 100644 ngui/ui/src/themes/finops/api/mocked/README.md create mode 100644 ngui/ui/src/themes/finops/api/mocked/auth/signIn/challengeStore.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.test.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.test.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/auth/signIn/scenarios.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/config.test.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/config.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.styles.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.test.tsx create mode 100644 ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.tsx create mode 100644 ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.test.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/devPanel/index.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.test.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.ts create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/login_options/200.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/201-token.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-enrollment-required.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-email-default.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-confirmed-methods.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-default.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-totp-default.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-email-not-verified.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-credentials.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-mfa-code.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-mfa-attempts-exceeded.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/404-login-challenge-not-found.json create mode 100644 ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/500-internal-error.json create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/ChallengeStatus.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeEnded.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.styles.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.test.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaChallengeStep.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaCodeEntry.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodIcon.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodInstructions.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/MfaMethodPicker.tsx create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/constants.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/fixtures.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/index.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/mfaChallengeStageStore.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/types.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.test.ts create mode 100644 ngui/ui/src/themes/finops/components/MfaChallengeStep/utils.ts create mode 100644 ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.styles.ts create mode 100644 ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.test.tsx create mode 100644 ngui/ui/src/themes/finops/components/VerificationCodeInput/VerificationCodeInput.tsx create mode 100644 ngui/ui/src/themes/finops/components/VerificationCodeInput/constants.ts create mode 100644 ngui/ui/src/themes/finops/components/VerificationCodeInput/index.ts create mode 100644 ngui/ui/src/themes/finops/hooks/useCompleteLoginChallenge.ts create mode 100644 ngui/ui/src/themes/finops/hooks/useCompleteSignIn.ts create mode 100644 ngui/ui/src/themes/finops/hooks/useSecondsUntil.ts create mode 100644 ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/AuthenticatorAppIcon.tsx create mode 100644 ngui/ui/src/themes/finops/icons/AuthenticatorAppIcon/index.ts create mode 100644 ngui/ui/src/themes/finops/tests/renderWithProviders.tsx diff --git a/ngui/ui/.claude/skills/ngui-ui/SKILL.md b/ngui/ui/.claude/skills/ngui-ui/SKILL.md index e252184e0..54985e9c3 100644 --- a/ngui/ui/.claude/skills/ngui-ui/SKILL.md +++ b/ngui/ui/.claude/skills/ngui-ui/SKILL.md @@ -1,6 +1,6 @@ --- name: ngui-ui -description: Project-specific bindings for the optscale FinOps for Cloud frontend (ngui/ui). Resolves the concrete values the portable skills reference abstractly — the themes/finops vs src boundary and the theme resolver, alias names, script commands, which checks are already red on main, shared component and hook inventory, test provider stack, i18n namespaces and the translation coverage gate. Use whenever a task touches ngui/ui, in combination with frontend-rules, frontend-react, unit-testing, unit-testing-vitest and git-workflow. +description: Conventions and values for the optscale FinOps for Cloud frontend (ngui/ui) — the themes/finops vs src boundary and the theme resolver, alias names, script commands, which checks are already red on main, shared component and hook inventory, test provider stack, i18n namespaces and the translation coverage gate. Use whenever a task touches ngui/ui. --- # ngui/ui — FinOps for Cloud frontend @@ -15,35 +15,19 @@ redirects imports to it at build time. See actually behaves, including two traps. This skill covers the frontend only. The rest of the `optscale` monorepo — the Python services, -`regression-tests/` — is out of its scope. - -## Which skills to load - -| Task | Load alongside this file | -| ----------------------------------- | -------------------------------------- | -| Any frontend file | `frontend-rules` | -| A `.tsx`, hook, context or form | `frontend-rules` + `frontend-react` | -| Writing or fixing a spec | `unit-testing` + `unit-testing-vitest` | -| Mock-driven prototype of a new view | `poc-builder` | -| Branch, commit or PR | `git-workflow` | - -**Do not load** `mpt-frontend-standards`, `mpt-design-system` or `mpt-module-testing`. Those assume -`@swo/design-system` / `@mpt-extension/sdk`; this app is **MUI 5 + tss-react** and imports neither. -`unit-testing-jest` does not apply either — the runner is Vitest. +`regression-tests/` — is out of its scope. The stack is **React + MUI 5 + tss-react**, tested with +**Vitest**; it uses neither `@swo/design-system` nor `@mpt-extension/sdk`. ## Where the rules live -The portable skills carry the rules; this file carries the values. Repo-local docs that are -authoritative on their own topic: +Repo-local docs that are authoritative on their own topic: -| Doc | Authoritative on | -| ----------------------------------------------------------------------------------------------------- | --------------------------------------------- | -| [`src/utils/themeResolver/README.md`](../../../src/utils/themeResolver/README.md) | How theme override resolution works | -| [`src/themes/finops/translations/README.md`](../../../src/themes/finops/translations/README.md) | Locales, draft locales, the coverage gate | -| [`src/themes/finops/utils/features/README.md`](../../../src/themes/finops/utils/features/README.md) | Theme feature flags | -| [`src/themes/finops/api/mocked/README.md`](../../../src/themes/finops/api/mocked/README.md) | Where mocked endpoints live and the seam rule | -| [`src/themes/finops/api/auth/signIn/README.md`](../../../src/themes/finops/api/auth/signIn/README.md) | The two-step sign-in flow and its two seams | -| [`regression-tests/CLAUDE.md`](../../../../../regression-tests/CLAUDE.md) | The Playwright visual suite (separate scope) | +| Doc | Authoritative on | +| --------------------------------------------------------------------------------------------------- | -------------------------------------------- | +| [`src/utils/themeResolver/README.md`](../../../src/utils/themeResolver/README.md) | How theme override resolution works | +| [`src/themes/finops/translations/README.md`](../../../src/themes/finops/translations/README.md) | Locales, draft locales, the coverage gate | +| [`src/themes/finops/utils/features/README.md`](../../../src/themes/finops/utils/features/README.md) | Theme feature flags | +| [`regression-tests/CLAUDE.md`](../../../../../regression-tests/CLAUDE.md) | The Playwright visual suite (separate scope) | ## Canonical config — code is truth @@ -99,26 +83,61 @@ Run from `ngui/ui/`: cd ngui/ui npx vitest run src/themes/finops/ # the specs you touched pnpm lint # must be clean -npx prettier --write 'src/themes/finops/**/*.{ts,tsx,json,md}' +npx prettier --write # exact command below pnpm build # resolves the theme + lazy page imports pnpm translate:validate # only if you touched translations ``` ### Three checks are already failing on `main` — diff, don't chase -Measured 2026-10-19 on a clean tree. Treat these as the baseline, not as something you broke: +Measured 2026-10-19 on a clean tree; `type:check` and `translate:validate` re-measured 2026-11-04 with +the same result. Treat these as the baseline, not as something you broke: -| Check | Pre-existing state | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `pnpm type:check` | **~15,300 error lines**, ~1,470 of them inside `src/themes/finops`. Not a usable gate. Baseline it (`npx tsc --noEmit > /tmp/tsc-base.txt` on a clean tree), then confirm your files contribute **zero** new lines. | -| `pnpm translate:validate` | 5 coverage failures for `awsUseExternalId` and `regionScope*`. Confirm your keys are absent from the diff. | -| `pnpm prettier:check` | `src/themes/finops/utils/features/README.md` is unformatted. Leave it; don't sweep it into your diff. | +| Check | Pre-existing state | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `pnpm type:check` | **~15,300 error lines**, ~1,470 of them inside `src/themes/finops`. Not a usable gate. Baseline it before your first edit, then diff — recipe below. Your files must add **zero** lines. | +| `pnpm translate:validate` | 5 coverage failures for `awsUseExternalId` and `regionScope*`. Confirm your keys are absent from the diff. | +| `pnpm prettier:check` | `src/themes/finops/utils/features/README.md` is unformatted. Leave it; don't sweep it into your diff. | `pnpm build` **is** clean, and is the only check that exercises the theme resolver and the lazy page imports. Treat a green build as the real signal. -Running prettier over the whole theme reformats unrelated files. Pass explicit paths, or -`git checkout --` the collateral before committing. +Line numbers shift as you edit, so compare the `tsc` runs without `(line,col)` — a raw `diff` reports +every error below your change as new: + +```bash +npx tsc --noEmit > /tmp/tsc-base.txt # before the first edit +npx tsc --noEmit > /tmp/tsc-after.txt # when done +diff <(sed -E 's/\([0-9]+,[0-9]+\)//' /tmp/tsc-base.txt | sort -u) \ + <(sed -E 's/\([0-9]+,[0-9]+\)//' /tmp/tsc-after.txt | sort -u) | grep '^>' # empty = nothing new +``` + +Running prettier over the whole theme reformats unrelated files, so format only what you staged: + +```bash +git diff --cached --name-only --relative --diff-filter=d -- src | grep -E '\.(ts|tsx|json|md)$' | xargs -r npx prettier --write +``` + +`--relative` turns the monorepo-root paths (`ngui/ui/src/…`) into ones that resolve from `ngui/ui`. +`xargs -r` skips prettier when nothing is staged — a bare `npx prettier --write` with no files waits on +stdin forever. In zsh an unquoted `$FILES` is one argument, so don't collect the list in a variable. + +### Seeing the mocked API answer + +The mocks run inside the dev server's Node SSR loader, which neither Vitest nor `pnpm build` touches — +a mock that imports a browser-only module breaks only here. Drive them on a second dev server: + +```bash +npx vite --port 3123 --strictPort & +curl -s -X POST localhost:3123/api/auth/v2/tokens -H 'Content-Type: application/json' \ + -H 'Cookie: finops_mocked_sign_in_scenario=mfa_totp_default' \ + -d '{"email":"finops.ffc@example.com","password":"password"}' # → 202 challenge +pkill -f "vite --port 3123" +``` + +Each answer is also logged as `[mocked-api] POST /auth/v2/tokens → 202`. `vite.config.mts` loads +`mockedApiPlugin.ts` without the path aliases, so the plugin may only `import type` from `handlers.ts`, +plus the import-free `api/mocked/config.ts` that holds every mock setting. ## Repo layout @@ -137,7 +156,7 @@ ngui/ │ └── utils/themeResolver/ # the override plugin └── themes/finops/ # THIS FORK'S CODE — everything you write ├── api/ components/ containers/ contexts/ hooks/ - ├── icons/ layouts/ pages/ shared/ translations/ utils/ + ├── icons/ layouts/ pages/ shared/ tests/ translations/ utils/ ├── theme.ts theme.features.ts urls.ts ``` @@ -148,6 +167,10 @@ counterpart (new work) are reachable only through an explicit `@theme/…` impor `themes/finops/shared/` holds fork-only building blocks that aren't overrides — `ResponsiveStack`, `DividerHorizontal`, `LabelColon`, `DataSourceTagCell`. +`themes/finops/api/mocked/` holds every endpoint stand-in, plus `devPanel/` — the dev-only scenario +panel. The theme `Greeter` lazy-loads it in development and is the only app code that reaches into +`api/mocked/`. + ### Adding a page + route Four touchpoints, all in the theme: @@ -197,7 +220,9 @@ In `themes/finops/hooks/` — browse the folder for the current list: | `useAsyncCall` | One-shot API call + its pending flag; what endpoint hooks wrap | | `useLoginOptions` | The sign-in email step — resolves an address's auth methods | | `useCreateToken` | The sign-in password step | -| `useLoginChallenge` | Reads the MFA challenge out of router state | +| `useLoginChallenge` | MFA router state — read, start (`useStartLoginChallenge`), end | +| `useCompleteSignIn` | Stores an issued token; shared by every sign-in path | +| `useSecondsUntil` | Countdown to a unix timestamp, ticking once a second | | `coreData/useAllDataSources` | Data-source list from the core-data cache | Upstream hooks worth knowing before writing your own: `useOrganizationInfo`, `useGetToken`, @@ -221,15 +246,12 @@ query with `container.querySelector("[data-test-id='…']")`. `ThemeProviderWrapper` + `IntlProvider` + `MemoryRouter`. Fine for smoke tests, but it **hardcodes `MemoryRouter` with no props**, so you cannot nest a router to seed route state, and it supplies neither an Apollo client nor an `alerts` slice. -- Rendering a theme component that reaches `Greeter` or `ThemeProviderWrapper` needs a local - provider stack: `Provider store={mockStore({ [ALERTS]: {} })}` → `MockedProvider` (from - `@apollo/client/testing`) → `ThemeProviderWrapper` → `IntlProvider` → `MemoryRouter initialEntries`. - Working example: - [`LoginChallengeStep.test.tsx`](../../../src/themes/finops/components/LoginChallengeStep/LoginChallengeStep.test.tsx). - `ThemeProviderWrapper` pulls organization theme settings through Apollo; without `MockedProvider` - you get an opaque Apollo `Invariant Violation`. -- Set `globalThis.IS_REACT_ACT_ENVIRONMENT = true` at the top of a render spec, or React 18 floods - the output with act() warnings. +- Rendering a theme component that reaches `Greeter` or `ThemeProviderWrapper`: use + [`@theme/tests/renderWithProviders`](../../../src/themes/finops/tests/renderWithProviders.tsx). It + takes an `initialEntry` (route state included) and the `` elements, supplies the alerts + slice, `MockedProvider`, `ThemeProviderWrapper`, `IntlProvider` and `MemoryRouter`, and sets + `IS_REACT_ACT_ENVIRONMENT`. `ThemeProviderWrapper` pulls organization theme settings through + Apollo; without `MockedProvider` you get an opaque Apollo `Invariant Violation`. - No shared factory or `mockResponse` util ships. Keep fixtures beside the module under test and promote only on the second duplicate. - Specs are co-located `*.test.ts(x)`. Pure logic is by far the cheapest thing to cover here — @@ -274,29 +296,28 @@ only that one). ## This repo's answers to the per-project questions -| Question | Answer | -| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Package manager | `pnpm` 10.33.0, Node 22.16.0, workspace `ngui/{ui,server}` | -| Where does new code go? | `src/themes/finops/` — always | -| Server state | Two layers: Apollo GraphQL against `ngui/server` (`graphql/__generated__/hooks/*`) **and** a legacy redux REST middleware (`src/api/*`, `apiAction`). Follow whatever the surrounding feature already uses. | -| Client state | redux + redux-persist; `initialize` in `containers/InitializeContainer/redux` holds the session | -| Forms | react-hook-form via `components/forms/common/fields` | -| Routing | react-router-dom 6; route classes in `utils/routes/`, rendered by `components/App` | -| Mocking a not-yet-built endpoint | The mock goes in [`api/mocked/`](../../../src/themes/finops/api/mocked/README.md) mirroring the real path; the real `api/` module keeps the contract and a one-line swap seam | -| Feature flags | [`theme.features.ts`](../../../src/themes/finops/theme.features.ts) + `useIsFeatureEnabled` | +| Question | Answer | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Package manager | `pnpm` 10.33.0, Node 22.16.0, workspace `ngui/{ui,server}` | +| Where does new code go? | `src/themes/finops/` — always | +| Server state | Two layers: Apollo GraphQL against `ngui/server` (`graphql/__generated__/hooks/*`) **and** a legacy redux REST middleware (`src/api/*`, `apiAction`). Follow whatever the surrounding feature already uses. | +| Client state | redux + redux-persist; `initialize` in `containers/InitializeContainer/redux` holds the session | +| Forms | react-hook-form via `components/forms/common/fields` | +| Routing | react-router-dom 6; route classes in `utils/routes/`, rendered by `components/App` | +| Mocking a not-yet-built endpoint | Served over HTTP by the dev server: `api/mocked/` mirroring the real path, bodies as JSON in `api/mocked/responses/`, on with `mfa_login`, off with `VITE_MOCKED_API=false` | +| Feature flags | [`theme.features.ts`](../../../src/themes/finops/theme.features.ts) + `useIsFeatureEnabled` | +| Generated GraphQL types | Every field is nullable (`data?.token?.token`). Narrow once at the call site, never cast. Apollo runs with the default `errorPolicy: "none"`, so a failed mutation rejects and a resolved one carries `data`. | ## Git -| Value | This repo | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Default branch | **`main`** (not `master`); `integration` tracks upstream, `release/N` for releases | -| Remote | `git@github.com:softwareone-platform/optscale.git` | -| Tracker key | `MPT-` (Jira: `softwareone.atlassian.net`) | -| Branch shape | `feature/MPT-12345-kebab-summary`, `fix/MPT-12345-kebab-summary` | -| Commit style | `MPT-12345 Sentence describing the change` — **not** Conventional Commits, and not parsed. A few `feat:`/`fix:` commits exist; follow the dominant `MPT-` form. | -| Merges | PRs into `main`; upstream arrives as `⚠️ Sync upstream/integration -> main` commits | - -Never run `git commit` yourself — stage only your own paths and hand the message over. +| Value | This repo | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Default branch | **`main`** (not `master`); `integration` tracks upstream, `release/N` for releases | +| Remote | `git@github.com:softwareone-platform/optscale.git` | +| Tracker key | `MPT-` (Jira: `softwareone.atlassian.net`) | +| Branch shape | `feature/MPT-12345-kebab-summary`, `fix/MPT-12345-kebab-summary` | +| Commit style | Mixed, and not parsed. Most of `main` is `MPT-12345 Sentence describing the change`; the MFA feature commits are `feat:`/`fix:`. Match the branch you are on. | +| Merges | PRs into `main`; upstream arrives as `⚠️ Sync upstream/integration -> main` commits | ## Do not touch @@ -313,8 +334,4 @@ Never run `git commit` yourself — stage only your own paths and hand the messa Change a script name, alias, locale or shared component in the same commit that changes this file. The three "already failing" baselines are dated — re-measure rather than trusting the numbers, and -update the date. Verify with: - -```bash -node ~/.claude/validate-skills.mjs ngui/ui/.claude/skills -``` +update the date. diff --git a/ngui/ui/AGENTS.md b/ngui/ui/AGENTS.md index a5ea47ff6..82c0d8f31 100644 --- a/ngui/ui/AGENTS.md +++ b/ngui/ui/AGENTS.md @@ -16,17 +16,3 @@ OptScale, kept byte-identical so upstream syncs stay mechanical. The Python services and the Playwright suite in `regression-tests/` (see [`regression-tests/CLAUDE.md`](../../regression-tests/CLAUDE.md)) are out of this file's scope. - - - -## Project summary - -Generated at session start from `.claude/skills/ngui-ui/SKILL.md`, `tsconfig.json`, the hooks in `.claude/settings*.json` and `~/.claude/skills` — edit those, not this block. - -- **Aliases (2)** — `@theme/*` · `@main/*` — declared in `tsconfig.json` -- **Verify chain** — vitest → lint → prettier → build → translate:validate -- **Shared skills it names (6)** — `frontend-rules` · `frontend-react` · `unit-testing` · `unit-testing-vitest` · `poc-builder` · `git-workflow` -- **Own skill files (1)** — `ngui-ui/` SKILL -- **Git** — default branch `main` · commits: MPT-12345 Sentence describing the change — not Conventional Commits, and not parsed - - diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/README.md b/ngui/ui/src/themes/finops/api/auth/signIn/README.md deleted file mode 100644 index 1fdd19588..000000000 --- a/ngui/ui/src/themes/finops/api/auth/signIn/README.md +++ /dev/null @@ -1,91 +0,0 @@ -# Sign-in (two-step login) - -Front-end implementation of the two-step login described in **FFC: TDR FinOps for Cloud MFA** -(Confluence → _Login processing workflow_). - -## The feature flag - -The whole thing sits behind `mfa_login` in -[`theme.features.ts`](../../../theme.features.ts), **off by default**. With the flag off, -`AuthorizationContainer` renders the original single-step `LoginForm` against the existing -`createToken` GraphQL mutation and nothing here runs. Flip it to `true` to get the two-step form -and the mocked transport: - -```ts -mfa_login: true; -``` - -Because the transport is mocked, a successful sign-in gets into the app shell but the first real -API call returns 401 — see [The mocked token](#the-mocked-token). Keep the flag off anywhere but a -dev machine. - -## Step 1 — the email step - -Entering an address and pressing **Continue** calls `POST /auth/v2/login_options` with `{ email }` -and gets back which methods that address can authenticate with. Nothing navigates before -_Continue_ is pressed. The answer is a constant `{ methods: ["password"], sso: { state: -"not_configured" } }` — the TDR pins it there until SSO ships, and an address-dependent answer -would leak which e-mails have an account. The form then shows the password field with the address -held read-only above it. - -## Step 2 — the password step - -Submitting the password calls `POST /auth/v2/tokens` with `{ email, password }` and the answer -decides where the visitor goes next: - -| Answer | What happens | -| ---------------------------------- | ----------------------------------------------------------------------------------- | -| `201` Token | Sign-in completes: the macaroon is processed and the app redirects to `/initialize` | -| `202` `state: mfa_required` | Routed to `/mfa` with the challenge in router state | -| `202` `state: enrollment_required` | Routed to `/mfa-enrollment` with the challenge in router state | -| `403` `OA0037` | Stays on the password step: email kept, password cleared and refocused | -| `403` `OA0073` | Routed to `/email-verification` (pre-existing behaviour) | -| anything else | Stays on the password step with a generic message | - -`/mfa` and `/mfa-enrollment` are placeholders — the verification and enrollment screens are a -separate piece of work. They read the challenge through `useLoginChallenge` and bounce back to -`/login` when there is none, because arriving without one means no password was ever verified. - -## The transport - -This directory is the real thing: `request.ts` does a `fetch`, `createToken.ts` and -`loginOptions.ts` are thin calls on top of it. **There is no mock in here and no swap to make** — -the app issues the same request it will issue in production, and you can read it in -DevTools → Network. - -What stands in for the service is the dev server: `VITE_MOCKED_API` (on by default in dev) lets a -Vite middleware answer these two routes locally. When the auth service ships them, set it to -`false` and the identical request goes upstream through the proxy. The machinery and the rules -are in [`api/mocked/README.md`](../../mocked/README.md). - -`request.ts` turns any non-2xx answer into an `AuthApiError` carrying the optscale error envelope -(`{"error": {"error_code": "OA00xx", "reason": "…"}}`), and hands the HTTP status back alongside -the body, because sign-in branches on `201` versus `202`. - -The mocked answers use the bodies from the TDR and are held ~600 ms by `api/mocked/mockDelay.ts`, -so the pending states are visible while clicking through. - -### Reaching each scenario - -The password is always `password`. Anything else answers as invalid credentials, which is also how -you exercise the recoverable error state. The outcome for a correct password is chosen from the -**email local part**: - -| Email | Outcome | -| ----------------------------------- | ----------------------------------- | -| `mfa_anypostfix@example.com` | `202` `mfa_required` | -| `enroll_anypostfix@example.com` | `202` `enrollment_required` | -| `unverified_anypostfix@example.com` | `403` `OA0073` (email not verified) | -| `error_anypostfix@example.com` | `500` `OA0061` (generic error) | -| anything else | `201` with a token | - -Matching ignores case and surrounding whitespace. - -### The mocked token - -The mocked module's `fixtures.ts` holds a real serialized macaroon (location `optscale`, caveat -`mfa:none`) so -`utils/macaroons` deserializes it exactly like a token from the auth service, and the normal -`initialize` → `/initialize` redirect runs unchanged. It is **not** accepted by any backend: a -mocked sign-in gets into the app shell, then the first real API call returns 401. Swap in the real -transport to sign in against a live environment. diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/completeLoginChallenge.ts b/ngui/ui/src/themes/finops/api/auth/signIn/completeLoginChallenge.ts new file mode 100644 index 000000000..cd6755ee9 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/auth/signIn/completeLoginChallenge.ts @@ -0,0 +1,12 @@ +import { postAuth } from "./request"; +import { AuthToken, CompleteLoginChallenge } from "./types"; + +/** + * `POST /auth/v2/tokens` with a challenge — the MFA step of sign-in. `201` carries the token; a + * rejection is sorted by `getMfaVerificationFailure`. Mocked in dev by `api/mocked/`. + */ +export const completeLoginChallenge: CompleteLoginChallenge = async ({ challenge_id, method_id, code }) => { + const { data } = await postAuth("/tokens", { challenge_id, method_id, code }); + + return data; +}; diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/constants.ts b/ngui/ui/src/themes/finops/api/auth/signIn/constants.ts index 1bd532090..472633628 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/constants.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/constants.ts @@ -21,5 +21,16 @@ export const CREATE_TOKEN_STATUS = Object.freeze({ export const AUTH_ERROR_CODE = Object.freeze({ INVALID_CREDENTIALS: "OA0037", INTERNAL_ERROR: "OA0061", - EMAIL_NOT_VERIFIED: "OA0073" + EMAIL_NOT_VERIFIED: "OA0073", + // Our proposal, not in the auth service yet. + INVALID_MFA_CODE: "OA0075", + MFA_ATTEMPTS_EXCEEDED: "OA0076", + LOGIN_CHALLENGE_NOT_FOUND: "OA0077" +} as const); + +export const MFA_VERIFICATION_FAILURE = Object.freeze({ + INVALID_CODE: "invalid_code", + CHALLENGE_EXPIRED: "challenge_expired", + CHALLENGE_LOCKED: "challenge_locked", + UNEXPECTED: "unexpected" } as const); diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/createToken.ts b/ngui/ui/src/themes/finops/api/auth/signIn/createToken.ts index e24dfe80b..6bb3eeb42 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/createToken.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/createToken.ts @@ -5,8 +5,7 @@ import { CreateToken, CreateTokenResponse } from "./types"; * `POST /auth/v2/tokens` — the password step of sign-in. * * `201` carries a token, `202` a login challenge. The auth service does not return the `202` - * branch yet; in dev the mocked API plugin answers this route instead. See - * [`api/mocked/README.md`](../../mocked/README.md). + * branch yet; in dev the mocked API plugin in `api/mocked/` answers this route instead. */ export const createToken: CreateToken = async ({ email, password }) => // The service's contract is not something TypeScript can verify — this is the one cast. diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/errors.test.ts b/ngui/ui/src/themes/finops/api/auth/signIn/errors.test.ts index c8fa45cb4..da0c6d936 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/errors.test.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/errors.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { AUTH_ERROR_CODE } from "./constants"; -import { AuthApiError, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors"; +import { AUTH_ERROR_CODE, MFA_VERIFICATION_FAILURE } from "./constants"; +import { AuthApiError, getMfaVerificationFailure, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors"; const authError = (errorCode: string) => new AuthApiError({ status: 403, errorCode, reason: "nope" }); @@ -39,3 +39,36 @@ describe("the error predicates", () => { expect(isEmailNotVerifiedError(thrown)).toBe(false); }); }); + +describe("getMfaVerificationFailure", () => { + it("reads a wrong code as retryable, with the attempts the service says are left", () => { + const error = new AuthApiError({ status: 403, errorCode: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "nope", params: [3] }); + + expect(getMfaVerificationFailure(error)).toEqual({ kind: MFA_VERIFICATION_FAILURE.INVALID_CODE, attemptsLeft: 3 }); + }); + + it("keeps a wrong code retryable when the attempts count is missing or unreadable", () => { + const error = new AuthApiError({ status: 403, errorCode: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "nope", params: ["x"] }); + + expect(getMfaVerificationFailure(error)).toEqual({ kind: MFA_VERIFICATION_FAILURE.INVALID_CODE, attemptsLeft: null }); + }); + + it("reads the last wrong code as a locked challenge", () => { + const error = new AuthApiError({ status: 403, errorCode: AUTH_ERROR_CODE.MFA_ATTEMPTS_EXCEEDED, reason: "nope" }); + + expect(getMfaVerificationFailure(error).kind).toBe(MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED); + }); + + it("reads any 404 as an expired challenge, whatever code it carries", () => { + const error = new AuthApiError({ status: 404, errorCode: "OA0053", reason: "Not found" }); + + expect(getMfaVerificationFailure(error).kind).toBe(MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED); + }); + + it("does not call a network failure or an unknown 403 terminal", () => { + expect(getMfaVerificationFailure(new TypeError("Failed to fetch")).kind).toBe(MFA_VERIFICATION_FAILURE.UNEXPECTED); + expect(getMfaVerificationFailure(authError(AUTH_ERROR_CODE.INVALID_CREDENTIALS)).kind).toBe( + MFA_VERIFICATION_FAILURE.UNEXPECTED + ); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/errors.ts b/ngui/ui/src/themes/finops/api/auth/signIn/errors.ts index 7ece41a69..ebf81ffdf 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/errors.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/errors.ts @@ -1,19 +1,33 @@ -import { AUTH_ERROR_CODE } from "./constants"; +import { AUTH_ERROR_CODE, MFA_VERIFICATION_FAILURE } from "./constants"; +import { AuthErrorParam, MfaVerificationFailure } from "./types"; /** - * Carries the optscale error envelope `{"error": {"error_code": "OA00xx", "reason": "…"}}` - * so callers can branch on the code instead of on a message. + * Carries the optscale error envelope `{"error": {"error_code": "OA00xx", "reason": "…", "params": […]}}` + * so callers can branch on the code instead of on a message. `params` fill the `%s` in `reason`. */ export class AuthApiError extends Error { readonly status: number; readonly errorCode: string; - constructor({ status, errorCode, reason }: { status: number; errorCode: string; reason: string }) { + readonly params: AuthErrorParam[]; + + constructor({ + status, + errorCode, + reason, + params = [] + }: { + status: number; + errorCode: string; + reason: string; + params?: AuthErrorParam[]; + }) { super(reason); this.name = "AuthApiError"; this.status = status; this.errorCode = errorCode; + this.params = params; } } @@ -22,3 +36,31 @@ const hasErrorCode = (error: unknown, errorCode: string) => error instanceof Aut export const isInvalidCredentialsError = (error: unknown): boolean => hasErrorCode(error, AUTH_ERROR_CODE.INVALID_CREDENTIALS); export const isEmailNotVerifiedError = (error: unknown): boolean => hasErrorCode(error, AUTH_ERROR_CODE.EMAIL_NOT_VERIFIED); + +const toAttemptsLeft = (value: unknown) => { + const attemptsLeft = Number(value); + + return Number.isInteger(attemptsLeft) && attemptsLeft >= 0 ? attemptsLeft : null; +}; + +/** Sorts a rejected `completeLoginChallenge` into what the visitor can do next. */ +export const getMfaVerificationFailure = (error: unknown): MfaVerificationFailure => { + if (!(error instanceof AuthApiError)) { + return { kind: MFA_VERIFICATION_FAILURE.UNEXPECTED }; + } + + // Unknown, expired and consumed challenges all answer 404, whatever the code. + if (error.status === 404) { + return { kind: MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED }; + } + + if (error.errorCode === AUTH_ERROR_CODE.MFA_ATTEMPTS_EXCEEDED) { + return { kind: MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED }; + } + + if (error.errorCode === AUTH_ERROR_CODE.INVALID_MFA_CODE) { + return { kind: MFA_VERIFICATION_FAILURE.INVALID_CODE, attemptsLeft: toAttemptsLeft(error.params[0]) }; + } + + return { kind: MFA_VERIFICATION_FAILURE.UNEXPECTED }; +}; diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/index.ts b/ngui/ui/src/themes/finops/api/auth/signIn/index.ts index 64a639d1f..8b442d6cb 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/index.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/index.ts @@ -1,9 +1,20 @@ -export { AUTH_ERROR_CODE, CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE, LOGIN_METHOD, MFA_METHOD_TYPE } from "./constants"; +export { + AUTH_ERROR_CODE, + CREATE_TOKEN_STATUS, + LOGIN_CHALLENGE_STATE, + LOGIN_METHOD, + MFA_METHOD_TYPE, + MFA_VERIFICATION_FAILURE +} from "./constants"; +export { completeLoginChallenge } from "./completeLoginChallenge"; export { createToken } from "./createToken"; -export { AuthApiError, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors"; +export { AuthApiError, getMfaVerificationFailure, isEmailNotVerifiedError, isInvalidCredentialsError } from "./errors"; export { resolveLoginOptions } from "./loginOptions"; export type { + AuthErrorParam, AuthToken, + CompleteLoginChallenge, + CompleteLoginChallengeParams, CreateToken, CreateTokenParams, CreateTokenResponse, @@ -14,5 +25,7 @@ export type { LoginOptionsParams, MfaMethod, MfaMethodType, + MfaTerminalFailureReason, + MfaVerificationFailure, ResolveLoginOptions } from "./types"; diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/loginOptions.ts b/ngui/ui/src/themes/finops/api/auth/signIn/loginOptions.ts index 7d4001623..e7c7bc46e 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/loginOptions.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/loginOptions.ts @@ -4,8 +4,8 @@ import { LoginOptions, ResolveLoginOptions } from "./types"; /** * `POST /auth/v2/login_options` — the email step of sign-in. * - * The auth service does not expose this route yet; in dev the mocked API plugin answers it - * instead. See [`api/mocked/README.md`](../../mocked/README.md). + * The auth service does not expose this route yet; in dev the mocked API plugin in `api/mocked/` + * answers it instead. */ export const resolveLoginOptions: ResolveLoginOptions = async ({ email }) => { const { data } = await postAuth("/login_options", { email }); diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/request.test.ts b/ngui/ui/src/themes/finops/api/auth/signIn/request.test.ts index 287b6608d..72c0af86c 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/request.test.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/request.test.ts @@ -39,6 +39,18 @@ describe("postAuth", () => { }); }); + it("keeps the envelope's params, which carry values such as the attempts left", async () => { + respondWith(403, { error: { error_code: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "Invalid code", params: [2] } }); + + await expect(postAuth("/tokens", {})).rejects.toMatchObject({ params: [2] }); + }); + + it("drops params it cannot read rather than pass on a shifted list", async () => { + respondWith(403, { error: { error_code: AUTH_ERROR_CODE.INVALID_MFA_CODE, reason: "Invalid code", params: [{}, 2] } }); + + await expect(postAuth("/tokens", {})).rejects.toMatchObject({ params: [] }); + }); + it("still rejects with an AuthApiError when a gateway answers without the envelope", async () => { respondWith(502, null); diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/request.ts b/ngui/ui/src/themes/finops/api/auth/signIn/request.ts index 89b06f0e3..f642a1a93 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/request.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/request.ts @@ -1,15 +1,22 @@ import { getApiUrl } from "api/utils"; import { AuthApiError } from "./errors"; +import { AuthErrorParam } from "./types"; const AUTH_API_URL = getApiUrl("auth"); +// Parsed JSON is unchecked, so `params` stays `unknown` until `isAuthErrorParams` narrows it. type AuthErrorEnvelope = { error?: { error_code?: string; reason?: string; + params?: unknown; }; }; +// All or nothing: params are positional (`params[0]` is the attempts left), so dropping one would shift the rest. +const isAuthErrorParams = (value: unknown): value is AuthErrorParam[] => + Array.isArray(value) && value.every((param) => typeof param === "string" || typeof param === "number"); + /** * `POST` against the auth service. * @@ -33,7 +40,8 @@ export const postAuth = async (path: string, body: unknown): Promise<{ st throw new AuthApiError({ status: response.status, errorCode: error?.error_code ?? "", - reason: error?.reason ?? response.statusText + reason: error?.reason ?? response.statusText, + params: isAuthErrorParams(error?.params) ? error.params : [] }); } diff --git a/ngui/ui/src/themes/finops/api/auth/signIn/types.ts b/ngui/ui/src/themes/finops/api/auth/signIn/types.ts index 37d1e6029..6a13ff2ac 100644 --- a/ngui/ui/src/themes/finops/api/auth/signIn/types.ts +++ b/ngui/ui/src/themes/finops/api/auth/signIn/types.ts @@ -1,5 +1,14 @@ import { ObjectValues } from "utils/types"; -import { CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE, LOGIN_METHOD, MFA_METHOD_TYPE } from "./constants"; +import { + CREATE_TOKEN_STATUS, + LOGIN_CHALLENGE_STATE, + LOGIN_METHOD, + MFA_METHOD_TYPE, + MFA_VERIFICATION_FAILURE +} from "./constants"; + +/** A value filling a `%s` in an error's `reason` — the auth service passes strings and numbers. */ +export type AuthErrorParam = string | number; export type MfaMethodType = ObjectValues; @@ -59,3 +68,22 @@ export type CreateTokenResponse = | { status: typeof CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED; data: LoginChallenge }; export type CreateToken = (params: CreateTokenParams) => Promise; + +export type CompleteLoginChallengeParams = { + challenge_id: string; + method_id: string; + code: string; +}; + +export type CompleteLoginChallenge = (params: CompleteLoginChallengeParams) => Promise; + +export type MfaVerificationFailure = + | { kind: typeof MFA_VERIFICATION_FAILURE.INVALID_CODE; attemptsLeft: number | null } + | { kind: typeof MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED } + | { kind: typeof MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED } + | { kind: typeof MFA_VERIFICATION_FAILURE.UNEXPECTED }; + +/** The failures after which this challenge can never succeed — sign-in has to start over. */ +export type MfaTerminalFailureReason = + | typeof MFA_VERIFICATION_FAILURE.CHALLENGE_EXPIRED + | typeof MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED; diff --git a/ngui/ui/src/themes/finops/api/mocked/README.md b/ngui/ui/src/themes/finops/api/mocked/README.md deleted file mode 100644 index cb4ec029e..000000000 --- a/ngui/ui/src/themes/finops/api/mocked/README.md +++ /dev/null @@ -1,79 +0,0 @@ -# Mocked API - -Every stand-in for an endpoint the backend has not shipped yet lives here, and **nothing else -does**. The tree mirrors `api/` one level down, so a mock sits at the same path as the contract it -stands in for: - -``` -api/auth/signIn/createToken.ts ← the seam, imported by the app -api/mocked/auth/signIn/createTokenMock.ts ← what it currently points at -``` - -## How a mock is reached - -**Nothing in the app imports a mock.** The app always calls the real transport — `fetch` against -`/auth/v2/…` — and the mock is served _over HTTP by the dev server_: - -``` -TwoStepLoginForm → useLoginOptions → api/auth/signIn/loginOptions.ts - └─ fetch POST /api/auth/v2/login_options - └─ http/mockedApiPlugin.ts (dev server middleware) - └─ http/handlers.ts → resolveLoginOptionsMock -``` - -Two things follow from that, and both are the point: - -- The call **shows up in DevTools → Network** with its real status (`200`, `202`, `403`), real - request body and real response body. Each one is also logged to the dev-server terminal as - `[mocked-api] POST /auth/v2/tokens → 202`. -- The app already exercises `fetch`, the status branching and the error-envelope parsing. When - the backend ships, **nothing in `api/` changes** — you set `VITE_MOCKED_API=false` and the same - request goes upstream through Vite's proxy instead. - -`mockedApiPlugin.ts` registers before Vite's `/api` proxy and only inspects paths under -`/api/auth/v2/`. A route absent from `MOCKED_ROUTES` falls straight through to the real backend, -so turning this on does not shadow endpoints that already work. - -## The rules - -1. **A mock is reachable only through `MOCKED_ROUTES` in `http/handlers.ts`.** Adding an endpoint - means adding a row there, never an import somewhere in the app. - -2. **Types, constants and errors belong to the real module**, not here. A mock imports them from - `@theme/api/` so it is forced to answer in the contract's shape, and so the shape never - has to be kept in two places. - -3. **Response bodies are copied verbatim** from the design record or a real response capture. - Keep them as plain literals — no builders, no factories — so they can be diffed against the - live endpoint later. Cite the source in a comment at the top of the fixtures file. - -4. **Mocks carry their own specs.** They are pure async functions, which makes them the cheapest - thing in the theme to cover, and the spec is what catches a fixture drifting from its contract. - -## What is in here now - -| Path | Stands in for | Documented in | -| -------------- | ----------------------------------------------------- | ------------------------------------------------------- | -| `auth/signIn/` | `POST /auth/v2/login_options`, `POST /auth/v2/tokens` | [`api/auth/signIn/README.md`](../auth/signIn/README.md) | - -`mockDelay.ts` is shared by all of them: it holds every mocked call for ~600 ms so the pending -states in the UI are visible while clicking through. `http/` holds the dev-server side — -`handlers.ts` (the route table) and `mockedApiPlugin.ts` (the Vite middleware). - -## The two switches - -| Switch | Default | Controls | -| ---------------------------------- | --------- | ------------------------------------------------------------ | -| `mfa_login` in `theme.features.ts` | `false` | Whether the UI uses the two-step login at all | -| `VITE_MOCKED_API` in `.env` | on in dev | Whether the dev server answers the routes in `MOCKED_ROUTES` | - -Turn the feature flag on to see the flow; leave `VITE_MOCKED_API` alone until the backend ships -the endpoints, then set it to `false` to point the same requests at the real service. The -middleware is `apply: "serve"` — it does not exist in a production build, where these requests go -straight to the backend. - -## What the flag reaches - -The sign-in mocks are reached only when the `mfa_login` flag in -[`theme.features.ts`](../../theme.features.ts) is `true` — see the sign-in README for what each -scenario does. diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/challengeStore.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/challengeStore.ts new file mode 100644 index 000000000..0fd722307 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/challengeStore.ts @@ -0,0 +1,32 @@ +import { LoginChallenge } from "@theme/api/auth/signIn/types"; +import { getCurrentUTCTimeInSec } from "utils/datetime"; + +type StoredChallenge = Pick; + +// Lives as long as the dev server's module instance, like the service's login_challenge table. +const challenges = new Map(); + +export const storeChallenge = ({ challenge_id, expires_at, attempts_left, user_email }: LoginChallenge) => { + challenges.set(challenge_id, { expires_at, attempts_left, user_email }); +}; + +/** An expired challenge is as gone as a consumed one — the service answers both with 404. */ +export const findLiveChallenge = (challengeId: string, nowSeconds = getCurrentUTCTimeInSec()) => { + const challenge = challenges.get(challengeId); + + return challenge && challenge.expires_at > nowSeconds ? challenge : undefined; +}; + +export const recordFailedAttempt = (challengeId: string) => { + const challenge = challenges.get(challengeId); + + if (challenge) { + challenge.attempts_left -= 1; + } + + return challenge?.attempts_left ?? 0; +}; + +export const consumeChallenge = (challengeId: string) => { + challenges.delete(challengeId); +}; diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.test.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.test.ts new file mode 100644 index 000000000..2ce527c16 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it, vi } from "vitest"; +import { AUTH_ERROR_CODE } from "@theme/api/auth/signIn/constants"; +import { LoginChallenge } from "@theme/api/auth/signIn/types"; +import { MOCK_LOGIN_CHALLENGE, MOCK_SIGN_IN_CREDENTIALS } from "@theme/api/mocked/config"; +import { completeLoginChallengeMock } from "./completeLoginChallengeMock"; +import { createTokenMock } from "./createTokenMock"; +import { TOTP_METHOD_ID } from "./fixtures"; +import { SIGN_IN_SCENARIO, SignInScenario } from "./scenarios"; + +vi.mock("@theme/api/mocked/mockDelay", () => ({ delay: () => Promise.resolve() })); + +const { email, password, mfaCode } = MOCK_SIGN_IN_CREDENTIALS; + +const startChallenge = async (scenario: SignInScenario = SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT) => + (await createTokenMock({ email, password }, scenario)).data as LoginChallenge; + +const verify = (challengeId: string, code: string) => + completeLoginChallengeMock({ challenge_id: challengeId, method_id: TOTP_METHOD_ID, code }); + +describe("completeLoginChallengeMock", () => { + it("issues a token for the right code", async () => { + const { challenge_id } = await startChallenge(); + + await expect(verify(challenge_id, mfaCode)).resolves.toMatchObject({ user_email: email }); + }); + + it("consumes the challenge on success, so the same challenge answers 404 afterwards", async () => { + const { challenge_id } = await startChallenge(); + await verify(challenge_id, mfaCode); + + await expect(verify(challenge_id, mfaCode)).rejects.toMatchObject({ + status: 404, + errorCode: AUTH_ERROR_CODE.LOGIN_CHALLENGE_NOT_FOUND + }); + }); + + it("answers a wrong code with 403 and one attempt fewer, filled into the reason too", async () => { + const { challenge_id, attempts_left } = await startChallenge(); + + await expect(verify(challenge_id, "000000")).rejects.toMatchObject({ + status: 403, + errorCode: AUTH_ERROR_CODE.INVALID_MFA_CODE, + params: [attempts_left - 1], + message: `Invalid verification code. ${attempts_left - 1} attempts left` + }); + }); + + it("locks the challenge on the last wrong code, and nothing gets through it after that", async () => { + const { challenge_id } = await startChallenge(SIGN_IN_SCENARIO.MFA_LAST_ATTEMPT); + + await expect(verify(challenge_id, "000000")).rejects.toMatchObject({ + status: 403, + errorCode: AUTH_ERROR_CODE.MFA_ATTEMPTS_EXCEEDED + }); + await expect(verify(challenge_id, mfaCode)).rejects.toMatchObject({ status: 404 }); + }); + + it("answers an expired challenge with 404 even for the right code", async () => { + const { challenge_id } = await startChallenge(SIGN_IN_SCENARIO.MFA_EXPIRING); + vi.useFakeTimers({ now: Date.now() + (MOCK_LOGIN_CHALLENGE.SHORT_TTL_SECONDS + 1) * 1000 }); + + await expect(verify(challenge_id, mfaCode)).rejects.toMatchObject({ status: 404 }); + + vi.useRealTimers(); + }); + + it("answers an unknown challenge with 404", async () => { + await expect(verify("not-a-challenge", mfaCode)).rejects.toMatchObject({ status: 404 }); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.ts new file mode 100644 index 000000000..8db84fb73 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/completeLoginChallengeMock.ts @@ -0,0 +1,36 @@ +import { CompleteLoginChallenge } from "@theme/api/auth/signIn/types"; +import { MOCK_LATENCY_MS, MOCK_SIGN_IN_CREDENTIALS } from "@theme/api/mocked/config"; +import { delay } from "@theme/api/mocked/mockDelay"; +import { consumeChallenge, findLiveChallenge, recordFailedAttempt } from "./challengeStore"; +import { authError, ERROR_BODY, issueToken } from "./fixtures"; + +/** + * Stands in for `POST /auth/v2/tokens` with a challenge, following the TDR's sign-in workflow: a + * wrong code costs an attempt, the last wrong code consumes the challenge, a right one consumes it + * and issues the token. Each rejection is built from its file in `api/mocked/responses/`. + */ +export const completeLoginChallengeMock: CompleteLoginChallenge = async ({ challenge_id, code }) => { + await delay(MOCK_LATENCY_MS); + + const challenge = findLiveChallenge(challenge_id); + + if (!challenge) { + throw authError(ERROR_BODY.LOGIN_CHALLENGE_NOT_FOUND); + } + + if (code === MOCK_SIGN_IN_CREDENTIALS.mfaCode) { + consumeChallenge(challenge_id); + + return issueToken(challenge.user_email); + } + + const attemptsLeft = recordFailedAttempt(challenge_id); + + if (attemptsLeft <= 0) { + consumeChallenge(challenge_id); + + throw authError(ERROR_BODY.MFA_ATTEMPTS_EXCEEDED); + } + + throw authError(ERROR_BODY.INVALID_MFA_CODE, [attemptsLeft]); +}; diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.test.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.test.ts index 2875e420e..3ddbc0e1f 100644 --- a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.test.ts +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.test.ts @@ -2,80 +2,117 @@ import { describe, expect, it, vi } from "vitest"; import { AUTH_ERROR_CODE, CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE } from "@theme/api/auth/signIn/constants"; import { AuthApiError } from "@theme/api/auth/signIn/errors"; import { AuthToken, LoginChallenge } from "@theme/api/auth/signIn/types"; +import { MOCK_SIGN_IN_CREDENTIALS } from "@theme/api/mocked/config"; +import { getCurrentUTCTimeInSec } from "utils/datetime"; import macaroon from "utils/macaroons"; -import { createTokenMock, MOCK_PASSWORD } from "./createTokenMock"; -import { MOCK_USER_ID, TOTP_METHOD_ID } from "./fixtures"; +import { createTokenMock } from "./createTokenMock"; +import { EMAIL_METHOD_ID, MOCK_USER_ID, TOTP_METHOD_ID } from "./fixtures"; +import { SIGN_IN_SCENARIO, SignInScenario } from "./scenarios"; // The latency only exists to make pending states visible in the browser; waiting it out here // would add ~600 ms per test for nothing. -vi.mock("@theme/api/mocked/mockDelay", () => ({ MOCK_LATENCY_MS: 0, delay: () => Promise.resolve() })); +vi.mock("@theme/api/mocked/mockDelay", () => ({ delay: () => Promise.resolve() })); -const signIn = (email: string, password = MOCK_PASSWORD) => createTokenMock({ email, password }); +const { email, password } = MOCK_SIGN_IN_CREDENTIALS; + +const signIn = (scenario?: SignInScenario) => createTokenMock({ email, password }, scenario); + +const challengeFor = async (scenario: SignInScenario) => { + const { status, data } = await signIn(scenario); + + expect(status).toBe(CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED); + + return data as LoginChallenge; +}; describe("createTokenMock", () => { - it("issues a token for an account without MFA", async () => { - const { status, data } = await signIn("finops.ffc@example.com"); + it("issues a token when no scenario was picked, like an account without MFA", async () => { + const { status, data } = await signIn(); expect(status).toBe(CREATE_TOKEN_STATUS.TOKEN_ISSUED); - expect(data as AuthToken).toMatchObject({ - user_id: MOCK_USER_ID, - user_email: "finops.ffc@example.com" - }); + expect(data as AuthToken).toMatchObject({ user_id: MOCK_USER_ID, user_email: email }); }); it("issues a token the app can deserialize as a macaroon", async () => { - const { data } = await signIn("finops.ffc@example.com"); + const { data } = await signIn(SIGN_IN_SCENARIO.TOKEN_ISSUED); const caveats = macaroon.processCaveats(macaroon.deserialize((data as AuthToken).token).getCaveats()); expect(caveats).toEqual({ mfa: "none" }); }); - it("answers with an mfa_required challenge when the account has confirmed methods", async () => { - const { status, data } = await signIn("mfa.user@example.com"); - const challenge = data as LoginChallenge; + it("answers mfa_totp_default with the authenticator app as the only default", async () => { + const challenge = await challengeFor(SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT); - expect(status).toBe(CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED); expect(challenge.state).toBe(LOGIN_CHALLENGE_STATE.MFA_REQUIRED); expect(challenge.default_method_id).toBe(TOTP_METHOD_ID); - expect(challenge.methods).toHaveLength(2); + expect(challenge.methods.filter(({ is_default }) => is_default).map(({ id }) => id)).toEqual([TOTP_METHOD_ID]); + expect(challenge.code_sent).toBe(false); }); - it("answers with an enrollment_required challenge with no methods to pick from", async () => { - const { status, data } = await signIn("enroll.user@example.com"); + it("answers mfa_email_default with the e-mail default and its code already sent", async () => { + const challenge = await challengeFor(SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT); + + expect(challenge.default_method_id).toBe(EMAIL_METHOD_ID); + expect(challenge.methods.find(({ is_default }) => is_default)?.id).toBe(EMAIL_METHOD_ID); + expect(challenge.code_sent).toBe(true); + }); + + it("fills the per-request fields of the TDR body from the address that signed in", async () => { + const { data } = await createTokenMock({ email: "reto.mayer@example.com", password }, SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT); const challenge = data as LoginChallenge; - expect(status).toBe(CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED); + expect(challenge.user_email).toBe("reto.mayer@example.com"); + expect(challenge.methods.find(({ id }) => id === EMAIL_METHOD_ID)?.label).toBe("re***@example.com"); + }); + + it("answers mfa_no_default with confirmed methods and nothing marked default", async () => { + const challenge = await challengeFor(SIGN_IN_SCENARIO.MFA_NO_DEFAULT); + + expect(challenge.default_method_id).toBeNull(); + expect(challenge.methods.some(({ is_default }) => is_default)).toBe(false); + expect(challenge.methods.every(({ confirmed }) => confirmed)).toBe(true); + }); + + it("answers mfa_no_confirmed_methods with methods the UI must not offer", async () => { + const challenge = await challengeFor(SIGN_IN_SCENARIO.MFA_NO_CONFIRMED_METHODS); + + expect(challenge.methods.length).toBeGreaterThan(0); + expect(challenge.methods.some(({ confirmed }) => confirmed)).toBe(false); + }); + + it("answers enrollment_required with no methods to pick from", async () => { + const challenge = await challengeFor(SIGN_IN_SCENARIO.ENROLLMENT_REQUIRED); + expect(challenge.state).toBe(LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED); expect(challenge.default_method_id).toBeNull(); expect(challenge.methods).toEqual([]); }); it("dates the challenge in the future so the next step can trust expires_at", async () => { - const { data } = await signIn("mfa.user@example.com"); + const challenge = await challengeFor(SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT); - expect((data as LoginChallenge).expires_at).toBeGreaterThan(Math.floor(Date.now() / 1000)); + expect(challenge.expires_at).toBeGreaterThan(getCurrentUTCTimeInSec()); }); - it("rejects a wrong password as invalid credentials", async () => { - await expect(signIn("finops.ffc@example.com", "wrong-password")).rejects.toMatchObject({ + it("rejects email_not_verified with OA0073", async () => { + await expect(signIn(SIGN_IN_SCENARIO.EMAIL_NOT_VERIFIED)).rejects.toMatchObject({ status: 403, - errorCode: AUTH_ERROR_CODE.INVALID_CREDENTIALS + errorCode: AUTH_ERROR_CODE.EMAIL_NOT_VERIFIED }); }); - it("rejects the generic error scenario with an AuthApiError that is not invalid credentials", async () => { - await expect(signIn("error.user@example.com")).rejects.toBeInstanceOf(AuthApiError); - await expect(signIn("error.user@example.com")).rejects.toMatchObject({ + it("rejects internal_error with an AuthApiError that is not invalid credentials", async () => { + await expect(signIn(SIGN_IN_SCENARIO.INTERNAL_ERROR)).rejects.toBeInstanceOf(AuthApiError); + await expect(signIn(SIGN_IN_SCENARIO.INTERNAL_ERROR)).rejects.toMatchObject({ status: 500, errorCode: AUTH_ERROR_CODE.INTERNAL_ERROR }); }); - it("matches the scenario regardless of email casing or padding", async () => { - const { status, data } = await signIn(" MFA.User@Example.com "); - - expect(status).toBe(CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED); - expect((data as LoginChallenge).state).toBe(LOGIN_CHALLENGE_STATE.MFA_REQUIRED); + it("rejects a wrong password as invalid credentials whatever the scenario", async () => { + await expect( + createTokenMock({ email, password: "wrong-password" }, SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT) + ).rejects.toMatchObject({ status: 403, errorCode: AUTH_ERROR_CODE.INVALID_CREDENTIALS }); }); }); diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.ts index 26c9e4ea6..4f696c20d 100644 --- a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.ts +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/createTokenMock.ts @@ -1,96 +1,60 @@ -import { v4 as uuidv4 } from "uuid"; -import { AUTH_ERROR_CODE, CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE } from "@theme/api/auth/signIn/constants"; -import { AuthApiError } from "@theme/api/auth/signIn/errors"; -import { CreateToken, LoginChallenge, LoginChallengeState } from "@theme/api/auth/signIn/types"; -import { delay, MOCK_LATENCY_MS } from "@theme/api/mocked/mockDelay"; -import { getAuthToken, getConfirmedMethods, TOTP_METHOD_ID } from "./fixtures"; - -/** The only password the mock accepts — anything else answers like invalid credentials. */ -export const MOCK_PASSWORD = "password"; - -/** - * Outcome is selected from the email local part so every branch is reachable by typing an - * address into the login form. See the README next to this file. - */ -export const MOCK_EMAIL_PREFIX = Object.freeze({ - MFA_REQUIRED: "mfa", - ENROLLMENT_REQUIRED: "enroll", - EMAIL_NOT_VERIFIED: "unverified", - INTERNAL_ERROR: "error" -} as const); - -const CHALLENGE_TTL_SECONDS = 5 * 60; - -const MAX_ATTEMPTS = 5; - -const getLocalPart = (email: string) => email.trim().toLowerCase().split("@")[0] ?? ""; - -const buildChallenge = (email: string, state: LoginChallengeState): LoginChallenge => { - const requiresEnrollment = state === LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED; - - return { - challenge_id: uuidv4(), - state, - expires_at: Math.floor(Date.now() / 1000) + CHALLENGE_TTL_SECONDS, - user_email: email, - methods: requiresEnrollment ? [] : getConfirmedMethods(email), - default_method_id: requiresEnrollment ? null : TOTP_METHOD_ID, - // The default method is the authenticator app, which generates its code locally. - code_sent: false, - attempts_left: MAX_ATTEMPTS - }; +import { CREATE_TOKEN_STATUS } from "@theme/api/auth/signIn/constants"; +import { CreateToken, LoginChallenge } from "@theme/api/auth/signIn/types"; +import { MOCK_LATENCY_MS, MOCK_LOGIN_CHALLENGE, MOCK_SIGN_IN_CREDENTIALS } from "@theme/api/mocked/config"; +import { delay } from "@theme/api/mocked/mockDelay"; +import { storeChallenge } from "./challengeStore"; +import { authError, CHALLENGE_BODY, ERROR_BODY, issueChallenge, issueToken } from "./fixtures"; +import { DEFAULT_SIGN_IN_SCENARIO, SIGN_IN_SCENARIO, SignInScenario } from "./scenarios"; + +type ChallengeScenario = Exclude< + SignInScenario, + typeof SIGN_IN_SCENARIO.TOKEN_ISSUED | typeof SIGN_IN_SCENARIO.EMAIL_NOT_VERIFIED | typeof SIGN_IN_SCENARIO.INTERNAL_ERROR +>; + +// Keyed by every challenge scenario, so a new one fails to compile until it names its response. +const CHALLENGE_RESPONSE: Record = { + [SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT]: { body: CHALLENGE_BODY.MFA_TOTP_DEFAULT }, + [SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT]: { body: CHALLENGE_BODY.MFA_EMAIL_DEFAULT }, + [SIGN_IN_SCENARIO.MFA_NO_DEFAULT]: { body: CHALLENGE_BODY.MFA_NO_DEFAULT }, + [SIGN_IN_SCENARIO.MFA_NO_CONFIRMED_METHODS]: { body: CHALLENGE_BODY.MFA_NO_CONFIRMED_METHODS }, + [SIGN_IN_SCENARIO.MFA_LAST_ATTEMPT]: { body: CHALLENGE_BODY.MFA_TOTP_DEFAULT, attemptsLeft: 1 }, + [SIGN_IN_SCENARIO.MFA_EXPIRING]: { + body: CHALLENGE_BODY.MFA_TOTP_DEFAULT, + ttlSeconds: MOCK_LOGIN_CHALLENGE.SHORT_TTL_SECONDS + }, + [SIGN_IN_SCENARIO.ENROLLMENT_REQUIRED]: { body: CHALLENGE_BODY.ENROLLMENT_REQUIRED } }; /** * Stands in for `POST /auth/v2/tokens` until the auth service grows the challenge branch. - * Resolves with the status and body the real endpoint would return, and rejects with an - * {@link AuthApiError} carrying the optscale error envelope. + * Resolves with the status and body the real endpoint would return for `scenario`, and rejects + * with an `AuthApiError` built from the matching error response. Typed off `CreateToken`, so a + * contract change breaks the mock at compile time. */ -export const createTokenMock: CreateToken = async ({ email, password }) => { +export const createTokenMock = async ( + { email, password }: Parameters[0], + scenario: SignInScenario = DEFAULT_SIGN_IN_SCENARIO +): ReturnType => { await delay(MOCK_LATENCY_MS); - const localPart = getLocalPart(email); - - if (password !== MOCK_PASSWORD) { - throw new AuthApiError({ - status: 403, - errorCode: AUTH_ERROR_CODE.INVALID_CREDENTIALS, - reason: "Email or password is invalid" - }); - } - - if (localPart.startsWith(MOCK_EMAIL_PREFIX.EMAIL_NOT_VERIFIED)) { - throw new AuthApiError({ - status: 403, - errorCode: AUTH_ERROR_CODE.EMAIL_NOT_VERIFIED, - reason: "Email is not verified" - }); - } - - if (localPart.startsWith(MOCK_EMAIL_PREFIX.INTERNAL_ERROR)) { - throw new AuthApiError({ - status: 500, - errorCode: AUTH_ERROR_CODE.INTERNAL_ERROR, - reason: "Database error" - }); - } - - if (localPart.startsWith(MOCK_EMAIL_PREFIX.ENROLLMENT_REQUIRED)) { - return { - status: CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED, - data: buildChallenge(email, LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED) - }; + if (password !== MOCK_SIGN_IN_CREDENTIALS.password) { + throw authError(ERROR_BODY.INVALID_CREDENTIALS); } - if (localPart.startsWith(MOCK_EMAIL_PREFIX.MFA_REQUIRED)) { - return { - status: CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED, - data: buildChallenge(email, LOGIN_CHALLENGE_STATE.MFA_REQUIRED) - }; + switch (scenario) { + case SIGN_IN_SCENARIO.TOKEN_ISSUED: + return { status: CREATE_TOKEN_STATUS.TOKEN_ISSUED, data: issueToken(email) }; + case SIGN_IN_SCENARIO.EMAIL_NOT_VERIFIED: + throw authError(ERROR_BODY.EMAIL_NOT_VERIFIED); + case SIGN_IN_SCENARIO.INTERNAL_ERROR: + throw authError(ERROR_BODY.INTERNAL_ERROR); + default: { + const { body, ...fill } = CHALLENGE_RESPONSE[scenario]; + const challenge = issueChallenge(body, email, fill); + + storeChallenge(challenge); + + return { status: CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED, data: challenge }; + } } - - return { - status: CREATE_TOKEN_STATUS.TOKEN_ISSUED, - data: getAuthToken(email) - }; }; diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.test.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.test.ts new file mode 100644 index 000000000..d413c90b3 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from "vitest"; +import { AUTH_ERROR_CODE, LOGIN_CHALLENGE_STATE, LOGIN_METHOD, MFA_METHOD_TYPE } from "@theme/api/auth/signIn/constants"; +import { CHALLENGE_BODY, ERROR_BODY, LOGIN_OPTIONS_BODY } from "./fixtures"; + +// `tsc` checks that every response file has the contract's keys; these check the values JSON cannot type. +describe("the JSON responses", () => { + it.each(Object.entries(CHALLENGE_BODY))("%s uses only states and method types the contract knows", (_, body) => { + expect(Object.values(LOGIN_CHALLENGE_STATE)).toContain(body.state); + expect(Object.values(MFA_METHOD_TYPE)).toEqual(expect.arrayContaining(body.methods.map(({ type }) => type))); + }); + + it("offers only login methods the contract knows", () => { + expect(Object.values(LOGIN_METHOD)).toEqual(expect.arrayContaining(LOGIN_OPTIONS_BODY.methods)); + }); + + it.each(Object.entries(ERROR_BODY))("%s carries the code AUTH_ERROR_CODE names it by", (name, body) => { + expect(body.error.error_code).toBe(AUTH_ERROR_CODE[name as keyof typeof AUTH_ERROR_CODE]); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.ts index d60436b94..317ebbb37 100644 --- a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.ts +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/fixtures.ts @@ -1,25 +1,86 @@ +import { v4 as uuidv4 } from "uuid"; import { MFA_METHOD_TYPE } from "@theme/api/auth/signIn/constants"; -import { AuthToken, MfaMethod } from "@theme/api/auth/signIn/types"; +import { AuthApiError } from "@theme/api/auth/signIn/errors"; +import { + AuthErrorParam, + AuthToken, + LoginChallenge, + LoginOptions, + MfaMethod, + MfaMethodType +} from "@theme/api/auth/signIn/types"; +import { MOCK_LOGIN_CHALLENGE } from "@theme/api/mocked/config"; +import loginOptions from "@theme/api/mocked/responses/auth/v2/login_options/200.json"; +import token from "@theme/api/mocked/responses/auth/v2/tokens/201-token.json"; +import enrollmentRequired from "@theme/api/mocked/responses/auth/v2/tokens/202-enrollment-required.json"; +import mfaEmailDefault from "@theme/api/mocked/responses/auth/v2/tokens/202-mfa-email-default.json"; +import mfaNoConfirmedMethods from "@theme/api/mocked/responses/auth/v2/tokens/202-mfa-no-confirmed-methods.json"; +import mfaNoDefault from "@theme/api/mocked/responses/auth/v2/tokens/202-mfa-no-default.json"; +import mfaTotpDefault from "@theme/api/mocked/responses/auth/v2/tokens/202-mfa-totp-default.json"; +import emailNotVerified from "@theme/api/mocked/responses/auth/v2/tokens/403-email-not-verified.json"; +import invalidCredentials from "@theme/api/mocked/responses/auth/v2/tokens/403-invalid-credentials.json"; +import invalidMfaCode from "@theme/api/mocked/responses/auth/v2/tokens/403-invalid-mfa-code.json"; +import mfaAttemptsExceeded from "@theme/api/mocked/responses/auth/v2/tokens/403-mfa-attempts-exceeded.json"; +import loginChallengeNotFound from "@theme/api/mocked/responses/auth/v2/tokens/404-login-challenge-not-found.json"; +import internalError from "@theme/api/mocked/responses/auth/v2/tokens/500-internal-error.json"; +import { getCurrentUTCTimeInSec } from "utils/datetime"; /** - * Response bodies taken from the "FFC: TDR FinOps for Cloud MFA" design record - * (Obtain token / Complete MFA challenge). Kept verbatim so they can be diffed against the - * real endpoint once the auth service ships the challenge branch. + * The typed side of `api/mocked/responses/` — the bodies themselves are JSON, copied from the + * "FFC: TDR FinOps for Cloud MFA" design record. + * + * A JSON import widens `"totp"` to `string`, so a body cannot be declared as its contract type. A plain + * `as` would also accept a file that lost a field, so these first demand every contract key: a renamed + * or deleted field fails `tsc`. Enum values are checked in `fixtures.test.ts`. */ -export const MOCK_USER_ID = "9c2a1f3e-5b47-4a21-8f0c-1d6e8b3a7c42"; +type ErrorBody = { error: { status_code: number; error_code: string; reason: string; params: AuthErrorParam[] } }; -/** - * A real serialized macaroon: location "optscale", identifier MOCK_USER_ID, caveat "mfa:none". - * Minted once with node-macaroons and pasted here so the mock stays a plain data module while - * `utils/macaroons` can still deserialize it exactly like a token from the auth service. - */ -export const MOCK_TOKEN = - "MDAxNmxvY2F0aW9uIG9wdHNjYWxlCjAwMzRpZGVudGlmaWVyIDljMmExZjNlLTViNDctNGEyMS04ZjBjLTFkNmU4YjNhN2M0MgowMDExY2lkIG1mYTpub25lCjAwMmZzaWduYXR1cmUgrrkLrCmb0VcdYWsrr-lUxd9rs-V-iq_yANUMhSBaeHIK"; +const asLoginChallenge = (body: Record & { methods: Record[] }) => + body as LoginChallenge; + +const asErrorBody = (body: { error: Record }) => body as ErrorBody; -export const TOTP_METHOD_ID = "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32"; +const asLoginOptions = (body: Record) => body as LoginOptions; -export const EMAIL_METHOD_ID = "b2e47d91-6c03-4f8a-b1d2-5e9a3c7f0481"; +export const LOGIN_OPTIONS_BODY = asLoginOptions(loginOptions); + +export const TOKEN_BODY: AuthToken = token; + +export const CHALLENGE_BODY = Object.freeze({ + MFA_TOTP_DEFAULT: asLoginChallenge(mfaTotpDefault), + MFA_EMAIL_DEFAULT: asLoginChallenge(mfaEmailDefault), + MFA_NO_DEFAULT: asLoginChallenge(mfaNoDefault), + // Breaks the contract on purpose — the service lists confirmed methods only — to reach the UI's guard. + MFA_NO_CONFIRMED_METHODS: asLoginChallenge(mfaNoConfirmedMethods), + ENROLLMENT_REQUIRED: asLoginChallenge(enrollmentRequired) +}); + +// Keys match `AUTH_ERROR_CODE`'s, so `fixtures.test.ts` can pair each file with its code. +export const ERROR_BODY = Object.freeze({ + INVALID_CREDENTIALS: asErrorBody(invalidCredentials), + EMAIL_NOT_VERIFIED: asErrorBody(emailNotVerified), + INTERNAL_ERROR: asErrorBody(internalError), + INVALID_MFA_CODE: asErrorBody(invalidMfaCode), + MFA_ATTEMPTS_EXCEEDED: asErrorBody(mfaAttemptsExceeded), + LOGIN_CHALLENGE_NOT_FOUND: asErrorBody(loginChallengeNotFound) +}); + +export const { user_id: MOCK_USER_ID } = TOKEN_BODY; + +const getMethodId = (type: MfaMethodType) => { + const method = CHALLENGE_BODY.MFA_TOTP_DEFAULT.methods.find((candidate) => candidate.type === type); + + if (!method) { + throw new Error(`202-mfa-totp-default.json has no ${type} method`); + } + + return method.id; +}; + +export const TOTP_METHOD_ID = getMethodId(MFA_METHOD_TYPE.TOTP); + +export const EMAIL_METHOD_ID = getMethodId(MFA_METHOD_TYPE.EMAIL); const maskEmail = (email: string) => { const [localPart = "", domain = ""] = email.split("@"); @@ -27,29 +88,33 @@ const maskEmail = (email: string) => { return `${localPart.slice(0, 2)}***@${domain}`; }; -export const getConfirmedMethods = (email: string): MfaMethod[] => [ - { - id: TOTP_METHOD_ID, - type: MFA_METHOD_TYPE.TOTP, - label: "Configured on Google Pixel 10 Pro", - is_default: true, - confirmed: true - }, - { - id: EMAIL_METHOD_ID, - type: MFA_METHOD_TYPE.EMAIL, - label: maskEmail(email), - is_default: false, - confirmed: true - } -]; - -export const getAuthToken = (email: string): AuthToken => ({ - token: MOCK_TOKEN, - user_id: MOCK_USER_ID, +/** The fields the service fills per request: a fresh id, an expiry from now, the address that signed in. */ +export const issueChallenge = ( + body: LoginChallenge, + email: string, + { ttlSeconds = MOCK_LOGIN_CHALLENGE.TTL_SECONDS, attemptsLeft = body.attempts_left } = {} +): LoginChallenge => ({ + ...body, + challenge_id: uuidv4(), + expires_at: getCurrentUTCTimeInSec() + ttlSeconds, user_email: email, - created_at: "2026-09-22T09:00:00", - valid_until: "2026-09-29T09:00:00", - ip: "127.0.0.1", - digest: "f0a1b2c3d4e5f60718293a4b5c6d7e8f" + attempts_left: attemptsLeft, + // The service labels an e-mail method with the masked address it sends to. + methods: body.methods.map((method) => + method.type === MFA_METHOD_TYPE.EMAIL ? { ...method, label: maskEmail(email) } : method + ) }); + +export const issueToken = (email: string): AuthToken => ({ ...TOKEN_BODY, user_email: email }); + +/** Fills each `%s` in `reason` with the next param, the way the auth service's exceptions do. */ +const formatReason = (reason: string, params: AuthErrorParam[]) => + params.reduce((formatted, param) => formatted.replace("%s", String(param)), reason); + +export const authError = ({ error }: ErrorBody, params: AuthErrorParam[] = error.params) => + new AuthApiError({ + status: error.status_code, + errorCode: error.error_code, + reason: formatReason(error.reason, params), + params + }); diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/index.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/index.ts index e761551a5..b1a97ecbe 100644 --- a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/index.ts +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/index.ts @@ -1,3 +1,4 @@ -export { createTokenMock, MOCK_EMAIL_PREFIX, MOCK_PASSWORD } from "./createTokenMock"; -export { EMAIL_METHOD_ID, MOCK_TOKEN, MOCK_USER_ID, TOTP_METHOD_ID } from "./fixtures"; +// What the route table needs; scenarios and fixtures are imported from their own files. +export { completeLoginChallengeMock } from "./completeLoginChallengeMock"; +export { createTokenMock } from "./createTokenMock"; export { resolveLoginOptionsMock } from "./loginOptionsMock"; diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.test.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.test.ts index dccd7aae2..dbe0946ce 100644 --- a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.test.ts +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.test.ts @@ -4,7 +4,7 @@ import { resolveLoginOptionsMock } from "./loginOptionsMock"; // The latency only exists to make pending states visible in the browser; waiting it out here // would add ~600 ms per test for nothing. -vi.mock("@theme/api/mocked/mockDelay", () => ({ MOCK_LATENCY_MS: 0, delay: () => Promise.resolve() })); +vi.mock("@theme/api/mocked/mockDelay", () => ({ delay: () => Promise.resolve() })); describe("resolveLoginOptionsMock", () => { it("offers the password method so the email step can hand over to the password step", async () => { diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.ts index a8ed4c413..dd95bb1ce 100644 --- a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.ts +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/loginOptionsMock.ts @@ -1,6 +1,7 @@ -import { LOGIN_METHOD } from "@theme/api/auth/signIn/constants"; import { ResolveLoginOptions } from "@theme/api/auth/signIn/types"; -import { delay, MOCK_LATENCY_MS } from "@theme/api/mocked/mockDelay"; +import { MOCK_LATENCY_MS } from "@theme/api/mocked/config"; +import { delay } from "@theme/api/mocked/mockDelay"; +import { LOGIN_OPTIONS_BODY } from "./fixtures"; /** * Stands in for `POST /auth/v2/login_options` — step 1 of the two-step login. @@ -12,8 +13,5 @@ import { delay, MOCK_LATENCY_MS } from "@theme/api/mocked/mockDelay"; export const resolveLoginOptionsMock: ResolveLoginOptions = async () => { await delay(MOCK_LATENCY_MS); - return { - methods: [LOGIN_METHOD.PASSWORD], - sso: { state: "not_configured" } - }; + return LOGIN_OPTIONS_BODY; }; diff --git a/ngui/ui/src/themes/finops/api/mocked/auth/signIn/scenarios.ts b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/scenarios.ts new file mode 100644 index 000000000..e817b74da --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/auth/signIn/scenarios.ts @@ -0,0 +1,35 @@ +import { ObjectValues } from "utils/types"; + +/** What the mocked `POST /auth/v2/tokens` answers to a correct password. Picked in the dev panel. */ +export const SIGN_IN_SCENARIO = Object.freeze({ + TOKEN_ISSUED: "token_issued", + MFA_TOTP_DEFAULT: "mfa_totp_default", + MFA_EMAIL_DEFAULT: "mfa_email_default", + MFA_NO_DEFAULT: "mfa_no_default", + MFA_NO_CONFIRMED_METHODS: "mfa_no_confirmed_methods", + MFA_LAST_ATTEMPT: "mfa_last_attempt", + MFA_EXPIRING: "mfa_expiring", + ENROLLMENT_REQUIRED: "enrollment_required", + EMAIL_NOT_VERIFIED: "email_not_verified", + INTERNAL_ERROR: "internal_error" +} as const); + +export type SignInScenario = ObjectValues; + +export const DEFAULT_SIGN_IN_SCENARIO: SignInScenario = SIGN_IN_SCENARIO.TOKEN_ISSUED; + +export const SIGN_IN_SCENARIO_DESCRIPTION: Record = { + [SIGN_IN_SCENARIO.TOKEN_ISSUED]: "201 token — no MFA, straight into the app", + [SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT]: "202 mfa_required — authenticator app is the default", + [SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT]: "202 mfa_required — e-mail is the default, code already sent", + [SIGN_IN_SCENARIO.MFA_NO_DEFAULT]: "202 mfa_required — no default method, opens the method picker", + [SIGN_IN_SCENARIO.MFA_NO_CONFIRMED_METHODS]: "202 mfa_required — no confirmed method (contract edge case)", + [SIGN_IN_SCENARIO.MFA_LAST_ATTEMPT]: "202 mfa_required — one attempt left, a wrong code locks the challenge", + [SIGN_IN_SCENARIO.MFA_EXPIRING]: "202 mfa_required — expires 30 seconds after sign-in", + [SIGN_IN_SCENARIO.ENROLLMENT_REQUIRED]: "202 enrollment_required — MFA enforced, nothing configured", + [SIGN_IN_SCENARIO.EMAIL_NOT_VERIFIED]: "403 OA0073 — email not verified", + [SIGN_IN_SCENARIO.INTERNAL_ERROR]: "500 OA0061 — generic error" +}; + +export const isSignInScenario = (value: unknown): value is SignInScenario => + Object.values(SIGN_IN_SCENARIO).includes(value); diff --git a/ngui/ui/src/themes/finops/api/mocked/config.test.ts b/ngui/ui/src/themes/finops/api/mocked/config.test.ts new file mode 100644 index 000000000..9cf276a6e --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/config.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { isMockedApiEnabled } from "./config"; + +describe("isMockedApiEnabled", () => { + it("is on while the mfa_login flag is on and nothing opts out", () => { + expect(isMockedApiEnabled({ mfaLogin: true })).toBe(true); + }); + + it("is off when mfa_login is off, so the legacy login never meets a mock", () => { + expect(isMockedApiEnabled({ mfaLogin: false })).toBe(false); + expect(isMockedApiEnabled({})).toBe(false); + }); + + it("lets VITE_MOCKED_API=false point the new UI at the real backend", () => { + expect(isMockedApiEnabled({ mfaLogin: true, mockedApiEnv: "false" })).toBe(false); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/mocked/config.ts b/ngui/ui/src/themes/finops/api/mocked/config.ts new file mode 100644 index 000000000..f57fd5dba --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/config.ts @@ -0,0 +1,40 @@ +/** + * Every setting of the mocked API in one place. `vite.config.mts` and `http/mockedApiPlugin.ts` load + * this file without the path aliases, so it must not import anything. + * + * Response bodies are not settings — they are JSON files in `responses/`. + */ + +/** Requests under this prefix are matched against the mocked routes; the rest go straight to the proxy. */ +export const MOCKED_PATH_PREFIX = "/api/auth/v2/"; + +export const MOCKED_HANDLERS_MODULE = "/src/themes/finops/api/mocked/http/handlers.ts"; + +/** Every mocked call waits this long, so the pending states are visible while clicking through. */ +export const MOCK_LATENCY_MS = 600; + +/** Any valid address signs in; the password and the MFA code are what the mock checks. */ +export const MOCK_SIGN_IN_CREDENTIALS = Object.freeze({ + email: "finops.ffc@example.com", + password: "password", + mfaCode: "123456" +}); + +/** Challenge lifetimes, from the TDR's `LoginChallenge` model. Attempts come with each 202 body. */ +export const MOCK_LOGIN_CHALLENGE = Object.freeze({ + TTL_SECONDS: 5 * 60, + // The `mfa_expiring` scenario, short enough to watch the countdown run out. + SHORT_TTL_SECONDS: 10 +}); + +/** Carries the dev panel's scenario to the dev-server middleware alongside the app's own `fetch`. */ +export const SIGN_IN_SCENARIO_COOKIE = "finops_mocked_sign_in_scenario"; + +export const DEV_PANEL_COLLAPSED_STORAGE_KEY = "finops_mocked_api_dev_panel_collapsed"; + +/** + * Whether the middleware answers and the dev panel shows — one rule for both, so they cannot disagree. + * On while the `mfa_login` flag is; `VITE_MOCKED_API=false` points the new UI at a real backend. + */ +export const isMockedApiEnabled = ({ mfaLogin, mockedApiEnv }: { mfaLogin?: boolean; mockedApiEnv?: string }) => + mfaLogin === true && mockedApiEnv !== "false"; diff --git a/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.styles.ts b/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.styles.ts new file mode 100644 index 000000000..f16f23ec3 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.styles.ts @@ -0,0 +1,54 @@ +import { makeStyles } from "tss-react/mui"; +import { MPT_BRAND_PRIMARY, MPT_BRAND_WHITE, MPT_GRAY_1, MPT_GRAY_4, MPT_SPACING_1, MPT_SPACING_2 } from "@theme/utils/layouts"; + +const useStyles = makeStyles()((theme) => ({ + panel: { + position: "fixed", + right: MPT_SPACING_2, + bottom: MPT_SPACING_2, + width: 380, + maxHeight: `calc(100vh - 2 * ${MPT_SPACING_2})`, + overflowY: "auto", + zIndex: theme.zIndex.snackbar, + textAlign: "left" + }, + header: { + display: "flex", + alignItems: "center", + justifyContent: "space-between", + padding: `${MPT_SPACING_1} ${MPT_SPACING_2}`, + backgroundColor: MPT_BRAND_PRIMARY, + color: MPT_BRAND_WHITE, + cursor: "pointer" + }, + body: { + padding: MPT_SPACING_2 + }, + code: { + fontFamily: "monospace", + backgroundColor: MPT_GRAY_1, + padding: "0 4px", + borderRadius: 4 + }, + hint: { + color: MPT_GRAY_4 + }, + details: { + display: "grid", + gridTemplateColumns: "auto 1fr", + columnGap: MPT_SPACING_2, + rowGap: 2, + margin: 0, + fontSize: theme.typography.caption.fontSize, + "& dt": { + color: MPT_GRAY_4 + }, + "& dd": { + margin: 0, + fontFamily: "monospace", + wordBreak: "break-all" + } + } +})); + +export default useStyles; diff --git a/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.test.tsx b/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.test.tsx new file mode 100644 index 000000000..f78e3d6ae --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.test.tsx @@ -0,0 +1,145 @@ +import { act } from "react-dom/test-utils"; +import { Route } from "react-router-dom"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE, MFA_VERIFICATION_FAILURE } from "@theme/api/auth/signIn"; +import { LoginChallenge } from "@theme/api/auth/signIn/types"; +import { CHALLENGE_BODY } from "@theme/api/mocked/auth/signIn/fixtures"; +import { SIGN_IN_SCENARIO } from "@theme/api/mocked/auth/signIn/scenarios"; +import { MOCK_SIGN_IN_CREDENTIALS, SIGN_IN_SCENARIO_COOKIE } from "@theme/api/mocked/config"; +import { readSignInScenarioCookie } from "@theme/api/mocked/http/signInScenarioCookie"; +import MfaChallengeStep from "@theme/components/MfaChallengeStep"; +import { SignInRouterState } from "@theme/hooks/useLoginChallenge"; +import { renderWithProviders } from "@theme/tests/renderWithProviders"; +import { MFA_CHALLENGE } from "@theme/urls"; +import { LOGIN } from "urls"; +import { getCurrentUTCTimeInSec } from "utils/datetime"; +import MockedApiDevPanel from "./MockedApiDevPanel"; + +const [totpMethod, emailMethod] = CHALLENGE_BODY.MFA_TOTP_DEFAULT.methods; + +const totpDefaultChallenge: LoginChallenge = { + challenge_id: "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", + state: LOGIN_CHALLENGE_STATE.MFA_REQUIRED, + expires_at: getCurrentUTCTimeInSec() + 5 * 60, + user_email: "finops.ffc@example.com", + methods: [totpMethod, emailMethod], + default_method_id: totpMethod.id, + code_sent: false, + attempts_left: 5 +}; + +const noDefaultChallenge: LoginChallenge = { + ...totpDefaultChallenge, + challenge_id: "7e3a5d1f-4c2b-4c8f-8e2f-1a5d3b7c9f02", + methods: [{ ...totpMethod, is_default: false }, emailMethod], + default_method_id: null +}; + +const createTokenSpy = vi.fn(); + +// The transport is the boundary: the panel's own routing and the MFA screen stay real. +vi.mock("@theme/api/auth/signIn", async (importOriginal) => ({ + ...(await importOriginal()), + createToken: (params: unknown) => createTokenSpy(params) +})); + +const renderAt = (initialEntry: { pathname: string; state?: SignInRouterState }) => { + const { container, find, text, unmount } = renderWithProviders({ + initialEntry, + routes: ( + <> + + + + + } + /> + } /> + + ) + }); + + return { + find, + text, + selectScenario: (scenario: string) => + act(() => { + const select = container.querySelector("select")!; + select.value = scenario; + select.dispatchEvent(new Event("change", { bubbles: true })); + }), + unmount + }; +}; + +describe("MockedApiDevPanel", () => { + afterEach(() => { + document.cookie = `${SIGN_IN_SCENARIO_COOKIE}=; path=/; max-age=0`; + createTokenSpy.mockReset(); + }); + + it("shows the credentials the mock accepts", () => { + const panel = renderAt({ pathname: LOGIN }); + + expect(panel.text("p_dev_credentials_email")).toContain(MOCK_SIGN_IN_CREDENTIALS.email); + expect(panel.text("p_dev_credentials_password")).toContain(MOCK_SIGN_IN_CREDENTIALS.password); + + panel.unmount(); + }); + + it("stores the picked scenario where the dev-server mock reads it", () => { + const panel = renderAt({ pathname: LOGIN }); + + panel.selectScenario(SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT); + + expect(readSignInScenarioCookie(document.cookie)).toBe(SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT); + expect(panel.text("p_dev_scenario_description")).toContain("e-mail is the default"); + + panel.unmount(); + }); + + it("shows which MFA screen is up, as reported by the challenge itself", () => { + const panel = renderAt({ pathname: MFA_CHALLENGE, state: { challenge: totpDefaultChallenge } }); + + expect(panel.text("p_dev_route")).toBe("MFA challenge"); + expect(panel.text("p_dev_mfa_stage")).toBe("MfaCodeEntry — authenticator app (TOTP) · default method"); + expect(panel.text("dl_dev_challenge")).toContain(totpDefaultChallenge.challenge_id); + + panel.unmount(); + }); + + it("re-runs sign-in with the current address and opens the fresh challenge from its own default", async () => { + createTokenSpy.mockResolvedValue({ status: CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED, data: noDefaultChallenge }); + const panel = renderAt({ pathname: MFA_CHALLENGE, state: { challenge: totpDefaultChallenge } }); + + await act(async () => panel.find("btn_dev_rerun_sign_in")!.click()); + + expect(createTokenSpy).toHaveBeenCalledWith({ + email: totpDefaultChallenge.user_email, + password: MOCK_SIGN_IN_CREDENTIALS.password + }); + expect(panel.text("p_dev_mfa_stage")).toBe("MfaMethodPicker — choosing a method"); + expect(panel.text("dl_dev_challenge")).toContain(noDefaultChallenge.challenge_id); + + panel.unmount(); + }); + + it("logs why a challenge ended to the console, not the panel", () => { + const consoleInfo = vi.spyOn(console, "info").mockImplementation(() => {}); + const panel = renderAt({ + pathname: MFA_CHALLENGE, + state: { + mfaTerminalFailure: { reason: MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED, userEmail: MOCK_SIGN_IN_CREDENTIALS.email } + } + }); + + expect(consoleInfo).toHaveBeenCalledWith(expect.stringContaining(MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED)); + expect(panel.text("mocked_api_dev_panel")).not.toContain(MFA_VERIFICATION_FAILURE.CHALLENGE_LOCKED); + + consoleInfo.mockRestore(); + panel.unmount(); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.tsx b/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.tsx new file mode 100644 index 000000000..ff38bc4b3 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/devPanel/MockedApiDevPanel.tsx @@ -0,0 +1,188 @@ +import { ChangeEvent, ReactNode, useEffect, useState } from "react"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Paper from "@mui/material/Paper"; +import Stack from "@mui/material/Stack"; +import TextField from "@mui/material/TextField"; +import Typography from "@mui/material/Typography"; +import { useLocation, useNavigate } from "react-router-dom"; +import { createToken, CREATE_TOKEN_STATUS, LoginChallenge } from "@theme/api/auth/signIn"; +import { isSignInScenario, SIGN_IN_SCENARIO, SIGN_IN_SCENARIO_DESCRIPTION } from "@theme/api/mocked/auth/signIn/scenarios"; +import { DEV_PANEL_COLLAPSED_STORAGE_KEY, MOCK_SIGN_IN_CREDENTIALS } from "@theme/api/mocked/config"; +import { readSignInScenarioCookie, writeSignInScenarioCookie } from "@theme/api/mocked/http/signInScenarioCookie"; +import { useMfaChallengeStage } from "@theme/components/MfaChallengeStep"; +import { useLoginChallenge, useMfaTerminalFailure, useStartLoginChallenge } from "@theme/hooks/useLoginChallenge"; +import { getLoginUrl, MFA_CHALLENGE } from "@theme/urls"; +import { describeMfaChallengeStage, describeRoute } from "./describeStage"; +import useStyles from "./MockedApiDevPanel.styles"; + +// Developer tooling that never reaches a production build, so its copy is deliberately not translated. + +const SCENARIO_SELECT_ID = "mocked_api_dev_panel_scenario"; + +const PanelSection = ({ title, children }: { title: string; children: ReactNode }) => ( + + + {title} + + {children} + +); + +const ChallengeDetails = ({ challenge }: { challenge: LoginChallenge }) => { + const { classes } = useStyles(); + + const rows: [string, string][] = [ + ["state", challenge.state], + ["challenge_id", challenge.challenge_id], + ["default_method_id", String(challenge.default_method_id)], + ["code_sent", String(challenge.code_sent)], + ["attempts_left", String(challenge.attempts_left)], + ["expires_at", new Date(challenge.expires_at * 1000).toLocaleTimeString()], + ...challenge.methods.map(({ id, type, is_default, confirmed }): [string, string] => [ + `method ${type}`, + `${id.slice(0, 8)}… ${confirmed ? "confirmed" : "PENDING"}${is_default ? " · default" : ""}` + ]) + ]; + + return ( + + {rows.map(([term, value]) => ( + +
{term}
+
{value}
+
+ ))} +
+ ); +}; + +const MockedApiDevPanel = () => { + const { classes } = useStyles(); + + const { pathname } = useLocation(); + const navigate = useNavigate(); + const challenge = useLoginChallenge(); + const startLoginChallenge = useStartLoginChallenge(); + const mfaStage = useMfaChallengeStage(); + const terminalFailure = useMfaTerminalFailure(); + + // The screen tells the visitor sign-in stopped; the exact reason is only for whoever is testing. + useEffect(() => { + if (terminalFailure) { + console.info(`[mocked-api] login challenge ended: ${terminalFailure.reason}`); + } + }, [terminalFailure]); + + const [isCollapsed, setIsCollapsed] = useState(() => localStorage.getItem(DEV_PANEL_COLLAPSED_STORAGE_KEY) === "true"); + const [scenario, setScenario] = useState(() => readSignInScenarioCookie(document.cookie)); + const [isRerunning, setIsRerunning] = useState(false); + + const toggleCollapsed = () => { + localStorage.setItem(DEV_PANEL_COLLAPSED_STORAGE_KEY, String(!isCollapsed)); + setIsCollapsed(!isCollapsed); + }; + + const selectScenario = ({ target: { value } }: ChangeEvent) => { + if (isSignInScenario(value)) { + writeSignInScenarioCookie(value); + setScenario(value); + } + }; + + const rerunSignIn = async (email: string) => { + const loginUrl = getLoginUrl(email); + + setIsRerunning(true); + + try { + const { status, data } = await createToken({ email, password: MOCK_SIGN_IN_CREDENTIALS.password }); + + if (status === CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED) { + startLoginChallenge(data, { replace: true }); + return; + } + + // A token or an error is the login page's to handle, so those scenarios continue from there. + navigate(loginUrl, { replace: true }); + } catch { + navigate(loginUrl, { replace: true }); + } finally { + setIsRerunning(false); + } + }; + + return ( + + + Mocked API · dev only + {isCollapsed ? : } + + {!isCollapsed && ( + + + + Email: {MOCK_SIGN_IN_CREDENTIALS.email} (any valid address) + + + Password: {MOCK_SIGN_IN_CREDENTIALS.password} (anything else → 403 OA0037) + + + MFA code: {MOCK_SIGN_IN_CREDENTIALS.mfaCode} (anything else is a wrong code) + + + + + {Object.values(SIGN_IN_SCENARIO).map((scenarioOption) => ( + + ))} + + + {SIGN_IN_SCENARIO_DESCRIPTION[scenario]} + + {challenge ? ( + + ) : ( + + Applies to the next sign-in. + + )} + + + + {describeRoute(pathname)} + + {pathname === MFA_CHALLENGE && mfaStage && ( + + {describeMfaChallengeStage(mfaStage)} + + )} + {challenge && } + + + )} + + ); +}; + +export default MockedApiDevPanel; diff --git a/ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.test.ts b/ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.test.ts new file mode 100644 index 000000000..2fa1ff74f --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; +import { CHALLENGE_BODY } from "@theme/api/mocked/auth/signIn/fixtures"; +import { MFA_CHALLENGE_VIEW } from "@theme/components/MfaChallengeStep"; +import { MFA_CHALLENGE } from "@theme/urls"; +import { describeMfaChallengeStage, describeRoute } from "./describeStage"; + +const [totpMethod, emailMethod] = CHALLENGE_BODY.MFA_TOTP_DEFAULT.methods; + +describe("describeRoute", () => { + it("names the sign-in routes", () => { + expect(describeRoute(MFA_CHALLENGE)).toBe("MFA challenge"); + }); + + it("falls back to the path for any other page", () => { + expect(describeRoute("/password-recovery")).toBe("/password-recovery"); + }); +}); + +describe("describeMfaChallengeStage", () => { + it("names the picker", () => { + expect(describeMfaChallengeStage({ view: MFA_CHALLENGE_VIEW.METHOD_PICKER })).toBe("MfaMethodPicker — choosing a method"); + }); + + it("says which method the code entry is for and whether it is the default", () => { + expect(describeMfaChallengeStage({ view: MFA_CHALLENGE_VIEW.CODE_ENTRY, method: totpMethod, isEmailCodeSent: false })).toBe( + "MfaCodeEntry — authenticator app (TOTP) · default method" + ); + }); + + it("says whether the e-mail code went out", () => { + expect( + describeMfaChallengeStage({ view: MFA_CHALLENGE_VIEW.CODE_ENTRY, method: emailMethod, isEmailCodeSent: false }) + ).toBe("MfaCodeEntry — e-mail, code not sent"); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.ts b/ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.ts new file mode 100644 index 000000000..cd2969d12 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/devPanel/describeStage.ts @@ -0,0 +1,30 @@ +import { MFA_METHOD_TYPE } from "@theme/api/auth/signIn"; +import { MFA_CHALLENGE_VIEW, MfaChallengeStage } from "@theme/components/MfaChallengeStep"; +import { MFA_CHALLENGE, MFA_ENROLLMENT } from "@theme/urls"; +import { LOGIN } from "urls"; + +const ROUTE_DESCRIPTION: Record = { + [LOGIN]: "Sign-in — email / password step", + [MFA_CHALLENGE]: "MFA challenge", + [MFA_ENROLLMENT]: "MFA enrollment (placeholder screen)" +}; + +export const describeRoute = (pathname: string) => ROUTE_DESCRIPTION[pathname] ?? pathname; + +export const describeMfaChallengeStage = (stage: MfaChallengeStage) => { + switch (stage.view) { + case MFA_CHALLENGE_VIEW.NO_SELECTABLE_METHODS: + return "NoSelectableMethods — error, no confirmed method"; + case MFA_CHALLENGE_VIEW.METHOD_PICKER: + return "MfaMethodPicker — choosing a method"; + case MFA_CHALLENGE_VIEW.CODE_ENTRY: { + const { method, isEmailCodeSent } = stage; + const methodDescription = + method.type === MFA_METHOD_TYPE.TOTP + ? "authenticator app (TOTP)" + : `e-mail, code ${isEmailCodeSent ? "sent" : "not sent"}`; + + return `MfaCodeEntry — ${methodDescription}${method.is_default ? " · default method" : ""}`; + } + } +}; diff --git a/ngui/ui/src/themes/finops/api/mocked/devPanel/index.ts b/ngui/ui/src/themes/finops/api/mocked/devPanel/index.ts new file mode 100644 index 000000000..8e65ebd47 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/devPanel/index.ts @@ -0,0 +1,3 @@ +import MockedApiDevPanel from "./MockedApiDevPanel"; + +export default MockedApiDevPanel; diff --git a/ngui/ui/src/themes/finops/api/mocked/http/handlers.test.ts b/ngui/ui/src/themes/finops/api/mocked/http/handlers.test.ts index 137fcd45d..53eea0ae7 100644 --- a/ngui/ui/src/themes/finops/api/mocked/http/handlers.test.ts +++ b/ngui/ui/src/themes/finops/api/mocked/http/handlers.test.ts @@ -1,12 +1,15 @@ import { describe, expect, it, vi } from "vitest"; import { AUTH_ERROR_CODE, CREATE_TOKEN_STATUS, LOGIN_CHALLENGE_STATE } from "@theme/api/auth/signIn/constants"; +import { TOTP_METHOD_ID } from "@theme/api/mocked/auth/signIn/fixtures"; +import { SIGN_IN_SCENARIO } from "@theme/api/mocked/auth/signIn/scenarios"; +import { MOCK_SIGN_IN_CREDENTIALS, SIGN_IN_SCENARIO_COOKIE } from "@theme/api/mocked/config"; import { MOCKED_ROUTES } from "./handlers"; -vi.mock("@theme/api/mocked/mockDelay", () => ({ MOCK_LATENCY_MS: 0, delay: () => Promise.resolve() })); +vi.mock("@theme/api/mocked/mockDelay", () => ({ delay: () => Promise.resolve() })); -const call = (route: string, body: Record) => MOCKED_ROUTES[route](body); +const call = (route: string, body: Record, cookie = "") => MOCKED_ROUTES[route](body, { cookie }); -const SIGN_IN = { email: "mfa.user@example.com", password: "password" }; +const SIGN_IN = { ...MOCK_SIGN_IN_CREDENTIALS }; describe("MOCKED_ROUTES", () => { it("answers login_options with 200 and the options as the body", async () => { @@ -16,19 +19,51 @@ describe("MOCKED_ROUTES", () => { expect(body).toEqual({ methods: ["password"], sso: { state: "not_configured" } }); }); - it("maps the challenge outcome onto the HTTP status, so the app can branch on it", async () => { - const { status, body } = await call("POST /auth/v2/tokens", SIGN_IN); + it("answers tokens with the scenario the dev panel stored in the cookie", async () => { + const { status, body } = await call( + "POST /auth/v2/tokens", + SIGN_IN, + `theme=dark; ${SIGN_IN_SCENARIO_COOKIE}=${SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT}` + ); expect(status).toBe(CREATE_TOKEN_STATUS.CHALLENGE_REQUIRED); expect(body).toMatchObject({ state: LOGIN_CHALLENGE_STATE.MFA_REQUIRED }); }); + it("issues a token when no scenario cookie is present", async () => { + const { status } = await call("POST /auth/v2/tokens", SIGN_IN); + + expect(status).toBe(CREATE_TOKEN_STATUS.TOKEN_ISSUED); + }); + + it("routes a body with a challenge to MFA verification and answers 201 for the right code", async () => { + const { body: challenge } = await call( + "POST /auth/v2/tokens", + SIGN_IN, + `${SIGN_IN_SCENARIO_COOKIE}=${SIGN_IN_SCENARIO.MFA_TOTP_DEFAULT}` + ); + const { challenge_id } = challenge as { challenge_id: string }; + + const { status } = await call("POST /auth/v2/tokens", { + challenge_id, + method_id: TOTP_METHOD_ID, + code: MOCK_SIGN_IN_CREDENTIALS.mfaCode + }); + + expect(status).toBe(201); + }); + it("rebuilds the optscale error envelope a rejected mock would have sent over the wire", async () => { const { status, body } = await call("POST /auth/v2/tokens", { ...SIGN_IN, password: "wrong-password" }); expect(status).toBe(403); expect(body).toEqual({ - error: { error_code: AUTH_ERROR_CODE.INVALID_CREDENTIALS, reason: "Email or password is invalid" } + error: { + status_code: 403, + error_code: AUTH_ERROR_CODE.INVALID_CREDENTIALS, + reason: "Email or password is invalid", + params: [] + } }); }); diff --git a/ngui/ui/src/themes/finops/api/mocked/http/handlers.ts b/ngui/ui/src/themes/finops/api/mocked/http/handlers.ts index 052003391..94404f07c 100644 --- a/ngui/ui/src/themes/finops/api/mocked/http/handlers.ts +++ b/ngui/ui/src/themes/finops/api/mocked/http/handlers.ts @@ -1,28 +1,39 @@ import { AuthApiError } from "@theme/api/auth/signIn/errors"; -import { createTokenMock, resolveLoginOptionsMock } from "@theme/api/mocked/auth/signIn"; +import { completeLoginChallengeMock, createTokenMock, resolveLoginOptionsMock } from "@theme/api/mocked/auth/signIn"; +import { readSignInScenarioCookie } from "./signInScenarioCookie"; export type MockedResponse = { status: number; body: unknown; }; -type MockedHandler = (body: Record) => Promise; +/** What the plugin passes on from the incoming request besides its JSON body. */ +export type MockedRequestContext = { + cookie: string; +}; + +export type MockedHandler = (body: Record, context: MockedRequestContext) => Promise; /** Rebuilds the optscale error envelope the real service would have sent over the wire. */ const toErrorResponse = (error: unknown): MockedResponse => { if (error instanceof AuthApiError) { return { status: error.status, - body: { error: { error_code: error.errorCode, reason: error.message } } + body: { error: { status_code: error.status, error_code: error.errorCode, reason: error.message, params: error.params } } }; } throw error; }; -const createToken: MockedHandler = async ({ email, password }) => { +// One route, two requests: the password step sends credentials, the MFA step a challenge. +const createToken: MockedHandler = async ({ email, password, challenge_id, method_id, code }, { cookie }) => { try { - const { status, data } = await createTokenMock({ email, password }); + if (challenge_id) { + return { status: 201, body: await completeLoginChallengeMock({ challenge_id, method_id, code }) }; + } + + const { status, data } = await createTokenMock({ email, password }, readSignInScenarioCookie(cookie)); return { status, body: data }; } catch (error) { diff --git a/ngui/ui/src/themes/finops/api/mocked/http/mockedApiPlugin.ts b/ngui/ui/src/themes/finops/api/mocked/http/mockedApiPlugin.ts index 1ca63a3f6..7c54bd6db 100644 --- a/ngui/ui/src/themes/finops/api/mocked/http/mockedApiPlugin.ts +++ b/ngui/ui/src/themes/finops/api/mocked/http/mockedApiPlugin.ts @@ -1,4 +1,8 @@ import type { Connect, Plugin } from "vite"; +// vite.config.mts loads this file without path aliases: config.ts imports nothing, and the +// handlers import is type-only, so it is erased. +import { MOCKED_HANDLERS_MODULE, MOCKED_PATH_PREFIX } from "../config"; +import type { MockedHandler } from "./handlers"; /** * Serves the mocked endpoints over real HTTP on the dev server, so a call the backend cannot @@ -8,17 +12,10 @@ import type { Connect, Plugin } from "vite"; * which adds the `/api` prefix in dev. This middleware is registered before Vite's proxy, so a * route listed in `handlers.ts` is answered here and everything else falls through upstream. * - * Opt out with `VITE_MOCKED_API=false` once the real endpoints ship. + * On while the `mfa_login` theme flag is; `VITE_MOCKED_API=false` opts out — see `isMockedApiEnabled`. */ -/** Only requests under this prefix are inspected; the rest skip the handler lookup entirely. */ -const MOCKED_PATH_PREFIX = "/api/auth/v2/"; - -const HANDLERS_MODULE = "/src/themes/finops/api/mocked/http/handlers.ts"; - -type MockedResponse = { status: number; body: unknown }; - -type MockedHandlers = Record) => Promise>; +type MockedHandlers = Record; const readJsonBody = (request: Connect.IncomingMessage) => new Promise>((resolve) => { @@ -57,7 +54,7 @@ export const MockedApi = (enabled: boolean): Plugin => ({ // Loaded through Vite so the handlers can keep using the app's aliases and TypeScript. server - .ssrLoadModule(HANDLERS_MODULE) + .ssrLoadModule(MOCKED_HANDLERS_MODULE) .then(async (module) => { const { MOCKED_ROUTES } = module as { MOCKED_ROUTES: MockedHandlers }; const handler = MOCKED_ROUTES[route]; @@ -67,7 +64,7 @@ export const MockedApi = (enabled: boolean): Plugin => ({ return; } - const { status, body } = await handler(await readJsonBody(request)); + const { status, body } = await handler(await readJsonBody(request), { cookie: request.headers.cookie ?? "" }); server.config.logger.info(`[mocked-api] ${route} → ${status}`); diff --git a/ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.test.ts b/ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.test.ts new file mode 100644 index 000000000..cd1a1380e --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { DEFAULT_SIGN_IN_SCENARIO, SIGN_IN_SCENARIO } from "@theme/api/mocked/auth/signIn/scenarios"; +import { SIGN_IN_SCENARIO_COOKIE } from "@theme/api/mocked/config"; +import { readSignInScenarioCookie, writeSignInScenarioCookie } from "./signInScenarioCookie"; + +const clearCookie = () => { + document.cookie = `${SIGN_IN_SCENARIO_COOKIE}=; path=/; max-age=0`; +}; + +describe("readSignInScenarioCookie", () => { + it("finds the scenario among other cookies", () => { + expect(readSignInScenarioCookie(`a=1; ${SIGN_IN_SCENARIO_COOKIE}=${SIGN_IN_SCENARIO.MFA_NO_DEFAULT}; b=2`)).toBe( + SIGN_IN_SCENARIO.MFA_NO_DEFAULT + ); + }); + + it("falls back to the default when the cookie is missing", () => { + expect(readSignInScenarioCookie("a=1")).toBe(DEFAULT_SIGN_IN_SCENARIO); + }); + + it("falls back to the default for a value that is not a known scenario", () => { + expect(readSignInScenarioCookie(`${SIGN_IN_SCENARIO_COOKIE}=renamed_long_ago`)).toBe(DEFAULT_SIGN_IN_SCENARIO); + }); +}); + +describe("writeSignInScenarioCookie", () => { + afterEach(clearCookie); + + it("stores a scenario the reader gets back from document.cookie", () => { + writeSignInScenarioCookie(SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT); + + expect(readSignInScenarioCookie(document.cookie)).toBe(SIGN_IN_SCENARIO.MFA_EMAIL_DEFAULT); + }); +}); diff --git a/ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.ts b/ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.ts new file mode 100644 index 000000000..62b60aa0f --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/http/signInScenarioCookie.ts @@ -0,0 +1,17 @@ +import { DEFAULT_SIGN_IN_SCENARIO, isSignInScenario, SignInScenario } from "@theme/api/mocked/auth/signIn/scenarios"; +import { SIGN_IN_SCENARIO_COOKIE } from "@theme/api/mocked/config"; + +// A cookie rides along with the app's own `fetch`, so the real transport never has to know a mock exists. + +export const readSignInScenarioCookie = (cookieHeader: string): SignInScenario => { + const value = cookieHeader + .split(";") + .map((cookie) => cookie.trim().split("=")) + .find(([name]) => name === SIGN_IN_SCENARIO_COOKIE)?.[1]; + + return isSignInScenario(value) ? value : DEFAULT_SIGN_IN_SCENARIO; +}; + +export const writeSignInScenarioCookie = (scenario: SignInScenario) => { + document.cookie = `${SIGN_IN_SCENARIO_COOKIE}=${scenario}; path=/; SameSite=Strict`; +}; diff --git a/ngui/ui/src/themes/finops/api/mocked/mockDelay.ts b/ngui/ui/src/themes/finops/api/mocked/mockDelay.ts index 9038f0ba9..32cb8ebfc 100644 --- a/ngui/ui/src/themes/finops/api/mocked/mockDelay.ts +++ b/ngui/ui/src/themes/finops/api/mocked/mockDelay.ts @@ -1,6 +1,3 @@ -/** Every mocked auth call waits this long, so the pending states are visible while clicking through. */ -export const MOCK_LATENCY_MS = 600; - export const delay = (ms: number) => new Promise((resolve) => { setTimeout(resolve, ms); diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/login_options/200.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/login_options/200.json new file mode 100644 index 000000000..627b976a8 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/login_options/200.json @@ -0,0 +1,6 @@ +{ + "methods": ["password"], + "sso": { + "state": "not_configured" + } +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/201-token.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/201-token.json new file mode 100644 index 000000000..c58171e16 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/201-token.json @@ -0,0 +1,9 @@ +{ + "token": "MDAxNmxvY2F0aW9uIG9wdHNjYWxlCjAwMzRpZGVudGlmaWVyIDljMmExZjNlLTViNDctNGEyMS04ZjBjLTFkNmU4YjNhN2M0MgowMDExY2lkIG1mYTpub25lCjAwMmZzaWduYXR1cmUgrrkLrCmb0VcdYWsrr-lUxd9rs-V-iq_yANUMhSBaeHIK", + "user_id": "9c2a1f3e-5b47-4a21-8f0c-1d6e8b3a7c42", + "user_email": "finops.ffc@example.com", + "created_at": "2026-09-22T09:00:00", + "valid_until": "2026-09-29T09:00:00", + "ip": "127.0.0.1", + "digest": "f0a1b2c3d4e5f60718293a4b5c6d7e8f" +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-enrollment-required.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-enrollment-required.json new file mode 100644 index 000000000..dbe568ae9 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-enrollment-required.json @@ -0,0 +1,10 @@ +{ + "challenge_id": "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", + "state": "enrollment_required", + "expires_at": 1758480300, + "user_email": "finops.ffc@example.com", + "methods": [], + "default_method_id": null, + "code_sent": false, + "attempts_left": 5 +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-email-default.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-email-default.json new file mode 100644 index 000000000..e6ecbd7f8 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-email-default.json @@ -0,0 +1,25 @@ +{ + "challenge_id": "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", + "state": "mfa_required", + "expires_at": 1758480300, + "user_email": "finops.ffc@example.com", + "methods": [ + { + "id": "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32", + "type": "totp", + "label": "Configured on Google Pixel 10 Pro", + "is_default": false, + "confirmed": true + }, + { + "id": "b2e47d91-6c03-4f8a-b1d2-5e9a3c7f0481", + "type": "email", + "label": "fi***@example.com", + "is_default": true, + "confirmed": true + } + ], + "default_method_id": "b2e47d91-6c03-4f8a-b1d2-5e9a3c7f0481", + "code_sent": true, + "attempts_left": 5 +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-confirmed-methods.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-confirmed-methods.json new file mode 100644 index 000000000..6d68ac4ef --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-confirmed-methods.json @@ -0,0 +1,18 @@ +{ + "challenge_id": "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", + "state": "mfa_required", + "expires_at": 1758480300, + "user_email": "finops.ffc@example.com", + "methods": [ + { + "id": "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32", + "type": "totp", + "label": "Configured on Google Pixel 10 Pro", + "is_default": true, + "confirmed": false + } + ], + "default_method_id": "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32", + "code_sent": false, + "attempts_left": 5 +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-default.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-default.json new file mode 100644 index 000000000..7b1d56c33 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-no-default.json @@ -0,0 +1,25 @@ +{ + "challenge_id": "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", + "state": "mfa_required", + "expires_at": 1758480300, + "user_email": "finops.ffc@example.com", + "methods": [ + { + "id": "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32", + "type": "totp", + "label": "Configured on Google Pixel 10 Pro", + "is_default": false, + "confirmed": true + }, + { + "id": "b2e47d91-6c03-4f8a-b1d2-5e9a3c7f0481", + "type": "email", + "label": "fi***@example.com", + "is_default": false, + "confirmed": true + } + ], + "default_method_id": null, + "code_sent": false, + "attempts_left": 5 +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-totp-default.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-totp-default.json new file mode 100644 index 000000000..a3f23cfae --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/202-mfa-totp-default.json @@ -0,0 +1,25 @@ +{ + "challenge_id": "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", + "state": "mfa_required", + "expires_at": 1758480300, + "user_email": "finops.ffc@example.com", + "methods": [ + { + "id": "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32", + "type": "totp", + "label": "Configured on Google Pixel 10 Pro", + "is_default": true, + "confirmed": true + }, + { + "id": "b2e47d91-6c03-4f8a-b1d2-5e9a3c7f0481", + "type": "email", + "label": "fi***@example.com", + "is_default": false, + "confirmed": true + } + ], + "default_method_id": "a1d3f7c0-2e18-4c9b-9a55-7b0d1c4e6f32", + "code_sent": false, + "attempts_left": 5 +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-email-not-verified.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-email-not-verified.json new file mode 100644 index 000000000..7db581245 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-email-not-verified.json @@ -0,0 +1,8 @@ +{ + "error": { + "status_code": 403, + "error_code": "OA0073", + "reason": "Email is not verified", + "params": [] + } +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-credentials.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-credentials.json new file mode 100644 index 000000000..1dde4178e --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-credentials.json @@ -0,0 +1,8 @@ +{ + "error": { + "status_code": 403, + "error_code": "OA0037", + "reason": "Email or password is invalid", + "params": [] + } +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-mfa-code.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-mfa-code.json new file mode 100644 index 000000000..1e990ba70 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-invalid-mfa-code.json @@ -0,0 +1,8 @@ +{ + "error": { + "status_code": 403, + "error_code": "OA0075", + "reason": "Invalid verification code. %s attempts left", + "params": [] + } +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-mfa-attempts-exceeded.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-mfa-attempts-exceeded.json new file mode 100644 index 000000000..d4cc08a9c --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/403-mfa-attempts-exceeded.json @@ -0,0 +1,8 @@ +{ + "error": { + "status_code": 403, + "error_code": "OA0076", + "reason": "Too many attempts", + "params": [] + } +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/404-login-challenge-not-found.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/404-login-challenge-not-found.json new file mode 100644 index 000000000..7054a8df0 --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/404-login-challenge-not-found.json @@ -0,0 +1,8 @@ +{ + "error": { + "status_code": 404, + "error_code": "OA0077", + "reason": "Login challenge not found, expired or consumed", + "params": [] + } +} diff --git a/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/500-internal-error.json b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/500-internal-error.json new file mode 100644 index 000000000..d099b2daa --- /dev/null +++ b/ngui/ui/src/themes/finops/api/mocked/responses/auth/v2/tokens/500-internal-error.json @@ -0,0 +1,8 @@ +{ + "error": { + "status_code": 500, + "error_code": "OA0061", + "reason": "Database error", + "params": [] + } +} diff --git a/ngui/ui/src/themes/finops/components/Greeter/Greeter.tsx b/ngui/ui/src/themes/finops/components/Greeter/Greeter.tsx index d272d35cc..af8518baf 100644 --- a/ngui/ui/src/themes/finops/components/Greeter/Greeter.tsx +++ b/ngui/ui/src/themes/finops/components/Greeter/Greeter.tsx @@ -1,13 +1,22 @@ -import { ReactNode } from "react"; +import { lazy, ReactNode, Suspense } from "react"; import { Stack } from "@mui/material"; import Grid from "@mui/material/Grid"; import { useTheme } from "@mui/material/styles"; +import { isMockedApiEnabled } from "@theme/api/mocked/config"; +import { featureFlags } from "@theme/theme.features"; import Logo from "components/Logo"; import TopAlertWrapper from "components/TopAlertWrapper"; import { ALERT_TYPES } from "components/TopAlertWrapper/constants"; import { SPACING_2, SPACING_1 } from "utils/layouts"; import useStyles from "./Greeter.styles"; +// `MODE` is a literal in a production build, so this folds to null and the panel chunk is never emitted. +const MockedApiDevPanel = + import.meta.env.MODE === "development" && + isMockedApiEnabled({ mfaLogin: featureFlags.mfa_login, mockedApiEnv: import.meta.env.VITE_MOCKED_API }) + ? lazy(() => import("@theme/api/mocked/devPanel")) + : null; + type GreeterProps = { content: ReactNode; }; @@ -44,6 +53,11 @@ const Greeter = ({ content }: GreeterProps) => { return ( <> + {MockedApiDevPanel && ( + + + + )}
({ [ALERTS]: {} })); - const challenge: LoginChallenge = { challenge_id: "6d2f4c0e-3b1a-4b7e-9d1e-0f4c2a6b8e91", - state: LOGIN_CHALLENGE_STATE.MFA_REQUIRED, + state: LOGIN_CHALLENGE_STATE.ENROLLMENT_REQUIRED, expires_at: 1758480300, user_email: "finops.ffc@example.com", methods: [], @@ -34,52 +18,39 @@ const challenge: LoginChallenge = { attempts_left: 5 }; -const renderAt = (entry: { pathname: string; state?: { challenge: LoginChallenge } }) => { - const container = document.createElement("div"); - const root = createRoot(container); - - const wrap = (children: ReactNode) => ( - - - - - - - - {/* Stands in for the login page, so a redirect is observable in the DOM. */} - } /> - - - - - - - ); - - act(() => { - root.render( - wrap() - ); +const renderAt = (initialEntry: { pathname: string; state?: { challenge: LoginChallenge } }) => + renderWithProviders({ + initialEntry, + routes: ( + <> + + } + /> + } /> + + ) }); - return { - container, - unmount: () => act(() => root.unmount()) - }; -}; - describe("LoginChallengeStep", () => { it("shows which account is signing in", () => { - const { container, unmount } = renderAt({ pathname: MFA_CHALLENGE, state: { challenge } }); + const { container, unmount } = renderAt({ pathname: MFA_ENROLLMENT, state: { challenge } }); expect(container.querySelector("[data-test-id='p_signing_in_as']")?.textContent).toContain("finops.ffc@example.com"); - expect(container.querySelector("[data-test-id='p_login_challenge_title']")?.textContent).toBe("Two-step verification"); + expect(container.querySelector("[data-test-id='p_login_challenge_title']")?.textContent).toBe( + "Set up two-step verification" + ); unmount(); }); it("sends a visitor who never passed the password step back to login", () => { - const { container, unmount } = renderAt({ pathname: MFA_CHALLENGE }); + const { container, unmount } = renderAt({ pathname: MFA_ENROLLMENT }); expect(container.querySelector("[data-test-id='login_page']")).not.toBeNull(); expect(container.querySelector("[data-test-id='p_signing_in_as']")).toBeNull(); diff --git a/ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx b/ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx new file mode 100644 index 000000000..6c84e9e8e --- /dev/null +++ b/ngui/ui/src/themes/finops/components/MfaChallengeStep/BackToLoginButton.tsx @@ -0,0 +1,16 @@ +import Button from "components/Button"; +import { LOGIN } from "urls"; + +const BackToLoginButton = () => ( +