From f01e357647408f63a4e13fed81f2f9d8765cffe6 Mon Sep 17 00:00:00 2001 From: Vantz Stockwell Date: Sat, 14 Mar 2026 19:06:14 -0400 Subject: [PATCH] =?UTF-8?q?test:=20frontend=20test=20suite=20=E2=80=94=20V?= =?UTF-8?q?itest=20infrastructure,=20auth/connection=20stores,=20vault=20c?= =?UTF-8?q?omposable,=20admin=20middleware?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/package-lock.json | 678 +++++++++++++++++- frontend/package.json | 23 +- frontend/tests/composables/useVault.spec.ts | 99 +++ frontend/tests/middleware/admin.spec.ts | 52 ++ frontend/tests/setup.ts | 38 + frontend/tests/stores/auth.store.spec.ts | 165 +++++ .../tests/stores/connection.store.spec.ts | 124 ++++ frontend/vitest.config.ts | 18 + 8 files changed, 1161 insertions(+), 36 deletions(-) create mode 100644 frontend/tests/composables/useVault.spec.ts create mode 100644 frontend/tests/middleware/admin.spec.ts create mode 100644 frontend/tests/setup.ts create mode 100644 frontend/tests/stores/auth.store.spec.ts create mode 100644 frontend/tests/stores/connection.store.spec.ts create mode 100644 frontend/vitest.config.ts 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, '.'), + }, + }, +})