Compare commits

...
Sign in to create a new pull request.

1 commit

Author SHA1 Message Date
Daniel Han
a2ab0dda30 studio/frontend: add Vitest suite for auth + 422 helper
Pins the behavioural invariants that landed in PR #5490 (the frontend
half of the #5375-regression fix-up backlog) so a future hardening
pass cannot quietly undo them. Today these surfaces have only typecheck
+ bundle-build coverage; this lifts the floor.

What's pinned (35 tests across 3 files, ~360 ms):

- src/lib/__tests__/format-fastapi-error.test.ts (17)
  formatFastApiDetail + readFastApiError: string passthrough, array
  detail joined as "field: msg" / "; ", nested loc joined by ".",
  missing msg / missing loc / empty array / mixed valid+junk, plus
  Response shapes for 422 / 400 / 500 / 503 / non-JSON / object-detail
  + message fallback. Regression target: rendering `[object Object]`
  or `Request failed (422)` instead of "field: msg".

- src/features/auth/__tests__/session.test.ts (10)
  storeAuthTokens writes only the two JWT keys (never the must-change
  key -- the CodeQL data-flow pin); clearAuthTokens removes all three;
  setMustChangePassword writes the literal "1" / removes the key
  (presence-encoded so the stored value stays a constant, not a
  derived boolean); isOnboardingDone treats only the literal "true"
  as done.

- src/features/auth/__tests__/api.test.ts (8)
  refreshSession singleflight (5 racing callers -> 1 /api/auth/refresh
  request); in-flight slot clears so subsequent calls refetch;
  non-OK response clears tokens; no refresh token -> no fetch;
  logoutGeneration drops the new token pair when logout() lands
  mid-refresh; logout() clears local state on 204; logout() retries
  with a rotated access token on 401; logout() still clears state on
  network failure.

Wiring:
- vitest 4 + happy-dom 18 as devDependencies.
- vitest.config.ts mirrors the @/ alias from tsconfig.app.json.
- New `test` / `test:watch` scripts.

Workflow step (CI gating) is intentionally NOT included in this PR
because the publishing token lacks `workflow` scope. After merge, add
this between the Typecheck and Build steps in
.github/workflows/studio-frontend-ci.yml:

  - name: Unit tests (Vitest)
    run: npm run test

`npm run typecheck` / `test` / `build` all clean locally.
2026-05-18 08:07:45 +00:00
6 changed files with 909 additions and 2 deletions

View file

