diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml new file mode 100644 index 0000000000..d4085f6c72 --- /dev/null +++ b/.github/workflows/test-web.yml @@ -0,0 +1,37 @@ +on: + push: + branches: + - main + - dev + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" + pull_request: + branches: + - main + - dev + - "release/**" + paths-ignore: + - "*.md" + - "LICENSE" + +jobs: + test-web: + runs-on: + - codebuild-defguard-core-runner-${{ github.run_id }}-${{ github.run_attempt }} + steps: + - uses: actions/checkout@v4 + with: + submodules: "recursive" + - uses: actions/setup-node@v4 + with: + node-version: 24 + - name: install deps + working-directory: ./web + run: | + npm i -g npm pnpm + pnpm i --frozen-lockfile + - name: Run tests + working-directory: ./web + run: pnpm run test diff --git a/deny.toml b/deny.toml index 4bbfa288c1..49991da9e5 100644 --- a/deny.toml +++ b/deny.toml @@ -110,34 +110,34 @@ confidence-threshold = 0.8 # aren't accepted for every possible crate as with the normal allow list exceptions = [ { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_common" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_core" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_mail" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_proto" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_web_ui" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_event_router" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_event_logger" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "defguard_version" }, { allow = [ - "AGPL-3.0-only", + "AGPL-3.0-only", "AGPL-3.0-or-later", ], crate = "model_derive" }, ] diff --git a/web/package.json b/web/package.json index d3d9ddbd62..0311645409 100644 --- a/web/package.json +++ b/web/package.json @@ -14,7 +14,10 @@ "typesafe-i18n": "typesafe-i18n", "vite": "vite", "prettier": "prettier", - "biome": "biome" + "biome": "biome", + "test": "vitest run", + "test:ui": "vitest --ui", + "test:watch": "vitest" }, "browserslist": { "production": [ @@ -40,7 +43,10 @@ ], "onlyBuiltDependencies": [ "@swc/core" - ] + ], + "overrides": { + "mdast-util-to-hast": "13.2.1" + } }, "dependencies": { "@floating-ui/react": "^0.27.16", @@ -128,6 +134,7 @@ "@types/react-dom": "^19.2.3", "@types/react-router-dom": "^5.3.3", "@vitejs/plugin-react-swc": "^4.2.2", + "@vitest/ui": "^4.0.14", "autoprefixer": "^10.4.22", "concurrently": "^9.2.1", "dotenv": "^17.2.3", @@ -140,6 +147,7 @@ "type-fest": "^4.41.0", "typescript": "~5.9.3", "vite": "^7.2.2", - "vite-plugin-package-version": "^1.1.0" + "vite-plugin-package-version": "^1.1.0", + "vitest": "^4.0.14" } } diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index af6ae57457..5c11d27296 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -4,6 +4,9 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + mdast-util-to-hast: 13.2.1 + importers: .: @@ -258,6 +261,9 @@ importers: '@vitejs/plugin-react-swc': specifier: ^4.2.2 version: 4.2.2(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + '@vitest/ui': + specifier: ^4.0.14 + version: 4.0.14(vitest@4.0.14) autoprefixer: specifier: ^10.4.22 version: 10.4.22(postcss@8.5.6) @@ -297,6 +303,9 @@ importers: vite-plugin-package-version: specifier: ^1.1.0 version: 1.1.0(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + vitest: + specifier: ^4.0.14 + version: 4.0.14(@types/node@24.10.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) packages: @@ -704,6 +713,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@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: @@ -992,6 +1004,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==} @@ -1022,6 +1037,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==} @@ -1107,6 +1125,40 @@ packages: peerDependencies: vite: ^4 || ^5 || ^6 || ^7 + '@vitest/expect@4.0.14': + resolution: {integrity: sha512-RHk63V3zvRiYOWAV0rGEBRO820ce17hz7cI2kDmEdfQsBjT2luEKB5tCOc91u1oSQoUOZkSv3ZyzkdkSLD7lKw==} + + '@vitest/mocker@4.0.14': + resolution: {integrity: sha512-RzS5NujlCzeRPF1MK7MXLiEFpkIXeMdQ+rN3Kk3tDI9j0mtbr7Nmuq67tpkOJQpgyClbOltCXMjLZicJHsH5Cg==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.14': + resolution: {integrity: sha512-SOYPgujB6TITcJxgd3wmsLl+wZv+fy3av2PpiPpsWPZ6J1ySUYfScfpIt2Yv56ShJXR2MOA6q2KjKHN4EpdyRQ==} + + '@vitest/runner@4.0.14': + resolution: {integrity: sha512-BsAIk3FAqxICqREbX8SetIteT8PiaUL/tgJjmhxJhCsigmzzH8xeadtp7LRnTpCVzvf0ib9BgAfKJHuhNllKLw==} + + '@vitest/snapshot@4.0.14': + resolution: {integrity: sha512-aQVBfT1PMzDSA16Y3Fp45a0q8nKexx6N5Amw3MX55BeTeZpoC08fGqEZqVmPcqN0ueZsuUQ9rriPMhZ3Mu19Ag==} + + '@vitest/spy@4.0.14': + resolution: {integrity: sha512-JmAZT1UtZooO0tpY3GRyiC/8W7dCs05UOq9rfsUUgEZEdq+DuHLmWhPsrTt0TiW7WYeL/hXpaE07AZ2RCk44hg==} + + '@vitest/ui@4.0.14': + resolution: {integrity: sha512-fvDz8o7SQpFLoSBo6Cudv+fE85/fPCkwTnLAN85M+Jv7k59w2mSIjT9Q5px7XwGrmYqqKBEYxh/09IBGd1E7AQ==} + peerDependencies: + vitest: 4.0.14 + + '@vitest/utils@4.0.14': + resolution: {integrity: sha512-hLqXZKAWNg8pI+SQXyXxWCTOpA3MvsqcbVeNgSi8x/CSN2wi26dSzn1wrOhmCmFjEvN9p8/kLFRHa6PI8jHazw==} + JSONStream@1.3.5: resolution: {integrity: sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ==} hasBin: true @@ -1142,6 +1194,10 @@ packages: resolution: {integrity: sha512-3CYzex9M9FGQjCGMGyi6/31c8GJbgb0qGyrx5HWxPd0aCwh4cB2YjMb2Xf9UuoogrMrlO9cTqnB5rI5GHZTcUA==} engines: {node: '>=0.10.0'} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} @@ -1226,6 +1282,10 @@ packages: ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + chai@6.2.1: + resolution: {integrity: sha512-p4Z49OGG5W/WBCPSS/dH3jQ73kD6tiMmUM+bckNK6Jr5JHMG3k9bg/BvKR8lKmtVBKmOiuVaV2ws8s9oSbwysg==} + engines: {node: '>=18'} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -1558,6 +1618,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -1589,6 +1652,9 @@ 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.1: resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} @@ -1596,6 +1662,10 @@ packages: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} + expect-type@1.2.2: + resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} + engines: {node: '>=12.0.0'} + extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -1611,6 +1681,9 @@ packages: picomatch: optional: true + fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -1641,6 +1714,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + flatted@3.3.3: + resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} engines: {node: '>=4.0'} @@ -2017,6 +2093,9 @@ packages: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} engines: {node: '>=10'} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + map-obj@1.0.1: resolution: {integrity: sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==} engines: {node: '>=0.10.0'} @@ -2044,8 +2123,8 @@ packages: mdast-util-phrasing@4.1.0: resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} - mdast-util-to-hast@13.2.0: - resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + mdast-util-to-hast@13.2.1: + resolution: {integrity: sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==} mdast-util-to-markdown@2.1.2: resolution: {integrity: sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==} @@ -2178,6 +2257,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==} @@ -2221,6 +2304,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + p-limit@1.3.0: resolution: {integrity: sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==} engines: {node: '>=4'} @@ -2294,6 +2380,9 @@ packages: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -2631,6 +2720,13 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + sirv@3.0.2: + resolution: {integrity: sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==} + engines: {node: '>=18'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -2667,11 +2763,17 @@ packages: split@1.0.1: resolution: {integrity: sha512-mTyOoPbrivtXnwnIxZRFYRrPNtEFKlpB2fvjSnCQUiAA6qAZzqwna5envK4uk6OIeP17CsdF3rSBGYVBsU0Tkg==} + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + standard-version@9.5.0: resolution: {integrity: sha512-3zWJ/mmZQsOaO+fOlsa0+QK90pwhNd042qEcw6hKFNoLFs7peGyvPffpEBbK/DSGPbyOvli0mUIFv5A4qTjh2Q==} engines: {node: '>=10'} hasBin: true + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -2813,14 +2915,28 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@0.3.2: + resolution: {integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + 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'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -2990,12 +3106,51 @@ packages: yaml: optional: true + vitest@4.0.14: + resolution: {integrity: sha512-d9B2J9Cm9dN9+6nxMnnNJKJCtcyKfnHj15N6YNJfaFHRLua/d3sRKU9RuKmO9mB0XdFtUizlxfz/VPbd3OxGhw==} + 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.0.14 + '@vitest/browser-preview': 4.0.14 + '@vitest/browser-webdriverio': 4.0.14 + '@vitest/ui': 4.0.14 + happy-dom: '*' + jsdom: '*' + 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==} which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wordwrap@1.0.0: resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} @@ -3466,6 +3621,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@polka/url@1.0.0-next.29': {} + '@react-hook/latest@1.0.3(react@19.2.0)': dependencies: react: 19.2.0 @@ -3681,6 +3838,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': {} @@ -3709,6 +3871,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 @@ -3791,6 +3955,56 @@ snapshots: transitivePeerDependencies: - '@swc/helpers' + '@vitest/expect@4.0.14': + dependencies: + '@standard-schema/spec': 1.0.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + chai: 6.2.1 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.14(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1))': + dependencies: + '@vitest/spy': 4.0.14 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + + '@vitest/pretty-format@4.0.14': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.14': + dependencies: + '@vitest/utils': 4.0.14 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.14': {} + + '@vitest/ui@4.0.14(vitest@4.0.14)': + dependencies: + '@vitest/utils': 4.0.14 + fflate: 0.8.2 + flatted: 3.3.3 + pathe: 2.0.3 + sirv: 3.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vitest: 4.0.14(@types/node@24.10.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + + '@vitest/utils@4.0.14': + dependencies: + '@vitest/pretty-format': 4.0.14 + tinyrainbow: 3.0.3 + JSONStream@1.3.5: dependencies: jsonparse: 1.3.1 @@ -3820,6 +4034,8 @@ snapshots: arrify@1.0.1: {} + assertion-error@2.0.1: {} + asynckit@0.4.0: {} autoprefixer@10.4.22(postcss@8.5.6): @@ -3901,6 +4117,8 @@ snapshots: ccount@2.0.1: {} + chai@6.2.1: {} + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -4262,6 +4480,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -4312,10 +4532,16 @@ snapshots: estree-util-is-identifier-name@3.0.0: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + eventemitter3@5.0.1: {} events@3.3.0: {} + expect-type@1.2.2: {} + extend@3.0.2: {} fast-deep-equal@3.1.3: {} @@ -4324,6 +4550,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fflate@0.8.2: {} + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -4354,6 +4582,8 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + flatted@3.3.3: {} + follow-redirects@1.15.11: {} form-data@4.0.4: @@ -4498,7 +4728,7 @@ snapshots: hast-util-from-parse5: 8.0.3 hast-util-to-parse5: 8.0.0 html-void-elements: 3.0.0 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 parse5: 7.3.0 unist-util-position: 5.0.0 unist-util-visit: 5.0.0 @@ -4734,6 +4964,10 @@ snapshots: dependencies: yallist: 4.0.0 + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + map-obj@1.0.1: {} map-obj@4.3.0: {} @@ -4801,7 +5035,7 @@ snapshots: '@types/mdast': 4.0.4 unist-util-is: 6.0.1 - mdast-util-to-hast@13.2.0: + mdast-util-to-hast@13.2.1: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 @@ -5021,6 +5255,8 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) + mrmime@2.0.1: {} + ms@2.1.3: {} n-gram@2.0.2: {} @@ -5057,6 +5293,8 @@ snapshots: object-inspect@1.13.4: {} + obug@2.1.1: {} + p-limit@1.3.0: dependencies: p-try: 1.0.0 @@ -5131,6 +5369,8 @@ snapshots: path-type@4.0.0: {} + pathe@2.0.3: {} + picocolors@1.1.1: {} picomatch@2.3.1: {} @@ -5236,7 +5476,7 @@ snapshots: devlop: 1.1.0 hast-util-to-jsx-runtime: 2.3.6 html-url-attributes: 3.0.1 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 react: 19.2.0 remark-parse: 11.0.0 remark-rehype: 11.1.2 @@ -5406,7 +5646,7 @@ snapshots: dependencies: '@types/hast': 3.0.4 '@types/mdast': 4.0.4 - mdast-util-to-hast: 13.2.0 + mdast-util-to-hast: 13.2.1 unified: 11.0.5 vfile: 6.0.3 @@ -5508,6 +5748,14 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + sirv@3.0.2: + dependencies: + '@polka/url': 1.0.0-next.29 + mrmime: 2.0.1 + totalist: 3.0.1 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -5544,6 +5792,8 @@ snapshots: dependencies: through: 2.3.8 + stackback@0.0.2: {} + standard-version@9.5.0: dependencies: chalk: 2.4.2 @@ -5561,6 +5811,8 @@ snapshots: stringify-package: 1.0.1 yargs: 16.2.0 + std-env@3.10.0: {} + string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -5734,15 +5986,23 @@ snapshots: tiny-invariant@1.3.3: {} + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 + tinyrainbow@3.0.3: {} + to-regex-range@5.0.1: dependencies: is-number: 7.0.0 + totalist@3.0.1: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -5894,10 +6154,53 @@ snapshots: terser: 5.37.0 yaml: 2.6.1 + vitest@4.0.14(@types/node@24.10.1)(@vitest/ui@4.0.14)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1): + dependencies: + '@vitest/expect': 4.0.14 + '@vitest/mocker': 4.0.14(vite@7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1)) + '@vitest/pretty-format': 4.0.14 + '@vitest/runner': 4.0.14 + '@vitest/snapshot': 4.0.14 + '@vitest/spy': 4.0.14 + '@vitest/utils': 4.0.14 + es-module-lexer: 1.7.0 + expect-type: 1.2.2 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.2.2(@types/node@24.10.1)(jiti@2.4.2)(sass@1.70.0)(terser@5.37.0)(yaml@2.6.1) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 24.10.1 + '@vitest/ui': 4.0.14(vitest@4.0.14) + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + web-namespaces@2.0.1: {} which-module@2.0.1: {} + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wordwrap@1.0.0: {} wrap-ansi@6.2.0: diff --git a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx index 772c97a2a1..8968939c1e 100644 --- a/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx +++ b/web/src/pages/network/NetworkEditForm/NetworkEditForm.tsx @@ -31,11 +31,7 @@ import { } from '../../../shared/types'; import { titleCase } from '../../../shared/utils/titleCase'; import { trimObjectStrings } from '../../../shared/utils/trimObjectStrings.ts'; -import { - validateIpList, - validateIpOrDomain, - validateIpOrDomainList, -} from '../../../shared/validators'; +import { Validate } from '../../../shared/validators'; import { useNetworkPageStore } from '../hooks/useNetworkPageStore'; import { DividerHeader } from './components/DividerHeader.tsx'; @@ -124,15 +120,15 @@ export const NetworkEditForm = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((value) => { - return validateIpList(value, ',', true); + .refine((val) => { + return Validate.any(val, [Validate.CIDRv4, Validate.CIDRv6], true); }, LL.form.error.addressNetmask()), endpoint: z .string() .trim() .min(1, LL.form.error.required()) .refine( - (val) => validateIpOrDomain(val, false, true), + (val) => Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain]), LL.form.error.endpoint(), ), port: z @@ -140,17 +136,34 @@ export const NetworkEditForm = () => { invalid_type_error: LL.form.error.required(), }) .max(65535, LL.form.error.portMax()), - allowed_ips: z.string(), + allowed_ips: z + .string() + .trim() + .optional() + .refine( + (val) => + Validate.any( + val, + [ + Validate.CIDRv4, + Validate.IPv4, + Validate.CIDRv6, + Validate.IPv6, + Validate.Empty, + ], + true, + ), + LL.form.error.address(), + ), dns: z .string() .trim() .optional() - .refine((val) => { - if (val === '' || !val) { - return true; - } - return validateIpOrDomainList(val, ',', false, true); - }, LL.form.error.allowedIps()), + .refine( + (val) => + Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Empty], true), + LL.form.error.address(), + ), allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())), keepalive_interval: z .number({ diff --git a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx index 55db9997b5..f28d33c025 100644 --- a/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx +++ b/web/src/pages/settings/components/SmtpSettings/components/SmtpSettingsForm/SmtpSettingsForm.tsx @@ -27,7 +27,7 @@ import { patternValidEmail } from '../../../../../../shared/patterns'; import { QueryKeys } from '../../../../../../shared/queries'; import type { SettingsSMTP } from '../../../../../../shared/types'; import { invalidateMultipleQueries } from '../../../../../../shared/utils/invalidateMultipleQueries'; -import { validateIpOrDomain } from '../../../../../../shared/validators'; +import { Validate } from '../../../../../../shared/validators'; import { useSettingsPage } from '../../../../hooks/useSettingsPage'; import { SmtpTestModal } from '../SmtpTest/SmtpTestModal'; import { useSmtpTestModal } from '../SmtpTest/useSmtpTestModal'; @@ -112,8 +112,14 @@ export const SmtpSettingsForm = () => { .trim() .min(1, LL.form.error.required()) .refine( - (val) => (!val ? true : validateIpOrDomain(val, false, true)), - LL.form.error.endpoint(), + (val) => + Validate.any(val, [ + Validate.IPv4, + Validate.IPv6, + Validate.Domain, + Validate.Empty, + ]), + LL.form.error.address(), ), smtp_port: z .number({ diff --git a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx index 6ecdd20349..993154b893 100644 --- a/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx +++ b/web/src/pages/wizard/components/WizardNetworkConfiguration/WizardNetworkConfiguration.tsx @@ -26,11 +26,7 @@ import { QueryKeys } from '../../../../shared/queries'; import { LocationMfaMode, ServiceLocationMode } from '../../../../shared/types.ts'; import { titleCase } from '../../../../shared/utils/titleCase'; import { trimObjectStrings } from '../../../../shared/utils/trimObjectStrings.ts'; -import { - validateIpList, - validateIpOrDomain, - validateIpOrDomainList, -} from '../../../../shared/validators'; +import { Validate } from '../../../../shared/validators'; import { useWizardStore } from '../../hooks/useWizardStore'; import { DividerHeader } from './components/DividerHeader.tsx'; @@ -111,15 +107,17 @@ export const WizardNetworkConfiguration = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((value) => { - return validateIpList(value, ',', true); - }, LL.form.error.addressNetmask()), + .refine( + (val) => Validate.any(val, [Validate.CIDRv4, Validate.CIDRv6]), + LL.form.error.addressNetmask(), + ), endpoint: z .string() .trim() .min(1, LL.form.error.required()) .refine( - (val) => validateIpOrDomain(val, false, true), + (val) => + Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain], true), LL.form.error.endpoint(), ), port: z @@ -128,17 +126,33 @@ export const WizardNetworkConfiguration = () => { }) .max(65535, LL.form.error.portMax()) .nonnegative(), - allowed_ips: z.string().trim(), + allowed_ips: z + .string() + .trim() + .refine( + (val) => + Validate.any( + val, + [ + Validate.CIDRv4, + Validate.IPv4, + Validate.CIDRv6, + Validate.IPv6, + Validate.Empty, + ], + true, + ), + LL.form.error.address(), + ), dns: z .string() .trim() .optional() - .refine((val) => { - if (val === '' || !val) { - return true; - } - return validateIpOrDomainList(val, ',', true); - }, LL.form.error.allowedIps()), + .refine( + (val) => + Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Empty], true), + LL.form.error.address(), + ), allowed_groups: z.array(z.string().trim().min(1, LL.form.error.minimumLength())), keepalive_interval: z .number({ diff --git a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx index 52c798c0ee..7f38c6d255 100644 --- a/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx +++ b/web/src/pages/wizard/components/WizardNetworkImport/WizardNetworkImport.tsx @@ -27,7 +27,7 @@ import { QueryKeys } from '../../../../shared/queries'; import type { ImportNetworkRequest } from '../../../../shared/types'; import { invalidateMultipleQueries } from '../../../../shared/utils/invalidateMultipleQueries'; import { titleCase } from '../../../../shared/utils/titleCase'; -import { validateIpOrDomain } from '../../../../shared/validators'; +import { Validate } from '../../../../shared/validators'; import { useWizardStore } from '../../hooks/useWizardStore'; interface FormInputs extends Omit { @@ -70,7 +70,10 @@ export const WizardNetworkImport = () => { .string() .trim() .min(1, LL.form.error.required()) - .refine((val) => validateIpOrDomain(val), LL.form.error.endpoint()), + .refine( + (val) => Validate.any(val, [Validate.IPv4, Validate.IPv6, Validate.Domain]), + LL.form.error.endpoint(), + ), fileName: z.string().trim().min(1, LL.form.error.required()), config: z.string().trim().min(1, LL.form.error.required()), allowed_groups: z.array(z.string().min(1, LL.form.error.minimumLength())), diff --git a/web/src/shared/patterns.ts b/web/src/shared/patterns.ts index cfb5db0bdb..b9c4d6cb7b 100644 --- a/web/src/shared/patterns.ts +++ b/web/src/shared/patterns.ts @@ -63,9 +63,14 @@ export const patternValidUrl = new RegExp( '$', 'i', ); - -export const patternValidDomain = - /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))(?::[0-9]{1,5})?$/; +export const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; +export const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/; +export const ipv4WithCIDRPattern = /^(\d{1,3}\.){3}\d{1,3}\/([0-9]|[1-2][0-9]|3[0-2])$/; +export const domainPattern = + /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)*(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3}))$/; + +export const domainWithPortPattern = + /^(?:(?:(?:[A-Za-z-]+):\/{1,3})?(?:[A-Za-z0-9])(?:[A-Za-z0-9\-.]){1,61}(?:\.[A-Za-z]{2,})+|\[(?:(?:(?:[a-fA-F0-9]){1,4})(?::(?:[a-fA-F0-9]){1,4}){7}|::1|::)\]|(?:(?:[0-9]{1,3})(?:\.[0-9]{1,3}){3})):[0-9]{1,5}$/; export const patternSafeUsernameCharacters = /^[a-zA-Z0-9]+[a-zA-Z0-9.\-_]*$/; diff --git a/web/src/shared/types.ts b/web/src/shared/types.ts index 33258f66b6..86bf0baca2 100644 --- a/web/src/shared/types.ts +++ b/web/src/shared/types.ts @@ -171,7 +171,7 @@ export type ModifyNetworkRequest = { Network, 'gateways' | 'connected' | 'id' | 'connected_at' | 'allowed_ips' > & { - allowed_ips: string; + allowed_ips?: string; }; }; diff --git a/web/src/shared/validators.ts b/web/src/shared/validators.ts index 44d3da6c57..23b24bbfc0 100644 --- a/web/src/shared/validators.ts +++ b/web/src/shared/validators.ts @@ -1,6 +1,12 @@ import ipaddr from 'ipaddr.js'; import { z } from 'zod'; -import { patternValidDomain, patternValidWireguardKey } from './patterns'; +import { + domainPattern, + ipv4Pattern, + ipv4WithCIDRPattern, + ipv4WithPortPattern, + patternValidWireguardKey, +} from './patterns'; export const validateWireguardPublicKey = (props: { requiredError: string; @@ -17,102 +23,155 @@ export const validateWireguardPublicKey = (props: { .max(44, props.maxError) .regex(patternValidWireguardKey, props.validKeyError); -// Returns false when invalid -export const validateIpOrDomain = ( - val: string, - allowMask = false, - allowIPv6 = false, -): boolean => { - const hasLetter = /\p{L}/u.test(val); - const hasColon = /:/.test(val); - if (!hasLetter || hasColon) { - return (allowIPv6 && validateIPv6(val, allowMask)) || validateIPv4(val, allowMask); - } else { - return patternValidDomain.test(val); - } -}; - -// Returns false when invalid -export const validateIpList = ( - val: string, - splitWith = ',', - allowMasks = false, -): boolean => { - return val - .replace(' ', '') - .split(splitWith) - .every((el) => { - if (!el.includes('/') && allowMasks) return false; - return validateIPv4(el, allowMasks) || validateIPv6(el, allowMasks); - }); -}; - -// Returns false when invalid -export const validateIpOrDomainList = ( - val: string, - splitWith = ',', - allowMasks = false, - allowIPv6 = false, -): boolean => { - const trimmed = val.replace(' ', ''); - const split = trimmed.split(splitWith); - for (const value of split) { - if ( - !validateIPv4(value, allowMasks) && - !patternValidDomain.test(value) && - (!allowIPv6 || !validateIPv6(value, allowMasks)) - ) { - return false; - } - } - return true; -}; - -// Returns false when invalid -export const validateIPv4 = (ip: string, allowMask = false): boolean => { - if (allowMask) { +export const Validate = { + IPv4: (ip: string): boolean => { + if (!ipv4Pattern.test(ip)) { + return false; + } + if (!ipaddr.IPv4.isValid(ip)) { + return false; + } + return true; + }, + IPv4withPort: (ip: string): boolean => { + if (!ipv4WithPortPattern.test(ip)) { + return false; + } + const addr = ip.split(':'); + if (!ipaddr.IPv4.isValid(addr[0]) || !Validate.Port(addr[1])) { + return false; + } + return true; + }, + IPv6: (ip: string): boolean => { + if (!ipaddr.IPv6.isValid(ip)) { + return false; + } + return true; + }, + IPv6withPort: (ip: string): boolean => { + if (ip.includes(']')) { + const address = ip.split(']'); + const ipv6 = address[0].replaceAll('[', '').replaceAll(']', ''); + const port = address[1].replaceAll(']', '').replaceAll(':', ''); + if (!ipaddr.IPv6.isValid(ipv6)) { + return false; + } + if (!Validate.Port(port)) { + return false; + } + } else { + return false; + } + return true; + }, + CIDRv4: (ip: string): boolean => { + if (!ipv4WithCIDRPattern.test(ip)) { + return false; + } if (ip.endsWith('/0')) { return false; } - if (ip.includes('/')) { - return ipaddr.IPv4.isValidCIDR(ip); + if (!ipaddr.IPv4.isValidCIDR(ip)) { + return false; + } + return true; + }, + CIDRv6: (ip: string): boolean => { + if (ip.endsWith('/0')) { + return false; + } + if (!ipaddr.IPv6.isValidCIDR(ip)) { + return false; + } + return true; + }, + Domain: (ip: string): boolean => { + if (!domainPattern.test(ip)) { + return false; + } + return true; + }, + DomainWithPort: (ip: string): boolean => { + const splitted = ip.split(':'); + const domain = splitted[0]; + const port = splitted[1]; + if (!Validate.Port(port)) { + return false; + } + if (!domainPattern.test(domain)) { + return false; + } + return true; + }, + Port: (val: string): boolean => { + const parsed = Number(val); + if (Number.isNaN(parsed) || !Number.isInteger(parsed)) { + return false; + } + return 0 < parsed && parsed <= 65535; + }, + Empty: (val: string): boolean => { + if (val === '' || !val) { + return true; } - } - const ipv4Pattern = /^(\d{1,3}\.){3}\d{1,3}$/; - const ipv4WithPortPattern = /^(\d{1,3}\.){3}\d{1,3}:\d{1,5}$/; - if (!ipv4Pattern.test(ip) && !ipv4WithPortPattern.test(ip)) { return false; - } + }, + any: ( + value: string | undefined, + validators: Array<(val: string) => boolean>, + allowList: boolean = false, + splitWith = ',', + ): boolean => { + if (!value) { + return true; + } + const items = value.replaceAll(' ', '').split(splitWith); - if (ipv4WithPortPattern.test(ip)) { - const [address, port] = ip.split(':'); - ip = address; - if (!validatePort(port)) { + if (items.length > 1 && !allowList) { return false; } - } - return ipaddr.IPv4.isValid(ip); -}; + for (const item of items) { + let valid = false; + for (const validator of validators) { + if (validator(item)) { + valid = true; + break; + } + } + if (!valid) { + return false; + } + } -export const validateIPv6 = (ip: string, allowMask = false): boolean => { - if (allowMask) { - if (ip.endsWith('/0')) { + return true; + }, + all: ( + value: string | undefined, + validators: Array<(val: string) => boolean>, + allowList: boolean = false, + splitWith = ',', + ): boolean => { + if (!value) { + return true; + } + const items = value.replaceAll(' ', '').split(splitWith); + + if (items.length > 1 && !allowList) { return false; } - if (ip.includes('/')) { - return ipaddr.IPv6.isValidCIDR(ip); + for (const item of items) { + for (const validator of validators) { + if (!validator(item)) { + return false; + } + } } - } - return ipaddr.IPv6.isValid(ip); -}; -export const validatePort = (val: string) => { - const parsed = parseInt(val, 10); - if (!Number.isNaN(parsed)) { - return parsed <= 65535; - } -}; + return true; + }, +} as const; export const numericString = (val: string) => /^\d+$/.test(val); diff --git a/web/tests/validators.test.ts b/web/tests/validators.test.ts new file mode 100644 index 0000000000..08f8000998 --- /dev/null +++ b/web/tests/validators.test.ts @@ -0,0 +1,301 @@ +import { describe, expect, it } from 'vitest'; +import { Validate } 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 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); + }); +}); + +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); + }); +}); + +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 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); + }); +}); + +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 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); + }); +}); + +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); + }); +}); + +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); + }); +}); 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'), + }, + }, +});