diff --git a/.github/workflows/test-web.yml.disabled b/.github/workflows/test-web.yml similarity index 100% rename from .github/workflows/test-web.yml.disabled rename to .github/workflows/test-web.yml diff --git a/Cargo.lock b/Cargo.lock index fab47d9761..2d9414490c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5271,9 +5271,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef" dependencies = [ "aws-lc-rs", "ring", diff --git a/web/package.json b/web/package.json index 34e6e8fcba..256cf62af0 100644 --- a/web/package.json +++ b/web/package.json @@ -10,7 +10,10 @@ "biome": "biome", "lint": "biome check ./src/ && prettier src/**/*.scss --check --log-level error && stylelint \"src/**/*.scss\" -c ./.stylelintrc.json && tsc -b", "fix": "biome check ./src/ --write --unsafe && prettier src/**/*.scss -w --log-level silent", - "tsc": "tsc" + "tsc": "tsc", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:watch": "vitest" }, "dependencies": { "@axa-ch/react-polymorphic-types": "^1.4.1", @@ -66,6 +69,7 @@ "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^6.0.1", + "@vitest/ui": "^4.1.0", "autoprefixer": "^10.4.27", "globals": "^17.4.0", "prettier": "^3.8.1", @@ -76,6 +80,7 @@ "stylelint-scss": "^7.0.0", "typescript": "~5.9.3", "vite": "^8.0.0", - "vite-plugin-image-optimizer": "^2.0.3" + "vite-plugin-image-optimizer": "^2.0.3", + "vitest": "^4.1.0" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 8246963e53..1ce3be632a 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -159,6 +159,9 @@ importers: '@vitejs/plugin-react': specifier: ^6.0.1 version: 6.0.1(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0)) + '@vitest/ui': + specifier: ^4.1.0 + version: 4.1.0(vitest@4.1.0) autoprefixer: specifier: ^10.4.27 version: 10.4.27(postcss@8.5.8) @@ -192,6 +195,9 @@ importers: vite-plugin-image-optimizer: specifier: ^2.0.3 version: 2.0.3(sharp@0.34.5)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0)) + vitest: + specifier: ^4.1.0 + version: 4.1.0(@types/node@25.5.0)(@vitest/ui@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0)) packages: @@ -306,28 +312,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.7': resolution: {integrity: sha512-om6FugwmibzfP/6ALj5WRDVSND4H2G9X0nkI1HZpp2ySf9lW2j0X68oQSaHEnls6666oy4KDsc5RFjT4m0kV0w==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.7': resolution: {integrity: sha512-00kx4YrBMU8374zd2wHuRV5wseh0rom5HqRND+vDldJPrWwQw+mzd/d8byI9hPx926CG+vWzq6AeiT7Yi5y59g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.7': resolution: {integrity: sha512-bV8/uo2Tj+gumnk4sUdkerWyCPRabaZdv88IpbmDWARQQoA/Q0YaqPz1a+LSEDIL7OfrnPi9Hq1Llz4ZIGyIQQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.7': resolution: {integrity: sha512-hOUHBMlFCvDhu3WCq6vaBoG0dp0LkWxSEnEEsxxXvOa9TfT6ZBnbh72A/xBM7CBYB7WgwqboetzFEVDnMxelyw==} @@ -607,105 +609,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -824,42 +810,36 @@ packages: engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm-musl@2.5.6': resolution: {integrity: sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg==} engines: {node: '>= 10.0.0'} cpu: [arm] os: [linux] - libc: [musl] '@parcel/watcher-linux-arm64-glibc@2.5.6': resolution: {integrity: sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-arm64-musl@2.5.6': resolution: {integrity: sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA==} engines: {node: '>= 10.0.0'} cpu: [arm64] os: [linux] - libc: [musl] '@parcel/watcher-linux-x64-glibc@2.5.6': resolution: {integrity: sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [glibc] '@parcel/watcher-linux-x64-musl@2.5.6': resolution: {integrity: sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg==} engines: {node: '>= 10.0.0'} cpu: [x64] os: [linux] - libc: [musl] '@parcel/watcher-win32-arm64@2.5.6': resolution: {integrity: sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q==} @@ -883,6 +863,9 @@ packages: resolution: {integrity: sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ==} engines: {node: '>= 10.0.0'} + '@polka/url@1.0.0-next.29': + resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==} + '@react-hook/latest@1.0.3': resolution: {integrity: sha512-dy6duzl+JnAZcDbNTfmaP3xHiKtbXYOaz3G51MGVljh548Y8MWzTr+PHLOfvpypEVW9zwvl+VyKjbWKEVbV1Rg==} peerDependencies: @@ -944,42 +927,36 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.0-rc.9': resolution: {integrity: sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] - libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.9': resolution: {integrity: sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.0-rc.9': resolution: {integrity: sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.0-rc.9': resolution: {integrity: sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] - libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.0-rc.9': resolution: {integrity: sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==} @@ -1267,6 +1244,9 @@ packages: '@types/byte-size@8.1.2': resolution: {integrity: sha512-jGyVzYu6avI8yuqQCNTZd65tzI8HZrLjKX9sdMqZrGWVlNChu0rf6p368oVEDCYJe5BMx2Ov04tD1wqtgTwGSA==} + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + '@types/d3-array@3.2.2': resolution: {integrity: sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==} @@ -1297,6 +1277,9 @@ packages: '@types/debug@4.1.12': resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1367,6 +1350,40 @@ packages: babel-plugin-react-compiler: optional: true + '@vitest/expect@4.1.0': + resolution: {integrity: sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==} + + '@vitest/mocker@4.1.0': + resolution: {integrity: sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0 || ^8.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.1.0': + resolution: {integrity: sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==} + + '@vitest/runner@4.1.0': + resolution: {integrity: sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==} + + '@vitest/snapshot@4.1.0': + resolution: {integrity: sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==} + + '@vitest/spy@4.1.0': + resolution: {integrity: sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==} + + '@vitest/ui@4.1.0': + resolution: {integrity: sha512-sTSDtVM1GOevRGsCNhp1mBUHKo9Qlc55+HCreFT4fe99AHxl1QQNXSL3uj4Pkjh5yEuWZIx8E2tVC94nnBZECQ==} + peerDependencies: + vitest: 4.1.0 + + '@vitest/utils@4.1.0': + resolution: {integrity: sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==} + acorn@8.16.0: resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==} engines: {node: '>=0.4.0'} @@ -1405,6 +1422,10 @@ packages: array-timsort@1.0.3: resolution: {integrity: sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + ast-types@0.16.1: resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} engines: {node: '>=4'} @@ -1480,6 +1501,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + chalk@5.6.2: resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} @@ -1689,6 +1714,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@2.0.0: + resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1717,9 +1745,16 @@ packages: estree-util-is-identifier-name@3.0.0: resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1749,6 +1784,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@11.1.2: resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} @@ -1759,6 +1797,9 @@ packages: flat-cache@6.1.20: resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + flatted@3.4.0: + resolution: {integrity: sha512-kC6Bb+ooptOIvWj5B63EQWkF0FEnNjV2ZNkLMLZRDDduIiWeFF4iKnslwhiWxjAdbg4NzTNo6h0qLuvFrcx+Sw==} + flatted@3.4.1: resolution: {integrity: sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==} @@ -2082,28 +2123,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -2136,6 +2173,9 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2273,6 +2313,10 @@ packages: react-dom: optional: true + mrmime@2.0.1: + resolution: {integrity: sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==} + engines: {node: '>=10'} + ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -2295,6 +2339,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + parent-module@1.0.1: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} @@ -2554,10 +2601,17 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + signal-exit@4.1.0: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + slash@5.1.0: resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} engines: {node: '>=14.16'} @@ -2589,6 +2643,12 @@ packages: peerDependencies: kysely: '*' + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + + std-env@4.0.0: + resolution: {integrity: sha512-zUMPtQ/HBY3/50VbpkupYHbRroTRZJPRLvreamgErJVys0ceuzMkD44J/QjqhHjOzK42GQ3QZIeFG1OYfOtKqQ==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2744,14 +2804,29 @@ packages: tiny-warning@1.0.3: resolution: {integrity: sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.4: + resolution: {integrity: sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==} + engines: {node: '>=18'} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.1.0: + resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + engines: {node: '>=14.0.0'} + to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -2893,6 +2968,41 @@ packages: yaml: optional: true + vitest@4.1.0: + resolution: {integrity: sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + 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 + web-namespaces@2.0.1: resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} @@ -2903,6 +3013,11 @@ packages: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + write-file-atomic@7.0.1: resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -3509,6 +3624,8 @@ snapshots: '@parcel/watcher-win32-x64': 2.5.6 optional: true + '@polka/url@1.0.0-next.29': {} + '@react-hook/latest@1.0.3(react@19.2.4)': dependencies: react: 19.2.4 @@ -3884,6 +4001,11 @@ snapshots: '@types/byte-size@8.1.2': {} + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + '@types/d3-array@3.2.2': {} '@types/d3-color@3.1.3': {} @@ -3912,6 +4034,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/deep-eql@4.0.2': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -3968,6 +4092,58 @@ snapshots: '@rolldown/pluginutils': 1.0.0-rc.7 vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0) + '@vitest/expect@4.1.0': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.1.0 + '@vitest/utils': 4.1.0 + chai: 6.2.2 + tinyrainbow: 3.1.0 + + '@vitest/mocker@4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0))': + dependencies: + '@vitest/spy': 4.1.0 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0) + + '@vitest/pretty-format@4.1.0': + dependencies: + tinyrainbow: 3.1.0 + + '@vitest/runner@4.1.0': + dependencies: + '@vitest/utils': 4.1.0 + pathe: 2.0.3 + + '@vitest/snapshot@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + '@vitest/utils': 4.1.0 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.1.0': {} + + '@vitest/ui@4.1.0(vitest@4.1.0)': + dependencies: + '@vitest/utils': 4.1.0 + fflate: 0.8.2 + flatted: 3.4.0 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vitest: 4.1.0(@types/node@25.5.0)(@vitest/ui@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0)) + + '@vitest/utils@4.1.0': + dependencies: + '@vitest/pretty-format': 4.1.0 + convert-source-map: 2.0.0 + tinyrainbow: 3.1.0 + acorn@8.16.0: {} ajv@8.18.0: @@ -3998,6 +4174,8 @@ snapshots: array-timsort@1.0.3: {} + assertion-error@2.0.1: {} + ast-types@0.16.1: dependencies: tslib: 2.8.1 @@ -4076,6 +4254,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.2: {} + chalk@5.6.2: {} character-entities-html4@2.1.0: {} @@ -4239,6 +4419,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@2.0.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4287,8 +4469,14 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + eventemitter3@5.0.4: {} + expect-type@1.3.0: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4313,6 +4501,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + file-entry-cache@11.1.2: dependencies: flat-cache: 6.1.20 @@ -4327,6 +4517,8 @@ snapshots: flatted: 3.4.1 hookified: 1.15.1 + flatted@3.4.0: {} + flatted@3.4.1: {} follow-redirects@1.15.11: {} @@ -4669,6 +4861,10 @@ snapshots: dependencies: yallist: 3.1.1 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} mathml-tag-names@4.0.0: {} @@ -4926,6 +5122,8 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) + mrmime@2.0.1: {} + ms@2.1.3: {} nanoid@3.3.11: {} @@ -4939,6 +5137,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + parent-module@1.0.1: dependencies: callsites: 3.1.0 @@ -5254,8 +5454,16 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + signal-exit@4.1.0: {} + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + slash@5.1.0: {} slice-ansi@4.0.0: @@ -5283,6 +5491,10 @@ snapshots: '@sqlite.org/sqlite-wasm': 3.48.0-build4 kysely: 0.27.6 + stackback@0.0.2: {} + + std-env@4.0.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5512,15 +5724,23 @@ snapshots: tiny-warning@1.0.3: {} + tinybench@2.9.0: {} + + tinyexec@1.0.4: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.1.0: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -5653,6 +5873,34 @@ snapshots: sass: 1.98.0 tsx: 4.21.0 + vitest@4.1.0(@types/node@25.5.0)(@vitest/ui@4.1.0)(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0)): + dependencies: + '@vitest/expect': 4.1.0 + '@vitest/mocker': 4.1.0(vite@8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.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 + tinybench: 2.9.0 + tinyexec: 1.0.4 + tinyglobby: 0.2.15 + tinyrainbow: 3.1.0 + vite: 8.0.0(@types/node@25.5.0)(esbuild@0.27.4)(sass@1.98.0)(tsx@4.21.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 25.5.0 + '@vitest/ui': 4.1.0(vitest@4.1.0) + transitivePeerDependencies: + - msw + web-namespaces@2.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -5661,6 +5909,11 @@ snapshots: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + write-file-atomic@7.0.1: dependencies: signal-exit: 4.1.0 diff --git a/web/src/shared/validate.ts b/web/src/shared/validate.ts index 5b201b62af..fa7c07c4b9 100644 --- a/web/src/shared/validate.ts +++ b/web/src/shared/validate.ts @@ -53,6 +53,10 @@ export const Validate = { if (!ipv4WithCIDRPattern.test(ip)) { return false; } + const ipPart = ip.split('/')[0]; + if (ipPart.split('.').some((octet) => octet.length > 1 && octet.startsWith('0'))) { + return false; + } if (ip.endsWith('/0') && !allow_zero) { return false; } diff --git a/web/tests/license.test.ts b/web/tests/license.test.ts new file mode 100644 index 0000000000..a7ad596f35 --- /dev/null +++ b/web/tests/license.test.ts @@ -0,0 +1,131 @@ +import dayjs from 'dayjs'; +import utc from 'dayjs/plugin/utc'; +import { describe, expect, it } from 'vitest'; +import type { LicenseInfo } from '../src/shared/api/types'; +import { + canUseBusinessFeature, + canUseEnterpriseFeature, + getLicenseState, +} from '../src/shared/utils/license'; + +dayjs.extend(utc); + +const makeLicense = (overrides: Partial = {}): LicenseInfo => ({ + subscription: false, + valid_until: null, + expired: false, + limits_exceeded: false, + tier: 'Business', + limits: null, + ...overrides, +}); + + +describe('getLicenseState', () => { + it('should return null for undefined (not yet loaded)', () => { + expect(getLicenseState(undefined)).toBeNull(); + }); + + it('should return noLicense for null', () => { + expect(getLicenseState(null)).toBe('noLicense'); + }); + + it('should return expiredLicense when expired flag is set', () => { + expect(getLicenseState(makeLicense({ expired: true }))).toBe('expiredLicense'); + }); + + it('should return validBusiness for valid Business license', () => { + expect(getLicenseState(makeLicense({ tier: 'Business' }))).toBe('validBusiness'); + }); + + it('should return validEnterprise for valid Enterprise license', () => { + expect(getLicenseState(makeLicense({ tier: 'Enterprise' }))).toBe('validEnterprise'); + }); + + it('should return gracePeriod for subscription license past valid_until', () => { + const pastDate = '2000-01-01T00:00:00Z'; + const license = makeLicense({ + subscription: true, + valid_until: pastDate, + expired: false, + }); + expect(getLicenseState(license)).toBe('gracePeriod'); + }); + + it('should return validBusiness for subscription license before valid_until', () => { + const futureDate = '2099-01-01T00:00:00Z'; + const license = makeLicense({ + subscription: true, + valid_until: futureDate, + expired: false, + tier: 'Business', + }); + expect(getLicenseState(license)).toBe('validBusiness'); + }); + + it('should return expiredLicense before checking gracePeriod (expired takes precedence)', () => { + const pastDate = '2000-01-01T00:00:00Z'; + const license = makeLicense({ + subscription: true, + valid_until: pastDate, + expired: true, + }); + expect(getLicenseState(license)).toBe('expiredLicense'); + }); +}); + + +describe('canUseBusinessFeature', () => { + it('should allow access with valid Business license', () => { + const result = canUseBusinessFeature(makeLicense({ tier: 'Business' })); + expect(result.result).toBe(true); + expect(result.error).toBeNull(); + expect(result.tierCheck).toBe('Business'); + }); + + it('should allow access with valid Enterprise license', () => { + const result = canUseBusinessFeature(makeLicense({ tier: 'Enterprise' })); + expect(result.result).toBe(true); + expect(result.error).toBeNull(); + }); + + it('should deny access when no license (null)', () => { + const result = canUseBusinessFeature(null); + expect(result.result).toBe(false); + expect(result.error).toBe('tier'); + }); + + it('should deny access when license is expired', () => { + const result = canUseBusinessFeature(makeLicense({ expired: true })); + expect(result.result).toBe(false); + expect(result.error).toBe('expired'); + }); +}); + + +describe('canUseEnterpriseFeature', () => { + it('should allow access with valid Enterprise license', () => { + const result = canUseEnterpriseFeature(makeLicense({ tier: 'Enterprise' })); + expect(result.result).toBe(true); + expect(result.error).toBeNull(); + expect(result.tierCheck).toBe('Enterprise'); + }); + + it('should deny access when license is Business tier', () => { + const result = canUseEnterpriseFeature(makeLicense({ tier: 'Business' })); + expect(result.result).toBe(false); + expect(result.error).toBe('tier'); + }); + + it('should deny access when no license (null)', () => { + const result = canUseEnterpriseFeature(null); + expect(result.result).toBe(false); + expect(result.error).toBe('tier'); + }); + + it('should deny access when Enterprise license is expired', () => { + const result = canUseEnterpriseFeature(makeLicense({ tier: 'Enterprise', expired: true })); + expect(result.result).toBe(false); + expect(result.error).toBe('expired'); + }); +}); diff --git a/web/tests/utils.test.ts b/web/tests/utils.test.ts new file mode 100644 index 0000000000..c0bb92c690 --- /dev/null +++ b/web/tests/utils.test.ts @@ -0,0 +1,300 @@ +import { describe, expect, it } from 'vitest'; +import { joinCsv, splitCsv } from '../src/shared/utils/csv'; +import { formatFileName } from '../src/shared/utils/formatFileName'; +import { formatIpForDisplay } from '../src/shared/utils/formatIpForDisplay'; +import { removeEmptyStrings } from '../src/shared/utils/removeEmptyStrings'; +import { removeNulls } from '../src/shared/utils/removeNulls'; +import { resourceById, resourceDisplayMap } from '../src/shared/utils/resourceById'; +import { isDeviceOnline, isUserOnline } from '../src/shared/utils/userOnlineStatus'; +import { isValidDefguardUrl } from '../src/shared/utils/defguardUrl'; +import type { Device, DeviceNetworkInfo } from '../src/shared/api/types'; + + +describe('joinCsv', () => { + it('should join array into comma-separated string', () => { + expect(joinCsv(['a', 'b', 'c'])).toBe('a, b, c'); + expect(joinCsv(['192.168.1.1', '10.0.0.1'])).toBe('192.168.1.1, 10.0.0.1'); + }); + + it('should return string as-is when passed a string', () => { + expect(joinCsv('already a string')).toBe('already a string'); + }); + + it('should return empty string for empty array', () => { + expect(joinCsv([])).toBe(''); + }); + + it('should return empty string for null', () => { + expect(joinCsv(null)).toBe(''); + }); + + it('should return empty string for undefined', () => { + expect(joinCsv(undefined)).toBe(''); + }); + +}); + +describe('splitCsv', () => { + it('should split comma-separated string into array', () => { + expect(splitCsv('a, b, c')).toEqual(['a', 'b', 'c']); + expect(splitCsv('192.168.1.1,10.0.0.1')).toEqual(['192.168.1.1', '10.0.0.1']); + }); + + it('should trim whitespace around items', () => { + expect(splitCsv(' a , b , c ')).toEqual(['a', 'b', 'c']); + }); + + it('should filter out empty items', () => { + expect(splitCsv('a,,b')).toEqual(['a', 'b']); + expect(splitCsv('a, ,b')).toEqual(['a', 'b']); + }); + + it('should return empty array for empty string', () => { + expect(splitCsv('')).toEqual([]); + }); + + it('should return empty array for whitespace-only string', () => { + expect(splitCsv(' ')).toEqual([]); + }); + +}); + +describe('joinCsv / splitCsv round-trip', () => { + it('should round-trip array through join and split', () => { + const original = ['192.168.1.1', '10.0.0.0/8', '172.16.0.1-172.16.0.10']; + expect(splitCsv(joinCsv(original))).toEqual(original); + }); +}); + + +describe('formatFileName', () => { + it('should convert to lowercase', () => { + expect(formatFileName('MyFile')).toBe('myfile'); + expect(formatFileName('README')).toBe('readme'); + }); + + it('should replace spaces with underscores', () => { + expect(formatFileName('my config file')).toBe('my_config_file'); + }); + + it('should trim leading and trailing whitespace', () => { + expect(formatFileName(' file ')).toBe('file'); + expect(formatFileName(' my file ')).toBe('my_file'); + }); + + it('should handle empty string', () => { + expect(formatFileName('')).toBe(''); + }); + + it('should handle string with only spaces', () => { + expect(formatFileName(' ')).toBe(''); + }); +}); + + +describe('formatIpForDisplay', () => { + it('should strip /32 from IPv4 host address', () => { + expect(formatIpForDisplay('192.168.1.1/32')).toBe('192.168.1.1'); + expect(formatIpForDisplay('10.0.0.1/32')).toBe('10.0.0.1'); + }); + + it('should strip /128 from IPv6 host address', () => { + expect(formatIpForDisplay('2001:db8::1/128')).toBe('2001:db8::1'); + expect(formatIpForDisplay('::1/128')).toBe('::1'); + }); + + it('should keep IPv4 CIDR that is not a host address', () => { + expect(formatIpForDisplay('192.168.1.0/24')).toBe('192.168.1.0/24'); + expect(formatIpForDisplay('10.0.0.0/8')).toBe('10.0.0.0/8'); + }); + + it('should keep IPv6 CIDR that is not a host address', () => { + expect(formatIpForDisplay('2001:db8::/32')).toBe('2001:db8::/32'); + expect(formatIpForDisplay('fe80::/10')).toBe('fe80::/10'); + }); + + it('should return plain IP unchanged (no slash)', () => { + expect(formatIpForDisplay('192.168.1.1')).toBe('192.168.1.1'); + expect(formatIpForDisplay('2001:db8::1')).toBe('2001:db8::1'); + }); + + it('should handle empty string', () => { + expect(formatIpForDisplay('')).toBe(''); + }); +}); + + +describe('removeEmptyStrings', () => { + it('should remove keys with empty string values', () => { + expect(removeEmptyStrings({ a: '', b: 'hello' })).toEqual({ b: 'hello' }); + }); + + it('should remove keys with whitespace-only string values', () => { + expect(removeEmptyStrings({ a: ' ', b: 'hello' })).toEqual({ b: 'hello' }); + }); + + it('should keep non-string falsy values', () => { + expect(removeEmptyStrings({ a: 0, b: false, c: null })).toEqual({ a: 0, b: false, c: null }); + }); + + it('should keep non-empty strings', () => { + expect(removeEmptyStrings({ a: 'value', b: ' space ' })).toEqual({ a: 'value', b: ' space ' }); + }); + + it('should handle object with all empty strings', () => { + expect(removeEmptyStrings({ a: '', b: '' })).toEqual({}); + }); + + it('should keep number, boolean, object values unchanged', () => { + const input = { name: 'test', count: 42, active: true, nested: { x: 1 } }; + expect(removeEmptyStrings(input)).toEqual(input); + }); +}); + + +describe('removeNulls', () => { + it('should remove null values from flat object', () => { + expect(removeNulls({ a: 1, b: null, c: 'hello' })).toEqual({ a: 1, c: 'hello' }); + }); + + it('should remove undefined values from flat object', () => { + expect(removeNulls({ a: 1, b: undefined, c: 'hello' })).toEqual({ a: 1, c: 'hello' }); + }); + + it('should recursively remove nulls from nested objects', () => { + expect(removeNulls({ a: { b: null, c: 1 }, d: 'hello' })).toEqual({ + a: { c: 1 }, + d: 'hello', + }); + }); + + it('should replace null/undefined with undefined in arrays (preserves slots)', () => { + const result = removeNulls([1, null, 2, undefined, 3]); + expect(result).toEqual([1, undefined, 2, undefined, 3]); + }); + + it('should keep falsy non-null/undefined values', () => { + expect(removeNulls({ a: 0, b: false, c: '' })).toEqual({ a: 0, b: false, c: '' }); + }); +}); + +describe('resourceById', () => { + it('should index array of resources by id', () => { + const items = [ + { id: 1, name: 'Alice' }, + { id: 2, name: 'Bob' }, + ]; + const result = resourceById(items); + expect(result).toEqual({ 1: { id: 1, name: 'Alice' }, 2: { id: 2, name: 'Bob' } }); + }); + + it('should return null for undefined input', () => { + expect(resourceById(undefined)).toBeNull(); + }); + + it('should return empty object for empty array', () => { + expect(resourceById([])).toEqual({}); + }); + +}); + +describe('resourceDisplayMap', () => { + it('should map id to display string', () => { + const items = [ + { id: 1, display: 'Alpha' }, + { id: 2, display: 'Beta' }, + ]; + expect(resourceDisplayMap(items)).toEqual({ 1: 'Alpha', 2: 'Beta' }); + }); + +}); + + +const makeNetwork = (is_active: boolean): DeviceNetworkInfo => ({ + device_wireguard_ips: [], + is_active, + network_gateway_ip: '10.0.0.1', + network_id: 1, + network_name: 'test', +}); + +const makeDevice = (networks: DeviceNetworkInfo[]): Device => ({ + id: 1, + user_id: 1, + name: 'device', + wireguard_pubkey: 'key', + created: '2024-01-01T00:00:00Z', + networks, +}); + +describe('isDeviceOnline', () => { + it('should return true if any network is active', () => { + const device = makeDevice([makeNetwork(false), makeNetwork(true)]); + expect(isDeviceOnline(device)).toBe(true); + }); + + it('should return false if no network is active', () => { + const device = makeDevice([makeNetwork(false), makeNetwork(false)]); + expect(isDeviceOnline(device)).toBe(false); + }); + + it('should return false for device with no networks', () => { + const device = makeDevice([]); + expect(isDeviceOnline(device)).toBe(false); + }); + +}); + +describe('isUserOnline', () => { + it('should return true if any device has an active network', () => { + const user = { + devices: [makeDevice([makeNetwork(false)]), makeDevice([makeNetwork(true)])], + } as any; + expect(isUserOnline(user)).toBe(true); + }); + + it('should return false if no device is online', () => { + const user = { + devices: [makeDevice([makeNetwork(false)]), makeDevice([makeNetwork(false)])], + } as any; + expect(isUserOnline(user)).toBe(false); + }); + + it('should return false if user has no devices', () => { + const user = { devices: [] } as any; + expect(isUserOnline(user)).toBe(false); + }); +}); + + +describe('isValidDefguardUrl', () => { + it('should accept valid https domain URLs', () => { + expect(isValidDefguardUrl('https://defguard.example.com')).toBe(true); + expect(isValidDefguardUrl('https://app.company.org')).toBe(true); + expect(isValidDefguardUrl('https://vpn.internal.corp')).toBe(true); + }); + + it('should accept valid http domain URLs', () => { + expect(isValidDefguardUrl('http://defguard.example.com')).toBe(true); + }); + + it('should accept URL with port', () => { + expect(isValidDefguardUrl('https://defguard.example.com:8080')).toBe(true); + }); + + it('should accept URL with path', () => { + expect(isValidDefguardUrl('https://defguard.example.com/enrollment')).toBe(true); + }); + + it('should reject URLs with IP address as hostname', () => { + expect(isValidDefguardUrl('https://192.168.1.1')).toBe(false); + expect(isValidDefguardUrl('https://10.0.0.1:8080')).toBe(false); + expect(isValidDefguardUrl('http://127.0.0.1')).toBe(false); + }); + + it('should reject invalid URLs', () => { + expect(isValidDefguardUrl('not-a-url')).toBe(false); + expect(isValidDefguardUrl('')).toBe(false); + expect(isValidDefguardUrl('ftp://')).toBe(false); + }); +}); diff --git a/web/tests/validators.test.ts b/web/tests/validators.test.ts new file mode 100644 index 0000000000..a40f30311a --- /dev/null +++ b/web/tests/validators.test.ts @@ -0,0 +1,719 @@ +import { describe, expect, it } from 'vitest'; +import { Validate } from '../src/shared/validate'; +import { aclDestinationValidator, aclPortsValidator } from '../src/shared/validators'; + +describe('Validate.IPv4', () => { + it('should accept valid IPv4 addresses', () => { + expect(Validate.IPv4('192.168.1.1')).toBe(true); + expect(Validate.IPv4('10.0.0.1')).toBe(true); + expect(Validate.IPv4('172.16.0.1')).toBe(true); + expect(Validate.IPv4('255.255.255.255')).toBe(true); + expect(Validate.IPv4('0.0.0.0')).toBe(true); + }); + + it('should reject invalid IPv4 addresses', () => { + expect(Validate.IPv4('1')).toBe(false); + expect(Validate.IPv4('256.1.1.1')).toBe(false); + expect(Validate.IPv4('192.168.1')).toBe(false); + expect(Validate.IPv4('192.168.1.1.1')).toBe(false); + expect(Validate.IPv4('abc.def.ghi.jkl')).toBe(false); + expect(Validate.IPv4('192.168.1.1/24')).toBe(false); + }); + + it('should reject empty strings', () => { + expect(Validate.IPv4('')).toBe(false); + }); +}); + +describe('Validate.IPv4withPort', () => { + it('should accept valid IPv4 with port', () => { + expect(Validate.IPv4withPort('192.168.1.1:8080')).toBe(true); + expect(Validate.IPv4withPort('10.0.0.1:80')).toBe(true); + expect(Validate.IPv4withPort('127.0.0.1:5051')).toBe(true); + expect(Validate.IPv4withPort('192.168.1.1:65535')).toBe(true); + }); + + it('should reject IPv4 without port', () => { + expect(Validate.IPv4withPort('192.168.1.1')).toBe(false); + }); + + it('should reject missing port value', () => { + expect(Validate.IPv4withPort('192.168.1.1:')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.IPv4withPort('')).toBe(false); + }); + + it('should reject invalid port numbers', () => { + expect(Validate.IPv4withPort('192.168.1.1:0')).toBe(false); + expect(Validate.IPv4withPort('192.168.1.1:65536')).toBe(false); + expect(Validate.IPv4withPort('192.168.1.1:99999')).toBe(false); + }); + + it('should reject invalid IPv4 format', () => { + expect(Validate.IPv4withPort('256.1.1.1:8080')).toBe(false); + expect(Validate.IPv4withPort('192.168.1:8080')).toBe(false); + }); +}); + +describe('Validate.IPv6', () => { + it('should accept valid IPv6 addresses', () => { + expect(Validate.IPv6('2001:db8::1')).toBe(true); + expect(Validate.IPv6('::1')).toBe(true); + expect(Validate.IPv6('::')).toBe(true); + expect(Validate.IPv6('2001:0db8:0000:0000:0000:0000:0000:0001')).toBe(true); + expect(Validate.IPv6('fe80::1')).toBe(true); + }); + + it('should reject invalid IPv6 addresses', () => { + expect(Validate.IPv6('192.168.1.1')).toBe(false); + expect(Validate.IPv6('gggg::1')).toBe(false); + expect(Validate.IPv6('invalid')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.IPv6('')).toBe(false); + }); +}); + +describe('Validate.IPv6withPort', () => { + it('should accept valid IPv6 with port in brackets', () => { + expect(Validate.IPv6withPort('[::1]:8080')).toBe(true); + expect(Validate.IPv6withPort('[2001:db8::1]:80')).toBe(true); + expect(Validate.IPv6withPort('[fe80::1]:65535')).toBe(true); + }); + + it('should reject IPv6 without brackets', () => { + expect(Validate.IPv6withPort('::1:8080')).toBe(false); + expect(Validate.IPv6withPort('2001:db8::1:8080')).toBe(false); + }); + + it('should reject IPv6 without port', () => { + expect(Validate.IPv6withPort('[::1]')).toBe(false); + }); + + it('should reject invalid port numbers', () => { + expect(Validate.IPv6withPort('[::1]:0')).toBe(false); + expect(Validate.IPv6withPort('[::1]:65536')).toBe(false); + }); + + it('should reject empty brackets', () => { + expect(Validate.IPv6withPort('[]:8080')).toBe(false); + }); + + it('should reject missing closing bracket', () => { + expect(Validate.IPv6withPort('[::1:8080')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.IPv6withPort('')).toBe(false); + }); +}); + +describe('Validate.CIDRv4', () => { + it('should accept valid IPv4 CIDR notation', () => { + expect(Validate.CIDRv4('192.168.1.0/24')).toBe(true); + expect(Validate.CIDRv4('10.0.0.0/8')).toBe(true); + expect(Validate.CIDRv4('172.16.0.0/12')).toBe(true); + expect(Validate.CIDRv4('192.168.1.1/32')).toBe(true); + expect(Validate.CIDRv4('192.168.1.0/1')).toBe(true); + }); + + it('should reject CIDR with /0 mask', () => { + expect(Validate.CIDRv4('192.168.1.0/0')).toBe(false); + }); + + it('should accept CIDR with /0 mask when allow_zero is true', () => { + expect(Validate.CIDRv4('0.0.0.0/0', true)).toBe(true); + expect(Validate.CIDRv4('192.168.1.0/0', true)).toBe(true); + }); + + it('should reject invalid CIDR masks', () => { + expect(Validate.CIDRv4('192.168.1.0/33')).toBe(false); + expect(Validate.CIDRv4('192.168.1.0/99')).toBe(false); + }); + + it('should reject IPv4 without CIDR mask', () => { + expect(Validate.CIDRv4('192.168.1.1')).toBe(false); + }); + + it('should reject invalid IPv4 in CIDR', () => { + expect(Validate.CIDRv4('256.1.1.1/24')).toBe(false); + expect(Validate.CIDRv4('192.168.1/24')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.CIDRv4('')).toBe(false); + }); + + it('should reject leading zeros in IP', () => { + expect(Validate.CIDRv4('01.168.1.0/24')).toBe(false); + expect(Validate.CIDRv4('001.002.003.004/24')).toBe(false); + }); +}); + +describe('Validate.CIDRv6', () => { + it('should accept valid IPv6 CIDR notation', () => { + expect(Validate.CIDRv6('2001:db8::/32')).toBe(true); + expect(Validate.CIDRv6('fe80::/10')).toBe(true); + expect(Validate.CIDRv6('::1/128')).toBe(true); + }); + + it('should reject CIDR with /0 mask', () => { + expect(Validate.CIDRv6('2001:db8::/0')).toBe(false); + }); + + it('should accept CIDR with /0 mask when allow_zero is true', () => { + expect(Validate.CIDRv6('::/0', true)).toBe(true); + expect(Validate.CIDRv6('2001:db8::/0', true)).toBe(true); + }); + + it('should reject invalid CIDR masks', () => { + expect(Validate.CIDRv6('2001:db8::/129')).toBe(false); + }); + + it('should reject IPv6 without CIDR mask', () => { + expect(Validate.CIDRv6('2001:db8::1')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.CIDRv6('')).toBe(false); + }); +}); + +describe('Validate.Domain', () => { + it('should accept valid domain names', () => { + expect(Validate.Domain('example.com')).toBe(true); + expect(Validate.Domain('sub.example.com')).toBe(true); + expect(Validate.Domain('my-domain.co.uk')).toBe(true); + expect(Validate.Domain('test123.example.org')).toBe(true); + }); + + it('should reject domains with port', () => { + expect(Validate.Domain('example.com:8080')).toBe(false); + }); + + it('should reject invalid domain formats', () => { + expect(Validate.Domain('invalid domain')).toBe(false); + expect(Validate.Domain('example..com')).toBe(false); + expect(Validate.Domain('domain.secret.com')).toBe(true); + }); + + it('should reject single-label names (no TLD)', () => { + expect(Validate.Domain('localhost')).toBe(false); + expect(Validate.Domain('myserver')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.Domain('')).toBe(false); + }); + + it('should accept domain with protocol prefix', () => { + expect(Validate.Domain('https://example.com')).toBe(true); + }); +}); + +describe('Validate.DomainWithPort', () => { + it('should accept valid domains with port', () => { + expect(Validate.DomainWithPort('example.com:8080')).toBe(true); + expect(Validate.DomainWithPort('sub.example.com:443')).toBe(true); + expect(Validate.DomainWithPort('test.org:3000')).toBe(true); + }); + + it('should reject domains without port', () => { + expect(Validate.DomainWithPort('example.com')).toBe(false); + }); + + it('should reject invalid port numbers', () => { + expect(Validate.DomainWithPort('example.com:0')).toBe(false); + expect(Validate.DomainWithPort('example.com:65536')).toBe(false); + expect(Validate.DomainWithPort('example.com:99999')).toBe(false); + }); +}); + +describe('Validate.Port', () => { + it('should accept valid port numbers', () => { + expect(Validate.Port('1')).toBe(true); + expect(Validate.Port('80')).toBe(true); + expect(Validate.Port('443')).toBe(true); + expect(Validate.Port('8080')).toBe(true); + expect(Validate.Port('65535')).toBe(true); + }); + + it('should reject port 0', () => { + expect(Validate.Port('0')).toBe(false); + }); + + it('should reject ports above 65535', () => { + expect(Validate.Port('65536')).toBe(false); + expect(Validate.Port('99999')).toBe(false); + }); + + it('should reject non-numeric values', () => { + expect(Validate.Port('abc')).toBe(false); + expect(Validate.Port('12.34')).toBe(false); + expect(Validate.Port('')).toBe(false); + }); + + it('should reject negative numbers', () => { + expect(Validate.Port('-1')).toBe(false); + }); +}); + +describe('Validate.any', () => { + it('should accept single valid value matching any validator', () => { + expect(Validate.any('192.168.1.1', [Validate.IPv4, Validate.IPv6])).toBe(true); + expect(Validate.any('2001:db8::1', [Validate.IPv4, Validate.IPv6])).toBe(true); + expect(Validate.any('example.com', [Validate.Domain, Validate.IPv4])).toBe(true); + }); + + it('should reject single value not matching any validator', () => { + expect(Validate.any('invalid', [Validate.IPv4, Validate.IPv6])).toBe(false); + expect(Validate.any('256.1.1.1', [Validate.IPv4, Validate.IPv6])).toBe(false); + }); + + it('should reject multiple values when allowList is false (default)', () => { + expect(Validate.any('192.168.1.1,10.0.0.1', [Validate.IPv4])).toBe(false); + expect(Validate.any('example.com,test.com', [Validate.Domain])).toBe(false); + }); + + it('should accept multiple valid values when allowList is true', () => { + expect(Validate.any('192.168.1.1,10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect( + Validate.any('192.168.1.1,2001:db8::1', [Validate.IPv4, Validate.IPv6], true), + ).toBe(true); + expect(Validate.any('example.com,test.org', [Validate.Domain], true)).toBe(true); + }); + + it('should reject list with any invalid value when allowList is true', () => { + expect(Validate.any('192.168.1.1,invalid', [Validate.IPv4], true)).toBe(false); + expect(Validate.any('192.168.1.1,256.1.1.1', [Validate.IPv4], true)).toBe(false); + }); + + it('should handle mixed valid values with allowList', () => { + expect( + Validate.any( + '192.168.1.1,2001:db8::1,10.0.0.1', + [Validate.IPv4, Validate.IPv6], + true, + ), + ).toBe(true); + expect( + Validate.any('example.com,192.168.1.1', [Validate.Domain, Validate.IPv4], true), + ).toBe(true); + }); + + it('should handle custom split character', () => { + expect(Validate.any('192.168.1.1;10.0.0.1', [Validate.IPv4], true, ';')).toBe(true); + expect(Validate.any('192.168.1.1|10.0.0.1', [Validate.IPv4], true, '|')).toBe(true); + }); + + it('should handle whitespace in list', () => { + expect(Validate.any('192.168.1.1, 10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect(Validate.any('192.168.1.1 , 10.0.0.1', [Validate.IPv4], true)).toBe(true); + }); + + it('should accept empty string with Empty validator in list', () => { + expect(Validate.any('', [Validate.IPv4, Validate.Empty], true)).toBe(true); + }); +}); + +describe('Validate.all', () => { + it('should accept single value matching all validators', () => { + expect(Validate.all('192.168.1.1', [Validate.IPv4])).toBe(true); + }); + + it('should reject single value not matching all validators', () => { + expect(Validate.all('192.168.1.1', [Validate.IPv4, Validate.IPv6])).toBe(false); + expect(Validate.all('invalid', [Validate.IPv4])).toBe(false); + }); + + it('should accept empty string or undefined', () => { + expect(Validate.all('', [Validate.IPv4])).toBe(true); + expect(Validate.all(undefined, [Validate.IPv4])).toBe(true); + }); + + it('should reject multiple values when allowList is false (default)', () => { + expect(Validate.all('192.168.1.1,10.0.0.1', [Validate.IPv4])).toBe(false); + }); + + it('should accept multiple valid values when allowList is true', () => { + expect(Validate.all('192.168.1.1,10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect(Validate.all('example.com,test.org', [Validate.Domain], true)).toBe(true); + }); + + it('should reject if any value does not match all validators when allowList is true', () => { + expect(Validate.all('192.168.1.1,invalid', [Validate.IPv4], true)).toBe(false); + expect(Validate.all('192.168.1.1,256.1.1.1', [Validate.IPv4], true)).toBe(false); + }); + + it('should handle custom split character', () => { + expect(Validate.all('192.168.1.1;10.0.0.1', [Validate.IPv4], true, ';')).toBe(true); + expect(Validate.all('192.168.1.1|10.0.0.1', [Validate.IPv4], true, '|')).toBe(true); + }); + + it('should handle whitespace in list', () => { + expect(Validate.all('192.168.1.1, 10.0.0.1', [Validate.IPv4], true)).toBe(true); + expect( + Validate.all('192.168.1.1 , 10.0.0.1 , 172.16.0.1', [Validate.IPv4], true), + ).toBe(true); + }); +}); + +describe('Validate.Empty', () => { + it('should accept empty string', () => { + expect(Validate.Empty('')).toBe(true); + }); + + it('should reject non-empty string', () => { + expect(Validate.Empty('hello')).toBe(false); + expect(Validate.Empty(' ')).toBe(false); + expect(Validate.Empty('0')).toBe(false); + }); +}); + +describe('Validate.Hostname', () => { + it('should accept valid single-label hostnames', () => { + expect(Validate.Hostname('localhost')).toBe(true); + expect(Validate.Hostname('myserver')).toBe(true); + expect(Validate.Hostname('web01')).toBe(true); + expect(Validate.Hostname('a')).toBe(true); + }); + + it('should accept hostnames with hyphens', () => { + expect(Validate.Hostname('my-server')).toBe(true); + expect(Validate.Hostname('host-01-prod')).toBe(true); + }); + + it('should reject hostnames starting with hyphen', () => { + expect(Validate.Hostname('-invalid')).toBe(false); + }); + + it('should reject hostnames ending with hyphen', () => { + expect(Validate.Hostname('invalid-')).toBe(false); + }); + + it('should reject FQDNs (multi-label with dots)', () => { + expect(Validate.Hostname('example.com')).toBe(false); + expect(Validate.Hostname('sub.domain.com')).toBe(false); + }); + + it('should reject empty string', () => { + expect(Validate.Hostname('')).toBe(false); + }); + + it('should reject hostnames with special characters', () => { + expect(Validate.Hostname('my_server')).toBe(false); + expect(Validate.Hostname('host name')).toBe(false); + expect(Validate.Hostname('host@name')).toBe(false); + }); +}); + +describe('aclPortsValidator', () => { + const schema = aclPortsValidator; + + const isValid = (value: string): boolean => { + try { + schema.parse(value); + return true; + } catch { + return false; + } + }; + + describe('Single ports', () => { + it('should accept valid port numbers', () => { + expect(isValid('80')).toBe(true); + expect(isValid('443')).toBe(true); + expect(isValid('8080')).toBe(true); + expect(isValid('0')).toBe(true); + expect(isValid('65535')).toBe(true); + }); + + it('should reject port above 65535', () => { + expect(isValid('65536')).toBe(false); + expect(isValid('99999')).toBe(false); + }); + + it('should reject non-numeric values', () => { + expect(isValid('abc')).toBe(false); + expect(isValid('80a')).toBe(false); + expect(isValid('twelve')).toBe(false); + }); + + it('should reject negative port numbers', () => { + expect(isValid('-1')).toBe(false); + expect(isValid('-80')).toBe(false); + }); + }); + + describe('Port ranges', () => { + it('should accept valid port ranges', () => { + expect(isValid('80-443')).toBe(true); + expect(isValid('1024-65535')).toBe(true); + expect(isValid('0-1')).toBe(true); + }); + + it('should reject range where start equals end', () => { + expect(isValid('80-80')).toBe(false); + }); + + it('should reject range where start > end', () => { + expect(isValid('443-80')).toBe(false); + expect(isValid('65535-0')).toBe(false); + }); + + it('should reject range with trailing dash', () => { + expect(isValid('80-')).toBe(false); + }); + + it('should reject range with multiple dashes', () => { + expect(isValid('80-443-8080')).toBe(false); + }); + + it('should reject range with non-numeric values', () => { + expect(isValid('abc-def')).toBe(false); + expect(isValid('80-abc')).toBe(false); + }); + }); + + describe('Comma-separated lists', () => { + it('should accept multiple ports', () => { + expect(isValid('80,443')).toBe(true); + expect(isValid('80,443,8080')).toBe(true); + expect(isValid('22,80,443,3000,8080')).toBe(true); + }); + + it('should accept mix of ports and ranges', () => { + expect(isValid('80,443-8080,9090')).toBe(true); + expect(isValid('22,80-443,8080-9090')).toBe(true); + }); + + it('should reject trailing comma', () => { + expect(isValid('80,')).toBe(false); + expect(isValid('80,443,')).toBe(false); + }); + + it('should reject leading comma', () => { + expect(isValid(',80')).toBe(false); + }); + + it('should reject empty tokens between commas', () => { + expect(isValid('80,,443')).toBe(false); + }); + + it('should reject if any entry is invalid', () => { + expect(isValid('80,abc,443')).toBe(false); + expect(isValid('80,65536')).toBe(false); + expect(isValid('80,443-80')).toBe(false); + }); + }); + + describe('Whitespace handling', () => { + it('should accept entries with whitespace', () => { + expect(isValid(' 80 , 443 ')).toBe(true); + expect(isValid('80 , 443')).toBe(true); + expect(isValid('80 - 443')).toBe(true); + }); + }); + + describe('Empty input', () => { + it('should accept empty string', () => { + expect(isValid('')).toBe(true); + }); + + it('should accept whitespace-only string', () => { + expect(isValid(' ')).toBe(true); + }); + }); +}); + +describe('aclDestinationValidator', () => { + const schema = aclDestinationValidator; + + // Helper function to test validator + const isValid = (value: string): boolean => { + try { + schema.parse(value); + return true; + } catch { + return false; + } + }; + + describe('Single IP addresses', () => { + it('should accept valid IPv4 address', () => { + expect(isValid('192.168.1.1')).toBe(true); + expect(isValid('10.0.0.1')).toBe(true); + expect(isValid('172.16.0.1')).toBe(true); + }); + + it('should accept valid IPv6 address', () => { + expect(isValid('2001:db8::1')).toBe(true); + expect(isValid('::1')).toBe(true); + expect(isValid('fe80::1')).toBe(true); + }); + + it('should accept empty string', () => { + expect(isValid('')).toBe(true); + }); + + it('should reject invalid IP address', () => { + expect(isValid('256.1.1.1')).toBe(false); + expect(isValid('invalid')).toBe(false); + expect(isValid('192.168.1')).toBe(false); + }); + }); + + describe('CIDR notation', () => { + it('should accept valid IPv4 CIDR', () => { + expect(isValid('192.168.1.0/24')).toBe(true); + expect(isValid('10.0.0.0/8')).toBe(true); + expect(isValid('172.16.0.0/16')).toBe(true); + }); + + it('should accept valid IPv6 CIDR', () => { + expect(isValid('2001:db8::/32')).toBe(true); + expect(isValid('fe80::/10')).toBe(true); + }); + + it('should reject invalid CIDR', () => { + expect(isValid('192.168.1.0/33')).toBe(false); + expect(isValid('256.1.1.1/24')).toBe(false); + }); + }); + + describe('IP ranges', () => { + it('should accept valid IPv4 range', () => { + expect(isValid('192.168.1.1-192.168.1.10')).toBe(true); + expect(isValid('10.0.0.1-10.0.0.100')).toBe(true); + }); + + it('should accept IPv4 range where end is larger than start', () => { + expect(isValid('10.1.80.2-10.1.80.10')).toBe(true); + expect(isValid('192.168.1.9-192.168.1.100')).toBe(true); + expect(isValid('10.0.0.99-10.0.0.100')).toBe(true); + }); + + it('should accept valid IPv6 range', () => { + expect(isValid('2001:db8::1-2001:db8::10')).toBe(true); + expect(isValid('::1-::ffff')).toBe(true); + }); + + it('should accept range with whitespace', () => { + expect(isValid('192.168.1.1 - 192.168.1.10')).toBe(true); + }); + + it('should reject range where start equals end', () => { + expect(isValid('192.168.1.1-192.168.1.1')).toBe(false); + expect(isValid('2001:db8::1-2001:db8::1')).toBe(false); + }); + + it('should reject range where start > end', () => { + expect(isValid('192.168.1.10-192.168.1.1')).toBe(false); + expect(isValid('10.1.80.10-10.1.80.2')).toBe(false); + expect(isValid('2001:db8::10-2001:db8::1')).toBe(false); + }); + + it('should reject range with CIDR notation', () => { + expect(isValid('192.168.1.0/24-192.168.1.10')).toBe(false); + expect(isValid('192.168.1.1-192.168.1.0/24')).toBe(false); + }); + + it('should reject range with mixed IP versions', () => { + expect(isValid('192.168.1.1-2001:db8::1')).toBe(false); + }); + + it('should reject range with invalid IP addresses', () => { + expect(isValid('256.1.1.1-192.168.1.10')).toBe(false); + expect(isValid('192.168.1.1-256.1.1.1')).toBe(false); + expect(isValid('invalid-192.168.1.10')).toBe(false); + }); + }); + + describe('Multiple entries (comma-separated)', () => { + it('should accept multiple valid IPv4 addresses', () => { + expect(isValid('192.168.1.1,10.0.0.1')).toBe(true); + expect(isValid('192.168.1.1,10.0.0.1,172.16.0.1')).toBe(true); + }); + + it('should accept multiple valid IPv6 addresses', () => { + expect(isValid('2001:db8::1,fe80::1')).toBe(true); + }); + + it('should accept mix of IPs, CIDR and ranges', () => { + expect(isValid('192.168.1.1,10.0.0.0/8,172.16.1.1-172.16.1.10')).toBe(true); + expect(isValid('192.168.1.1,192.168.1.0/24,192.168.2.1-192.168.2.10')).toBe(true); + }); + + it('should accept entries with whitespace', () => { + expect(isValid('192.168.1.1, 10.0.0.1, 172.16.0.1')).toBe(true); + expect(isValid('192.168.1.1 , 10.0.0.1')).toBe(true); + }); + + it('should reject if any entry is invalid', () => { + expect(isValid('192.168.1.1,invalid')).toBe(false); + expect(isValid('192.168.1.1,256.1.1.1')).toBe(false); + expect(isValid('192.168.1.1,10.0.0.1-10.0.0.0')).toBe(false); + }); + }); + + it('should handle IPv4 boundary values in ranges', () => { + expect(isValid('0.0.0.1-0.0.0.255')).toBe(true); + expect(isValid('255.255.255.0-255.255.255.255')).toBe(true); + }); + + it('should correctly compare multi-digit octets', () => { + expect(isValid('10.1.80.2-10.1.80.8')).toBe(true); + expect(isValid('10.1.80.2-10.1.80.10')).toBe(true); + expect(isValid('192.168.1.9-192.168.1.10')).toBe(true); + expect(isValid('192.168.1.99-192.168.1.100')).toBe(true); + expect(isValid('10.10.10.10-10.10.10.100')).toBe(true); + }); + + describe('Dotted mask notation', () => { + it('should accept valid dotted subnet masks', () => { + expect(isValid('192.168.1.0/255.255.255.0')).toBe(true); + expect(isValid('10.0.0.0/255.0.0.0')).toBe(true); + expect(isValid('172.16.0.0/255.255.0.0')).toBe(true); + expect(isValid('192.168.1.0/255.255.255.128')).toBe(true); + }); + + it('should reject invalid dotted subnet masks', () => { + expect(isValid('192.168.1.0/255.255.255.1')).toBe(false); + expect(isValid('192.168.1.0/255.0.255.0')).toBe(false); + }); + }); + + describe('Edge cases', () => { + it('should reject trailing comma', () => { + expect(isValid('192.168.1.1,')).toBe(false); + }); + + it('should reject leading comma', () => { + expect(isValid(',192.168.1.1')).toBe(false); + }); + + it('should reject empty tokens between commas', () => { + expect(isValid('192.168.1.1,,10.0.0.1')).toBe(false); + }); + + it('should reject double slash in CIDR', () => { + expect(isValid('192.168.1.0//24')).toBe(false); + }); + + it('should reject just a slash', () => { + expect(isValid('/')).toBe(false); + }); + + it('should reject domain names', () => { + expect(isValid('example.com')).toBe(false); + expect(isValid('localhost')).toBe(false); + }); + + it('should reject IP with port', () => { + expect(isValid('192.168.1.1:80')).toBe(false); + }); + }); +}); diff --git a/web/vitest.config.mts b/web/vitest.config.mts new file mode 100644 index 0000000000..06e004bc54 --- /dev/null +++ b/web/vitest.config.mts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config'; +import * as path from 'path'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + include: ['tests/**/*.test.ts'], + }, + resolve: { + alias: { + '@scss': path.resolve(__dirname, './src/shared/scss'), + '@scssutils': path.resolve(__dirname, './src/shared/scss/global'), + }, + }, +});