@ -82,9 +82,11 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"happy-dom": "^18.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vitest": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@ -6326,6 +6328,17 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/d3": {
"version": "7.4.3",
"resolved": "https://registry.npmjs.org/@types/d3/-/d3-7.4.3.tgz",
@ -6588,6 +6601,13 @@
"@types/ms": "*"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@ -6732,6 +6752,13 @@
"integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==",
"license": "MIT"
},
"node_modules/@types/whatwg-mimetype": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz",
"integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.59.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz",
@ -7069,6 +7096,119 @@
}
}
},
"node_modules/@vitest/expect": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz",
"integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz",
"integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.5",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz",
"integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==",
"dev": true,
"license": "MIT",
"dependencies": {
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/runner": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz",
"integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.5",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz",
"integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"@vitest/utils": "4.1.5",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/spy": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz",
"integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@vitest/utils": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz",
"integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.5",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
@ -7268,6 +7408,16 @@
"node": ">=10"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/assistant-cloud": {
"version": "0.1.27",
"resolved": "https://registry.npmjs.org/assistant-cloud/-/assistant-cloud-0.1.27.tgz",
@ -7553,6 +7703,16 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chalk": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz",
@ -8850,6 +9010,13 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
"dev": true,
"license": "MIT"
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@ -9097,6 +9264,16 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/esutils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@ -9163,6 +9340,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1"
}
},
"node_modules/expect-type": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@ -9715,6 +9902,38 @@
"integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==",
"license": "MIT"
},
"node_modules/happy-dom": {
"version": "18.0.1",
"resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-18.0.1.tgz",
"integrity": "sha512-qn+rKOW7KWpVTtgIUi6RVmTBZJSe2k0Db0vh1f7CWrWclkkc7/Q+FrOfkZIb2eiErLyqu5AXEzE7XthO9JVxRA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "^20.0.0",
"@types/whatwg-mimetype": "^3.0.2",
"whatwg-mimetype": "^3.0.0"
},
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/happy-dom/node_modules/@types/node": {
"version": "20.19.40",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz",
"integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/happy-dom/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/has-flag": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
@ -12414,6 +12633,17 @@
"node": ">= 10"
}
},
"node_modules/obug": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT"
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@ -14132,6 +14362,13 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@ -14194,6 +14431,13 @@
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@ -14203,6 +14447,13 @@
"node": ">= 0.8"
}
},
"node_modules/std-env": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
"dev": true,
"license": "MIT"
},
"node_modules/stdin-discarder": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
@ -14469,6 +14720,13 @@
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
"dev": true,
"license": "MIT"
},
"node_modules/tinyexec": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz",
@ -14494,6 +14752,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tinyrainbow": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/tldts": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
@ -15285,6 +15553,96 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/vitest": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz",
"integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.5",
"@vitest/mocker": "4.1.5",
"@vitest/pretty-format": "4.1.5",
"@vitest/runner": "4.1.5",
"@vitest/snapshot": "4.1.5",
"@vitest/spy": "4.1.5",
"@vitest/utils": "4.1.5",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
"obug": "^2.1.1",
"pathe": "^2.0.3",
"picomatch": "^4.0.3",
"std-env": "^4.0.0-rc.1",
"tinybench": "^2.9.0",
"tinyexec": "^1.0.2",
"tinyglobby": "^0.2.15",
"tinyrainbow": "^3.1.0",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.5",
"@vitest/browser-preview": "4.1.5",
"@vitest/browser-webdriverio": "4.1.5",
"@vitest/coverage-istanbul": "4.1.5",
"@vitest/coverage-v8": "4.1.5",
"@vitest/ui": "4.1.5",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vscode-jsonrpc": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.0.tgz",
@ -15353,6 +15711,16 @@
"node": ">= 8"
}
},
"node_modules/whatwg-mimetype": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz",
"integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -15368,6 +15736,23 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",

View file

@ -12,6 +12,8 @@
"lint": "eslint .",
"preview": "vite preview",
"typecheck": "tsc -b --pretty false",
"test": "vitest run",
"test:watch": "vitest",
"biome:check": "biome check .",
"biome:fix": "biome check . --write"
},
@ -95,8 +97,10 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"happy-dom": "^18.0.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.55.0",
"vite": "^8.0.1"
"vite": "^8.0.1",
"vitest": "^4.0.0"
}
}

View file

