diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 6eda9fa..4d9805b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -23,9 +23,13 @@ }, "devDependencies": { "@nuxtjs/tailwindcss": "^6.0.0", + "@pinia/testing": "^0.1.7", "@primevue/nuxt-module": "^4.0.0", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.8.4", "nuxt": "^3.10.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^4.1.0" } }, "node_modules/@alloc/quick-lru": { @@ -1706,23 +1710,6 @@ "nuxt": "^3.21.2" } }, - "node_modules/@nuxt/schema": { - "version": "4.4.2", - "resolved": "https://registry.npmjs.org/@nuxt/schema/-/schema-4.4.2.tgz", - "integrity": "sha512-/q6C7Qhiricgi+PKR7ovBnJlKTL0memCbA1CzRT+itCW/oeYzUfeMdQ35mGntlBoyRPNrMXbzuSUhfDbSCU57w==", - "extraneous": true, - "license": "MIT", - "dependencies": { - "@vue/shared": "^3.5.30", - "defu": "^6.1.4", - "pathe": "^2.0.3", - "pkg-types": "^2.3.0", - "std-env": "^4.0.0" - }, - "engines": { - "node": "^14.18.0 || >=16.10.0" - } - }, "node_modules/@nuxt/telemetry": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/@nuxt/telemetry/-/telemetry-2.7.0.tgz", @@ -1849,6 +1836,13 @@ "unctx": "^2.4.1" } }, + "node_modules/@one-ini/wasm": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@one-ini/wasm/-/wasm-0.1.1.tgz", + "integrity": "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw==", + "dev": true, + "license": "MIT" + }, "node_modules/@oxc-minify/binding-android-arm-eabi": { "version": "0.117.0", "resolved": "https://registry.npmjs.org/@oxc-minify/binding-android-arm-eabi/-/binding-android-arm-eabi-0.117.0.tgz", @@ -3229,6 +3223,22 @@ "url": "https://github.com/sponsors/posva" } }, + "node_modules/@pinia/testing": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.7.tgz", + "integrity": "sha512-xcDq6Ry/kNhZ5bsUMl7DeoFXwdume1NYzDggCiDUDKoPQ6Mo0eH9VU7bJvBtlurqe6byAntWoX5IhVFqWzRz/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "vue-demi": "^0.14.10" + }, + "funding": { + "url": "https://github.com/sponsors/posva" + }, + "peerDependencies": { + "pinia": ">=2.2.6" + } + }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", @@ -4022,6 +4032,13 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -4033,12 +4050,40 @@ "tslib": "^2.4.0" } }, + "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/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", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, + "node_modules/@types/node": { + "version": "25.5.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-25.5.0.tgz", + "integrity": "sha512-jp2P3tQMSxWugkCUKLRPVUpGaL5MVFwF8RDuSRztfwgN1wmqJeMSbKlnEtQqU8UrhTmzEmZdu2I6v2dpp7XIxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, "node_modules/@types/resolve": { "version": "1.20.2", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", @@ -4046,6 +4091,23 @@ "dev": true, "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/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@unhead/vue": { "version": "2.1.12", "resolved": "https://registry.npmjs.org/@unhead/vue/-/vue-2.1.12.tgz", @@ -4135,6 +4197,129 @@ "vue": "^3.0.0" } }, + "node_modules/@vitest/expect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", + "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "chai": "^6.2.2", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", + "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.0", + "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-0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/mocker/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/@vitest/pretty-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", + "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", + "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.0", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", + "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "@vitest/utils": "4.1.0", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", + "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", + "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.0", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@volar/language-core": { "version": "2.4.28", "resolved": "https://registry.npmjs.org/@volar/language-core/-/language-core-2.4.28.tgz", @@ -4399,6 +4584,17 @@ "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", "license": "MIT" }, + "node_modules/@vue/test-utils": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@vue/test-utils/-/test-utils-2.4.6.tgz", + "integrity": "sha512-FMxEjOpYNYiFe0GkaHsnJPXFHxQ6m4t8vI/ElPGpMWxZKpmRvQ33OIrvRXemy6yha03RxhOlQuy+gZMC3CQSow==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-beautify": "^1.14.9", + "vue-component-type-helpers": "^2.0.0" + } + }, "node_modules/@xterm/addon-fit": { "version": "0.10.0", "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", @@ -4747,6 +4943,16 @@ "dev": true, "license": "MIT" }, + "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/ast-kit": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ast-kit/-/ast-kit-2.2.0.tgz", @@ -5312,6 +5518,16 @@ ], "license": "CC-BY-4.0" }, + "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", @@ -5444,16 +5660,6 @@ "dev": true, "license": "MIT" }, - "node_modules/commander": { - "version": "11.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", - "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=16" - } - }, "node_modules/commondir": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", @@ -5511,6 +5717,24 @@ "integrity": "sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==", "license": "MIT" }, + "node_modules/config-chain": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", + "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ini": "^1.3.4", + "proto-list": "~1.2.1" + } + }, + "node_modules/config-chain/node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "dev": true, + "license": "ISC" + }, "node_modules/consola": { "version": "3.4.2", "resolved": "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz", @@ -6184,6 +6408,68 @@ "dev": true, "license": "MIT" }, + "node_modules/editorconfig": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/editorconfig/-/editorconfig-1.0.7.tgz", + "integrity": "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@one-ini/wasm": "0.1.1", + "commander": "^10.0.0", + "minimatch": "^9.0.1", + "semver": "^7.5.3" + }, + "bin": { + "editorconfig": "bin/editorconfig" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/editorconfig/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/editorconfig/node_modules/commander": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/editorconfig/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -6439,6 +6725,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/exsolve": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/exsolve/-/exsolve-1.0.8.tgz", @@ -6889,6 +7185,24 @@ "uncrypto": "^0.1.3" } }, + "node_modules/happy-dom": { + "version": "20.8.4", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.8.4.tgz", + "integrity": "sha512-GKhjq4OQCYB4VLFBzv8mmccUadwlAusOZOI7hC1D9xDIT5HhzkJK17c4el2f6R6C715P9xB4uiMxeKUa2nHMwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.18.3" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -7457,6 +7771,143 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-beautify": { + "version": "1.15.4", + "resolved": "https://registry.npmjs.org/js-beautify/-/js-beautify-1.15.4.tgz", + "integrity": "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "config-chain": "^1.1.13", + "editorconfig": "^1.0.4", + "glob": "^10.4.2", + "js-cookie": "^3.0.5", + "nopt": "^7.2.1" + }, + "bin": { + "css-beautify": "js/bin/css-beautify.js", + "html-beautify": "js/bin/html-beautify.js", + "js-beautify": "js/bin/js-beautify.js" + }, + "engines": { + "node": ">=14" + } + }, + "node_modules/js-beautify/node_modules/abbrev": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", + "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-beautify/node_modules/brace-expansion": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/js-beautify/node_modules/glob": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", + "integrity": "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/lru-cache": { + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/js-beautify/node_modules/minimatch": { + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-beautify/node_modules/nopt": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", + "integrity": "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w==", + "dev": true, + "license": "ISC", + "dependencies": { + "abbrev": "^2.0.0" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } + }, + "node_modules/js-beautify/node_modules/path-scurry": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^10.2.0", + "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + }, + "engines": { + "node": ">=16 || 14 >=14.18" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -9867,6 +10318,13 @@ "dev": true, "license": "MIT" }, + "node_modules/proto-list": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", + "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", + "dev": true, + "license": "ISC" + }, "node_modules/quansync": { "version": "0.2.11", "resolved": "https://registry.npmjs.org/quansync/-/quansync-0.2.11.tgz", @@ -10549,6 +11007,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", @@ -10676,6 +11141,13 @@ "node": ">=20.16.0" } }, + "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/standard-as-callback": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", @@ -10922,6 +11394,16 @@ "url": "https://opencollective.com/svgo" } }, + "node_modules/svgo/node_modules/commander": { + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz", + "integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, "node_modules/system-architecture": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/system-architecture/-/system-architecture-0.1.0.tgz", @@ -11300,6 +11782,13 @@ "dev": true, "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/tinyclip": { "version": "0.1.12", "resolved": "https://registry.npmjs.org/tinyclip/-/tinyclip-0.1.12.tgz", @@ -11335,6 +11824,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/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -11530,6 +12029,13 @@ "node": ">=18.12.0" } }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, "node_modules/unenv": { "version": "2.0.0-rc.24", "resolved": "https://registry.npmjs.org/unenv/-/unenv-2.0.0-rc.24.tgz", @@ -12409,6 +12915,88 @@ "@types/estree": "^1.0.0" } }, + "node_modules/vitest": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", + "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.0", + "@vitest/mocker": "4.1.0", + "@vitest/pretty-format": "4.1.0", + "@vitest/runner": "4.1.0", + "@vitest/snapshot": "4.1.0", + "@vitest/spy": "4.1.0", + "@vitest/utils": "4.1.0", + "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.0.3", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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.0", + "@vitest/browser-preview": "4.1.0", + "@vitest/browser-webdriverio": "4.1.0", + "@vitest/ui": "4.1.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.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/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, "node_modules/vscode-uri": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/vscode-uri/-/vscode-uri-3.1.0.tgz", @@ -12447,6 +13035,13 @@ "ufo": "^1.6.1" } }, + "node_modules/vue-component-type-helpers": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/vue-component-type-helpers/-/vue-component-type-helpers-2.2.12.tgz", + "integrity": "sha512-YbGqHZ5/eW4SnkPNR44mKVc6ZKQoRs/Rux1sxC6rdwXb4qpbOSYfDr9DsTHolOTGmIKgM9j141mZbBeg05R1pw==", + "dev": true, + "license": "MIT" + }, "node_modules/vue-demi": { "version": "0.14.10", "resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz", @@ -12509,6 +13104,16 @@ "integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==", "license": "MIT" }, + "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/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -12536,6 +13141,23 @@ "node": "^18.17.0 || >=20.5.0" } }, + "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/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index bb717b7..857e580 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,26 +5,33 @@ "scripts": { "dev": "nuxi dev", "build": "nuxi generate", - "preview": "nuxi preview" + "preview": "nuxi preview", + "test": "vitest run", + "test:watch": "vitest", + "test:cov": "vitest run --coverage" }, "dependencies": { "@pinia/nuxt": "^0.5.0", "@primevue/themes": "^4.0.0", + "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-search": "^0.15.0", + "@xterm/addon-web-links": "^0.11.0", + "@xterm/addon-webgl": "^0.18.0", + "@xterm/xterm": "^5.4.0", "guacamole-common-js": "^1.5.0", "lucide-vue-next": "^0.300.0", "monaco-editor": "^0.45.0", "pinia": "^2.1.0", - "primevue": "^4.0.0", - "@xterm/xterm": "^5.4.0", - "@xterm/addon-fit": "^0.10.0", - "@xterm/addon-search": "^0.15.0", - "@xterm/addon-web-links": "^0.11.0", - "@xterm/addon-webgl": "^0.18.0" + "primevue": "^4.0.0" }, "devDependencies": { "@nuxtjs/tailwindcss": "^6.0.0", + "@pinia/testing": "^0.1.7", "@primevue/nuxt-module": "^4.0.0", + "@vue/test-utils": "^2.4.6", + "happy-dom": "^20.8.4", "nuxt": "^3.10.0", - "typescript": "^5.3.0" + "typescript": "^5.3.0", + "vitest": "^4.1.0" } } diff --git a/frontend/tests/composables/useVault.spec.ts b/frontend/tests/composables/useVault.spec.ts new file mode 100644 index 0000000..3cd2491 --- /dev/null +++ b/frontend/tests/composables/useVault.spec.ts @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { useVault } from '../../composables/useVault' + +const mockFetch = vi.mocked($fetch as ReturnType) + +beforeEach(() => { + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// Helper: assert no Authorization header was sent +// --------------------------------------------------------------------------- +function assertNoAuthHeader(callIndex = 0) { + const callArgs = mockFetch.mock.calls[callIndex] + const options = callArgs[1] as Record | undefined + if (options && options.headers) { + const headers = options.headers as Record + expect(headers).not.toHaveProperty('Authorization') + } + // If no options object at all — cookie-only, no auth header by definition +} + +// --------------------------------------------------------------------------- +// SSH Keys +// --------------------------------------------------------------------------- +describe('listKeys()', () => { + it('fetches /api/ssh-keys without Authorization header', async () => { + mockFetch.mockResolvedValueOnce([]) + const { listKeys } = useVault() + await listKeys() + expect(mockFetch).toHaveBeenCalledWith('/api/ssh-keys') + assertNoAuthHeader() + }) +}) + +describe('importKey()', () => { + it('posts key data to /api/ssh-keys', async () => { + mockFetch.mockResolvedValueOnce({ id: 1 }) + const { importKey } = useVault() + const payload = { name: 'my-key', privateKey: '-----BEGIN RSA PRIVATE KEY-----' } + await importKey(payload) + expect(mockFetch).toHaveBeenCalledWith('/api/ssh-keys', { method: 'POST', body: payload }) + assertNoAuthHeader() + }) +}) + +describe('deleteKey()', () => { + it('sends DELETE to /api/ssh-keys/:id', async () => { + mockFetch.mockResolvedValueOnce(undefined) + const { deleteKey } = useVault() + await deleteKey(7) + expect(mockFetch).toHaveBeenCalledWith('/api/ssh-keys/7', { method: 'DELETE' }) + assertNoAuthHeader() + }) +}) + +// --------------------------------------------------------------------------- +// Credentials +// --------------------------------------------------------------------------- +describe('listCredentials()', () => { + it('fetches /api/credentials without Authorization header', async () => { + mockFetch.mockResolvedValueOnce([]) + const { listCredentials } = useVault() + await listCredentials() + expect(mockFetch).toHaveBeenCalledWith('/api/credentials') + assertNoAuthHeader() + }) +}) + +describe('createCredential()', () => { + it('posts credential data to /api/credentials', async () => { + const cred = { label: 'prod-db', username: 'admin', password: 'hunter2' } + mockFetch.mockResolvedValueOnce({ id: 5, ...cred }) + const { createCredential } = useVault() + await createCredential(cred) + expect(mockFetch).toHaveBeenCalledWith('/api/credentials', { method: 'POST', body: cred }) + assertNoAuthHeader() + }) +}) + +describe('updateCredential()', () => { + it('sends PUT to /api/credentials/:id', async () => { + mockFetch.mockResolvedValueOnce(undefined) + const { updateCredential } = useVault() + await updateCredential(5, { password: 'new-pass' }) + expect(mockFetch).toHaveBeenCalledWith('/api/credentials/5', { method: 'PUT', body: { password: 'new-pass' } }) + assertNoAuthHeader() + }) +}) + +describe('deleteCredential()', () => { + it('sends DELETE to /api/credentials/:id', async () => { + mockFetch.mockResolvedValueOnce(undefined) + const { deleteCredential } = useVault() + await deleteCredential(5) + expect(mockFetch).toHaveBeenCalledWith('/api/credentials/5', { method: 'DELETE' }) + assertNoAuthHeader() + }) +}) diff --git a/frontend/tests/middleware/admin.spec.ts b/frontend/tests/middleware/admin.spec.ts new file mode 100644 index 0000000..00e84fe --- /dev/null +++ b/frontend/tests/middleware/admin.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAuthStore } from '../../stores/auth.store' + +// The middleware calls useAuthStore() and navigateTo() as Nuxt auto-imports. +// We override the global.useAuthStore shim (set in setup.ts) with the real +// store factory so it resolves against the active Pinia instance. +// navigateTo is already vi.fn() from setup.ts. + +const mockNavigateTo = vi.mocked(navigateTo as ReturnType) + +// Load the middleware factory. +// defineNuxtRouteMiddleware is a pass-through shim that just returns the fn. +// So `adminMiddleware` will be the inner route handler function. +let adminMiddleware: () => unknown + +beforeEach(async () => { + setActivePinia(createPinia()) + vi.clearAllMocks() + + // Bind useAuthStore to the global so middleware can call it as an auto-import + global.useAuthStore = useAuthStore as any + + // Re-import the middleware fresh each test to get the current global binding + const mod = await import('../../middleware/admin?t=' + Date.now()) + adminMiddleware = mod.default +}) + +// --------------------------------------------------------------------------- +// admin middleware +// --------------------------------------------------------------------------- +describe('admin middleware', () => { + it('redirects non-admin users to "/"', () => { + const auth = useAuthStore() + // Non-admin user + auth.user = { id: 2, email: 'bob@example.com', displayName: 'Bob', role: 'user' } + + adminMiddleware() + + expect(mockNavigateTo).toHaveBeenCalledWith('/') + }) + + it('allows admin users through without redirecting', () => { + const auth = useAuthStore() + // Admin user + auth.user = { id: 1, email: 'alice@example.com', displayName: 'Alice', role: 'admin' } + + adminMiddleware() + + expect(mockNavigateTo).not.toHaveBeenCalled() + }) +}) diff --git a/frontend/tests/setup.ts b/frontend/tests/setup.ts new file mode 100644 index 0000000..1f389b7 --- /dev/null +++ b/frontend/tests/setup.ts @@ -0,0 +1,38 @@ +import { vi } from 'vitest' +import { ref, computed, onMounted, watch } from 'vue' + +// ------------------------------------------------------------------- +// Nuxt auto-import globals +// These are injected by Nuxt at runtime but Vitest doesn't run Nuxt, +// so we shim them here so test files can import store/composable modules +// without hitting "X is not defined" errors. +// ------------------------------------------------------------------- + +// $fetch — Nuxt's isomorphic fetch wrapper +global.$fetch = vi.fn() + +// navigateTo — Nuxt router utility +global.navigateTo = vi.fn() + +// Route middleware helpers +global.defineNuxtRouteMiddleware = vi.fn((fn: Function) => fn) + +// Plugin helper (used in some plugin files, not in tests directly) +global.defineNuxtPlugin = vi.fn((fn: Function) => fn) + +// Page meta (used in page components) +global.definePageMeta = vi.fn() + +// Vue Composition API — re-export from Vue so stores using these +// via Nuxt auto-imports resolve correctly in Vitest +global.ref = ref +global.computed = computed +global.onMounted = onMounted +global.watch = watch + +// useAuthStore — needed by middleware; forward-declared here so +// admin.ts middleware can resolve it. Individual test files override +// this with the real Pinia store instance. +// (Set to undefined by default — tests that import middleware must +// call setActivePinia first and then the real store will resolve.) +global.useAuthStore = undefined as any diff --git a/frontend/tests/stores/auth.store.spec.ts b/frontend/tests/stores/auth.store.spec.ts new file mode 100644 index 0000000..23c70e8 --- /dev/null +++ b/frontend/tests/stores/auth.store.spec.ts @@ -0,0 +1,165 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useAuthStore } from '../../stores/auth.store' + +// $fetch and navigateTo are shimmed in tests/setup.ts as globals. +// We re-cast them here so vi.mocked() provides typed mock utilities. +const mockFetch = vi.mocked($fetch as ReturnType) +const mockNavigateTo = vi.mocked(navigateTo as ReturnType) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// login() +// --------------------------------------------------------------------------- +describe('login()', () => { + it('stores the user and returns the response on success', async () => { + const user = { id: 1, email: 'alice@example.com', displayName: 'Alice', role: 'user' } + mockFetch.mockResolvedValueOnce({ user }) + + const auth = useAuthStore() + const result = await auth.login('alice@example.com', 'secret') + + expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', { + method: 'POST', + body: { email: 'alice@example.com', password: 'secret' }, + }) + expect(auth.user).toEqual(user) + expect(result).toEqual({ user }) + }) + + it('includes totpCode in body when provided', async () => { + const user = { id: 1, email: 'alice@example.com', displayName: 'Alice', role: 'user' } + mockFetch.mockResolvedValueOnce({ user }) + + const auth = useAuthStore() + await auth.login('alice@example.com', 'secret', '123456') + + expect(mockFetch).toHaveBeenCalledWith('/api/auth/login', { + method: 'POST', + body: { email: 'alice@example.com', password: 'secret', totpCode: '123456' }, + }) + }) + + it('returns requires_totp and does NOT set user when TOTP is required', async () => { + mockFetch.mockResolvedValueOnce({ requires_totp: true }) + + const auth = useAuthStore() + const result = await auth.login('alice@example.com', 'secret') + + expect(result).toEqual({ requires_totp: true }) + expect(auth.user).toBeNull() + }) + + it('does NOT include an Authorization header (cookie-only auth)', async () => { + mockFetch.mockResolvedValueOnce({ user: { id: 1, email: 'a@b.com', displayName: null, role: 'user' } }) + + const auth = useAuthStore() + await auth.login('a@b.com', 'pass') + + const callArgs = mockFetch.mock.calls[0] + const options = callArgs[1] as Record + expect(options).not.toHaveProperty('headers') + }) +}) + +// --------------------------------------------------------------------------- +// logout() +// --------------------------------------------------------------------------- +describe('logout()', () => { + it('clears user state and calls navigateTo("/login")', async () => { + mockFetch.mockResolvedValueOnce({}) // logout POST succeeds + const auth = useAuthStore() + auth.user = { id: 1, email: 'alice@example.com', displayName: 'Alice', role: 'user' } + + await auth.logout() + + expect(auth.user).toBeNull() + expect(mockNavigateTo).toHaveBeenCalledWith('/login') + }) + + it('clears user state even when the logout request fails', async () => { + mockFetch.mockRejectedValueOnce(new Error('network error')) + const auth = useAuthStore() + auth.user = { id: 1, email: 'alice@example.com', displayName: 'Alice', role: 'user' } + + await auth.logout() + + expect(auth.user).toBeNull() + expect(mockNavigateTo).toHaveBeenCalledWith('/login') + }) +}) + +// --------------------------------------------------------------------------- +// fetchProfile() +// --------------------------------------------------------------------------- +describe('fetchProfile()', () => { + it('sets user on success', async () => { + const user = { id: 2, email: 'bob@example.com', displayName: 'Bob', role: 'admin' } + mockFetch.mockResolvedValueOnce(user) + + const auth = useAuthStore() + await auth.fetchProfile() + + expect(auth.user).toEqual(user) + expect(mockFetch).toHaveBeenCalledWith('/api/auth/profile') + }) + + it('sets user to null on failure', async () => { + mockFetch.mockRejectedValueOnce(new Error('401')) + const auth = useAuthStore() + auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'user' } + + await auth.fetchProfile() + + expect(auth.user).toBeNull() + }) +}) + +// --------------------------------------------------------------------------- +// getWsTicket() +// --------------------------------------------------------------------------- +describe('getWsTicket()', () => { + it('returns the ticket string from the API', async () => { + mockFetch.mockResolvedValueOnce({ ticket: 'abc-xyz-ticket' }) + + const auth = useAuthStore() + const ticket = await auth.getWsTicket() + + expect(ticket).toBe('abc-xyz-ticket') + expect(mockFetch).toHaveBeenCalledWith('/api/auth/ws-ticket', { method: 'POST' }) + }) +}) + +// --------------------------------------------------------------------------- +// getters +// --------------------------------------------------------------------------- +describe('isAuthenticated getter', () => { + it('returns false when user is null', () => { + const auth = useAuthStore() + expect(auth.isAuthenticated).toBe(false) + }) + + it('returns true when user is set', () => { + const auth = useAuthStore() + auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'user' } + expect(auth.isAuthenticated).toBe(true) + }) +}) + +describe('isAdmin getter', () => { + it('returns false for non-admin role', () => { + const auth = useAuthStore() + auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'user' } + expect(auth.isAdmin).toBe(false) + }) + + it('returns true for admin role', () => { + const auth = useAuthStore() + auth.user = { id: 1, email: 'a@b.com', displayName: null, role: 'admin' } + expect(auth.isAdmin).toBe(true) + }) +}) diff --git a/frontend/tests/stores/connection.store.spec.ts b/frontend/tests/stores/connection.store.spec.ts new file mode 100644 index 0000000..4e5ef79 --- /dev/null +++ b/frontend/tests/stores/connection.store.spec.ts @@ -0,0 +1,124 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useConnectionStore } from '../../stores/connection.store' + +const mockFetch = vi.mocked($fetch as ReturnType) + +const makeHost = (id: number) => ({ + id, + name: `host-${id}`, + hostname: `192.168.1.${id}`, + port: 22, + protocol: 'ssh' as const, + groupId: null, + credentialId: null, + tags: [], + notes: null, + color: null, + lastConnectedAt: null, + group: null, +}) + +beforeEach(() => { + setActivePinia(createPinia()) + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// fetchHosts() +// --------------------------------------------------------------------------- +describe('fetchHosts()', () => { + it('populates hosts array and resets loading flag', async () => { + const hosts = [makeHost(1), makeHost(2)] + mockFetch.mockResolvedValueOnce(hosts) + + const store = useConnectionStore() + await store.fetchHosts() + + expect(store.hosts).toEqual(hosts) + expect(store.loading).toBe(false) + expect(mockFetch).toHaveBeenCalledWith('/api/hosts') + }) + + it('does NOT include Authorization headers (cookie-only auth)', async () => { + mockFetch.mockResolvedValueOnce([]) + const store = useConnectionStore() + await store.fetchHosts() + + const callArgs = mockFetch.mock.calls[0] + // fetchHosts passes no options — only the URL + expect(callArgs.length).toBe(1) + }) +}) + +// --------------------------------------------------------------------------- +// createHost() +// --------------------------------------------------------------------------- +describe('createHost()', () => { + it('posts to /api/hosts and refreshes the list', async () => { + const newHost = makeHost(10) + // First call: POST to create, second call: GET for fetchHosts + mockFetch + .mockResolvedValueOnce(newHost) // POST + .mockResolvedValueOnce([newHost]) // GET (fetchHosts) + + const store = useConnectionStore() + const result = await store.createHost({ name: 'host-10', hostname: '10.0.0.10', port: 22, protocol: 'ssh' }) + + expect(result).toEqual(newHost) + expect(mockFetch).toHaveBeenNthCalledWith(1, '/api/hosts', { + method: 'POST', + body: { name: 'host-10', hostname: '10.0.0.10', port: 22, protocol: 'ssh' }, + }) + expect(store.hosts).toEqual([newHost]) + }) +}) + +// --------------------------------------------------------------------------- +// deleteHost() +// --------------------------------------------------------------------------- +describe('deleteHost()', () => { + it('sends DELETE and refreshes the list', async () => { + mockFetch + .mockResolvedValueOnce(undefined) // DELETE + .mockResolvedValueOnce([]) // fetchHosts + + const store = useConnectionStore() + await store.deleteHost(5) + + expect(mockFetch).toHaveBeenNthCalledWith(1, '/api/hosts/5', { method: 'DELETE' }) + expect(store.hosts).toEqual([]) + }) +}) + +// --------------------------------------------------------------------------- +// Group CRUD +// --------------------------------------------------------------------------- +describe('createGroup()', () => { + it('posts to /api/groups and refreshes the tree', async () => { + mockFetch + .mockResolvedValueOnce(undefined) // POST + .mockResolvedValueOnce([]) // fetchTree + + const store = useConnectionStore() + await store.createGroup({ name: 'Dev Servers' }) + + expect(mockFetch).toHaveBeenNthCalledWith(1, '/api/groups', { + method: 'POST', + body: { name: 'Dev Servers' }, + }) + }) +}) + +describe('deleteGroup()', () => { + it('sends DELETE and refreshes the tree', async () => { + mockFetch + .mockResolvedValueOnce(undefined) // DELETE + .mockResolvedValueOnce([]) // fetchTree + + const store = useConnectionStore() + await store.deleteGroup(3) + + expect(mockFetch).toHaveBeenNthCalledWith(1, '/api/groups/3', { method: 'DELETE' }) + }) +}) diff --git a/frontend/vitest.config.ts b/frontend/vitest.config.ts new file mode 100644 index 0000000..c008d20 --- /dev/null +++ b/frontend/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config' +import vue from '@vitejs/plugin-vue' +import { resolve } from 'path' + +export default defineConfig({ + plugins: [vue()], + test: { + globals: true, + environment: 'happy-dom', + setupFiles: ['./tests/setup.ts'], + }, + resolve: { + alias: { + '~': resolve(__dirname, '.'), + '@': resolve(__dirname, '.'), + }, + }, +})