test: frontend test suite — Vitest infrastructure, auth/connection stores, vault composable, admin middleware

28 tests across 4 spec files. Vitest + happy-dom configured with Nuxt auto-import
shims ($$fetch, navigateTo, defineNuxtRouteMiddleware) so stores and composables
resolve cleanly outside the Nuxt runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Vantz Stockwell 2026-03-14 19:06:14 -04:00
parent 5abbffca9b
commit f01e357647
8 changed files with 1161 additions and 36 deletions

View File

@ -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",

View File

@ -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"
}
}

View File

@ -0,0 +1,99 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useVault } from '../../composables/useVault'
const mockFetch = vi.mocked($fetch as ReturnType<typeof vi.fn>)
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<string, unknown> | undefined
if (options && options.headers) {
const headers = options.headers as Record<string, unknown>
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()
})
})

View File

@ -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<typeof vi.fn>)
// 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()
})
})

38
frontend/tests/setup.ts Normal file
View File

@ -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

View File

@ -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<typeof vi.fn>)
const mockNavigateTo = vi.mocked(navigateTo as ReturnType<typeof vi.fn>)
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<string, unknown>
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)
})
})

View File

@ -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<typeof vi.fn>)
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' })
})
})

18
frontend/vitest.config.ts Normal file
View File

@ -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, '.'),
},
},
})