@ -0,0 +1,243 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTH_MUST_CHANGE_PASSWORD_KEY,
AUTH_REFRESH_TOKEN_KEY,
AUTH_TOKEN_KEY,
} from "@/features/auth/session";
type FetchMock = ReturnType<typeof vi.fn>;
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": "application/json" },
});
}
function deferred<T>(): {
promise: Promise<T>;
resolve: (v: T) => void;
reject: (e: unknown) => void;
} {
let resolve!: (v: T) => void;
let reject!: (e: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
async function loadApi() {
// Reset module-level singleflight + generation state between tests.
vi.resetModules();
return await import("@/features/auth/api");
}
let fetchMock: FetchMock;
beforeEach(() => {
localStorage.clear();
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
describe("refreshSession singleflight", () => {
it("issues exactly one /api/auth/refresh request when N callers race", async () => {
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-original");
const refreshDeferred = deferred<Response>();
fetchMock.mockImplementation(async (input: RequestInfo) => {
const url = typeof input === "string" ? input : (input as Request).url;
if (url.endsWith("/api/auth/refresh")) {
return refreshDeferred.promise;
}
throw new Error(`unexpected fetch: ${url}`);
});
const { refreshSession } = await loadApi();
// Kick off all callers BEFORE we resolve the in-flight fetch, so they
// all subscribe to the same singleflight promise.
const callersPromise = Promise.all([
refreshSession(),
refreshSession(),
refreshSession(),
refreshSession(),
refreshSession(),
]);
// Yield once so the refresh IIFE has reached its fetch call.
await Promise.resolve();
refreshDeferred.resolve(
jsonResponse(200, {
access_token: "new-access",
refresh_token: "new-refresh",
must_change_password: false,
}),
);
const results = await callersPromise;
expect(results).toEqual([true, true, true, true, true]);
const refreshCalls = fetchMock.mock.calls.filter(([input]) => {
const url = typeof input === "string" ? input : (input as Request).url;
return url.endsWith("/api/auth/refresh");
});
expect(refreshCalls).toHaveLength(1);
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBe("new-access");
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBe("new-refresh");
});
it("clears the in-flight slot after resolution so the next call refreshes again", async () => {
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-1");
fetchMock.mockResolvedValue(
jsonResponse(200, {
access_token: "a",
refresh_token: "b",
must_change_password: false,
}),
);
const { refreshSession } = await loadApi();
await refreshSession();
await refreshSession();
const refreshCalls = fetchMock.mock.calls.filter(([input]) => {
const url = typeof input === "string" ? input : (input as Request).url;
return url.endsWith("/api/auth/refresh");
});
expect(refreshCalls).toHaveLength(2);
});
it("returns false and clears tokens on non-OK refresh response", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "old-access");
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-expired");
fetchMock.mockResolvedValue(jsonResponse(401, { detail: "invalid" }));
const { refreshSession } = await loadApi();
const ok = await refreshSession();
expect(ok).toBe(false);
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
});
it("returns false (no refresh fired) when there is no refresh token", async () => {
const { refreshSession } = await loadApi();
const ok = await refreshSession();
expect(ok).toBe(false);
expect(fetchMock).not.toHaveBeenCalled();
});
});
describe("logoutGeneration invalidates a mid-flight refresh", () => {
it("drops the new token pair when logout() runs while refresh is in flight", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "access-old");
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-pre-logout");
const refreshDeferred = deferred<Response>();
const logoutDeferred = deferred<Response>();
fetchMock.mockImplementation(async (input: RequestInfo) => {
const url = typeof input === "string" ? input : (input as Request).url;
if (url.endsWith("/api/auth/refresh")) return refreshDeferred.promise;
if (url.endsWith("/api/auth/logout")) return logoutDeferred.promise;
throw new Error(`unexpected fetch: ${url}`);
});
const { refreshSession, logout } = await loadApi();
// Start the refresh, do NOT await yet.
const refreshPromise = refreshSession();
// Yield once so the IIFE inside refreshSession starts executing.
await Promise.resolve();
// Run logout to completion; this should bump logoutGeneration before
// the refresh has the chance to write tokens back.
const logoutPromise = logout();
logoutDeferred.resolve(new Response(null, { status: 204 }));
await logoutPromise;
// Now resolve the refresh with what would otherwise be a success.
refreshDeferred.resolve(
jsonResponse(200, {
access_token: "access-NEW",
refresh_token: "rt-NEW",
must_change_password: false,
}),
);
const refreshed = await refreshPromise;
// The generation mismatch must have made refreshSession() drop the
// new pair on the floor; the SPA must stay logged out.
expect(refreshed).toBe(false);
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
});
});
describe("logout()", () => {
it("clears local state on a 204 even if the network is fine", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "a");
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "b");
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "1");
fetchMock.mockResolvedValue(new Response(null, { status: 204 }));
const { logout } = await loadApi();
await logout();
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
});
it("retries with a fresh access token when the first POST hits 401", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "stale-access");
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "rt-1");
const calls: { url: string; auth: string | null }[] = [];
fetchMock.mockImplementation(async (input: RequestInfo, init?: RequestInit) => {
const url = typeof input === "string" ? input : (input as Request).url;
const headers = new Headers(init?.headers);
calls.push({ url, auth: headers.get("authorization") });
if (url.endsWith("/api/auth/logout")) {
// First logout sees the stale token and 401s; second sees the
// post-refresh token and 204s.
const seen = calls.filter((c) => c.url.endsWith("/api/auth/logout")).length;
return seen === 1
? new Response(null, { status: 401 })
: new Response(null, { status: 204 });
}
if (url.endsWith("/api/auth/refresh")) {
return jsonResponse(200, {
access_token: "rotated-access",
refresh_token: "rt-2",
must_change_password: false,
});
}
throw new Error(`unexpected fetch: ${url}`);
});
const { logout } = await loadApi();
await logout();
const logoutCalls = calls.filter((c) => c.url.endsWith("/api/auth/logout"));
const refreshCalls = calls.filter((c) => c.url.endsWith("/api/auth/refresh"));
expect(logoutCalls).toHaveLength(2);
expect(refreshCalls).toHaveLength(1);
expect(logoutCalls[0].auth).toBe("Bearer stale-access");
expect(logoutCalls[1].auth).toBe("Bearer rotated-access");
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
});
it("still clears local state when the network throws", async () => {
localStorage.setItem(AUTH_TOKEN_KEY, "a");
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "b");
fetchMock.mockRejectedValue(new TypeError("offline"));
const { logout } = await loadApi();
await logout();
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
});
});

