diff --git a/studio/frontend/package-lock.json b/studio/frontend/package-lock.json index 0525b984a1..00ae6c9ad2 100644 --- a/studio/frontend/package-lock.json +++ b/studio/frontend/package-lock.json @@ -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", diff --git a/studio/frontend/package.json b/studio/frontend/package.json index d104aad157..7db09c5c4b 100644 --- a/studio/frontend/package.json +++ b/studio/frontend/package.json @@ -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" } } diff --git a/studio/frontend/src/features/auth/__tests__/api.test.ts b/studio/frontend/src/features/auth/__tests__/api.test.ts new file mode 100644 index 0000000000..07b78c9564 --- /dev/null +++ b/studio/frontend/src/features/auth/__tests__/api.test.ts @@ -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; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }); +} + +function deferred(): { + promise: Promise; + resolve: (v: T) => void; + reject: (e: unknown) => void; +} { + let resolve!: (v: T) => void; + let reject!: (e: unknown) => void; + const promise = new Promise((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(); + 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(); + const logoutDeferred = deferred(); + + 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(); + }); +}); diff --git a/studio/frontend/src/features/auth/__tests__/session.test.ts b/studio/frontend/src/features/auth/__tests__/session.test.ts new file mode 100644 index 0000000000..0f08fff8f1 --- /dev/null +++ b/studio/frontend/src/features/auth/__tests__/session.test.ts @@ -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); + }); +}); diff --git a/studio/frontend/src/lib/__tests__/format-fastapi-error.test.ts b/studio/frontend/src/lib/__tests__/format-fastapi-error.test.ts new file mode 100644 index 0000000000..5978efbabb --- /dev/null +++ b/studio/frontend/src/lib/__tests__/format-fastapi-error.test.ts @@ -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"); + }); +}); diff --git a/studio/frontend/vitest.config.ts b/studio/frontend/vitest.config.ts new file mode 100644 index 0000000000..233104b012 --- /dev/null +++ b/studio/frontend/vitest.config.ts @@ -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, + }, +});