View file

@ -0,0 +1,117 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import { beforeEach, describe, expect, it } from "vitest";
import {
AUTH_MUST_CHANGE_PASSWORD_KEY,
AUTH_REFRESH_TOKEN_KEY,
AUTH_TOKEN_KEY,
ONBOARDING_DONE_KEY,
clearAuthTokens,
getAuthToken,
getRefreshToken,
hasAuthToken,
hasRefreshToken,
isOnboardingDone,
markOnboardingDone,
mustChangePassword,
resetOnboardingDone,
setMustChangePassword,
storeAuthTokens,
} from "@/features/auth/session";
beforeEach(() => {
localStorage.clear();
});
describe("storeAuthTokens", () => {
it("writes exactly the access + refresh keys, never the must-change-password key", () => {
storeAuthTokens("access-x", "refresh-y");
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBe("access-x");
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBe("refresh-y");
// The must-change-password flag stays out of this function (CodeQL data-flow pin).
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
});
});
describe("clearAuthTokens", () => {
it("removes the access, refresh, and must-change keys", () => {
localStorage.setItem(AUTH_TOKEN_KEY, "a");
localStorage.setItem(AUTH_REFRESH_TOKEN_KEY, "b");
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "1");
clearAuthTokens();
expect(localStorage.getItem(AUTH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_REFRESH_TOKEN_KEY)).toBeNull();
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
});
});
describe("hasAuthToken / getAuthToken", () => {
it("reads back what storeAuthTokens wrote", () => {
expect(hasAuthToken()).toBe(false);
expect(getAuthToken()).toBeNull();
storeAuthTokens("x", "y");
expect(hasAuthToken()).toBe(true);
expect(getAuthToken()).toBe("x");
});
});
describe("hasRefreshToken / getRefreshToken", () => {
it("reads back what storeAuthTokens wrote", () => {
expect(hasRefreshToken()).toBe(false);
expect(getRefreshToken()).toBeNull();
storeAuthTokens("x", "rt");
expect(hasRefreshToken()).toBe(true);
expect(getRefreshToken()).toBe("rt");
});
});
describe("mustChangePassword / setMustChangePassword (key-presence encoding)", () => {
it("starts false when nothing is stored", () => {
expect(mustChangePassword()).toBe(false);
});
it("setMustChangePassword(true) writes the literal '1' and the read returns true", () => {
setMustChangePassword(true);
// The stored value is a literal constant, not a derived boolean.
// The CodeQL data-flow break depends on this remaining a constant.
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBe("1");
expect(mustChangePassword()).toBe(true);
});
it("setMustChangePassword(false) removes the key and the read returns false", () => {
setMustChangePassword(true);
setMustChangePassword(false);
expect(localStorage.getItem(AUTH_MUST_CHANGE_PASSWORD_KEY)).toBeNull();
expect(mustChangePassword()).toBe(false);
});
it("treats any present value as truthy (presence is the signal)", () => {
// Defensive: even if some other code wrote a different value, presence wins.
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "true");
expect(mustChangePassword()).toBe(true);
localStorage.setItem(AUTH_MUST_CHANGE_PASSWORD_KEY, "");
// Empty string is still a present key.
expect(mustChangePassword()).toBe(true);
});
});
describe("onboarding flag", () => {
it("starts unset and round-trips through mark/reset", () => {
expect(isOnboardingDone()).toBe(false);
markOnboardingDone();
expect(localStorage.getItem(ONBOARDING_DONE_KEY)).toBe("true");
expect(isOnboardingDone()).toBe(true);
resetOnboardingDone();
expect(localStorage.getItem(ONBOARDING_DONE_KEY)).toBeNull();
expect(isOnboardingDone()).toBe(false);
});
it("only treats the literal 'true' as done (regression pin: not just truthy)", () => {
localStorage.setItem(ONBOARDING_DONE_KEY, "1");
expect(isOnboardingDone()).toBe(false);
localStorage.setItem(ONBOARDING_DONE_KEY, "TRUE");
expect(isOnboardingDone()).toBe(false);
});
});

View file

@ -0,0 +1,140 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
import { describe, expect, it } from "vitest";
import {
formatFastApiDetail,
readFastApiError,
} from "@/lib/format-fastapi-error";
function fakeResponse(
status: number,
body: unknown,
): Response {
return {
status,
async json() {
if (typeof body === "string") throw new SyntaxError("bad json");
return body;
},
} as unknown as Response;
}
describe("formatFastApiDetail", () => {
it("returns string detail verbatim", () => {
expect(formatFastApiDetail("password too short")).toBe(
"password too short",
);
});
it("treats empty / nullish / non-array shapes as no message", () => {
expect(formatFastApiDetail("")).toBeNull();
expect(formatFastApiDetail(null)).toBeNull();
expect(formatFastApiDetail(undefined)).toBeNull();
expect(formatFastApiDetail(42)).toBeNull();
expect(formatFastApiDetail(true)).toBeNull();
expect(formatFastApiDetail({ foo: 1 })).toBeNull();
});
it("joins a single FastAPI 422 array entry as `field: msg`", () => {
expect(
formatFastApiDetail([
{ loc: ["body", "password"], msg: "field required" },
]),
).toBe("password: field required");
});
it("joins multi-entry detail with `; ` and strips the 'body' segment", () => {
expect(
formatFastApiDetail([
{ loc: ["body", "password"], msg: "field required" },
{ loc: ["body", "username"], msg: "must be lowercase" },
]),
).toBe("password: field required; username: must be lowercase");
});
it("joins nested loc segments with `.`", () => {
expect(
formatFastApiDetail([
{ loc: ["body", "settings", "lr"], msg: "must be > 0" },
]),
).toBe("settings.lr: must be > 0");
});
it("falls back to path when msg is missing", () => {
expect(formatFastApiDetail([{ loc: ["body", "lr"] }])).toBe("lr");
});
it("falls back to msg when loc is missing", () => {
expect(formatFastApiDetail([{ msg: "bad request" }])).toBe("bad request");
});
it("returns null for empty / no-content arrays", () => {
expect(formatFastApiDetail([])).toBeNull();
expect(formatFastApiDetail([{}, {}])).toBeNull();
});
it("skips mid-array junk and yields the valid entry", () => {
expect(
formatFastApiDetail([
null,
{ loc: ["body", "x"], msg: "bad" },
"not-an-object",
]),
).toBe("x: bad");
});
});
describe("readFastApiError", () => {
it("renders 422 array detail as `field: msg`", async () => {
const r = fakeResponse(422, {
detail: [{ loc: ["body", "p"], msg: "bad" }],
});
expect(await readFastApiError(r)).toBe("p: bad");
});
it("renders 400 string detail verbatim", async () => {
expect(await readFastApiError(fakeResponse(400, { detail: "Invalid" }))).toBe(
"Invalid",
);
});
it("falls through to `message` when detail is missing", async () => {
expect(await readFastApiError(fakeResponse(500, { message: "boom" }))).toBe(
"boom",
);
});
it("uses the fallback prefix + status code when body has neither", async () => {
expect(await readFastApiError(fakeResponse(503, {}))).toBe(
"Request failed (503)",
);
});
it("handles non-JSON bodies via the fallback", async () => {
expect(await readFastApiError(fakeResponse(502, "not json"))).toBe(
"Request failed (502)",
);
});
it("honours a custom fallback prefix", async () => {
expect(
await readFastApiError(fakeResponse(413, "binary"), "Upload too large"),
).toBe("Upload too large (413)");
});
it("falls through when detail is an empty array", async () => {
expect(await readFastApiError(fakeResponse(422, { detail: [] }))).toBe(
"Request failed (422)",
);
});
it("prefers `message` when detail is an object (not a renderable shape)", async () => {
expect(
await readFastApiError(
fakeResponse(400, { detail: { foo: 1 }, message: "msg-fallback" }),
),
).toBe("msg-fallback");
});
});

View file

@ -0,0 +1,18 @@
// SPDX-License-Identifier: AGPL-3.0-only
// Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
import path from "node:path";
import { defineConfig } from "vitest/config";
export default defineConfig({
resolve: {
alias: { "@": path.resolve(__dirname, "./src") },
},
test: {
environment: "happy-dom",
globals: false,
include: ["src/**/__tests__/**/*.test.ts", "src/**/*.test.ts"],
restoreMocks: true,
clearMocks: true,
},
});