From f969ba7effa65def7ca20eeb24c9aedfc93b4f4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 09:06:24 +0100 Subject: [PATCH 01/23] update dependencies --- Cargo.lock | 12 +-- flake.lock | 12 +-- web/package.json | 14 +-- web/pnpm-lock.yaml | 257 +++++++++++++++++---------------------------- 4 files changed, 115 insertions(+), 180 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4a6f696a65..e9530c1277 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4562,9 +4562,9 @@ dependencies = [ [[package]] name = "proc-macro-crate" -version = "3.4.0" +version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219cb19e96be00ab2e37d6e299658a0cfa83e52429179969b0f0121b4ac46983" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ "toml_edit", ] @@ -6448,18 +6448,18 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "0.7.5+spec-1.1.0" +version = "1.0.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e" dependencies = [ "serde_core", ] [[package]] name = "toml_edit" -version = "0.23.10+spec-1.0.0" +version = "0.25.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84c8b9f757e028cee9fa244aea147aab2a9ec09d5325a9b01e0a49730c2b5269" +checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2" dependencies = [ "indexmap 2.13.0", "toml_datetime", diff --git a/flake.lock b/flake.lock index 9b2d3e5625..e4c38fe099 100644 --- a/flake.lock +++ b/flake.lock @@ -32,11 +32,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1771848320, - "narHash": "sha256-0MAd+0mun3K/Ns8JATeHT1sX28faLII5hVLq0L3BdZU=", + "lastModified": 1772542754, + "narHash": "sha256-WGV2hy+VIeQsYXpsLjdr4GvHv5eECMISX1zKLTedhdg=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "2fc6539b481e1d2569f25f8799236694180c0993", + "rev": "8c809a146a140c5c8806f13399592dbcb1bb5dc4", "type": "github" }, "original": { @@ -74,11 +74,11 @@ ] }, "locked": { - "lastModified": 1771988922, - "narHash": "sha256-Fc6FHXtfEkLtuVJzd0B6tFYMhmcPLuxr90rWfb/2jtQ=", + "lastModified": 1772679930, + "narHash": "sha256-FxYmdacqrdDVeE9QqZKTIpNLjv2B8GSKssgwlZuTR98=", "owner": "oxalica", "repo": "rust-overlay", - "rev": "f4443dc3f0b6c5e6b77d923156943ce816d1fcb9", + "rev": "9b741db17141331fdb26270a1b66b81be8be9edd", "type": "github" }, "original": { diff --git a/web/package.json b/web/package.json index 3fd1946840..d76cca85c7 100644 --- a/web/package.json +++ b/web/package.json @@ -20,9 +20,9 @@ "@shortercode/webzip": "1.1.1-0", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", - "@tanstack/react-form": "^1.28.3", + "@tanstack/react-form": "^1.28.4", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.163.3", + "@tanstack/react-router": "^1.166.2", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", "@uidotdev/usehooks": "^2.4.1", @@ -34,7 +34,7 @@ "humanize-duration": "^3.33.2", "ipaddr.js": "^2.3.0", "lodash-es": "^4.17.23", - "motion": "^12.34.5", + "motion": "^12.35.0", "qrcode.react": "^4.2.0", "qs": "^6.15.0", "radashi": "^12.7.2", @@ -53,11 +53,11 @@ "devDependencies": { "@biomejs/biome": "2.4.4", "@inlang/paraglide-js": "2.12.0", - "@tanstack/devtools-vite": "^0.5.2", - "@tanstack/react-devtools": "^0.9.7", + "@tanstack/devtools-vite": "^0.5.3", + "@tanstack/react-devtools": "^0.9.9", "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router-devtools": "^1.163.3", - "@tanstack/router-plugin": "^1.164.0", + "@tanstack/react-router-devtools": "^1.166.2", + "@tanstack/router-plugin": "^1.166.2", "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 0993e44400..22e1705bf4 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -30,14 +30,14 @@ importers: specifier: ^2.0.1 version: 2.0.1 '@tanstack/react-form': - specifier: ^1.28.3 - version: 1.28.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.28.4 + version: 1.28.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-query': specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.163.3 - version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.166.2 + version: 1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -72,8 +72,8 @@ importers: specifier: ^4.17.23 version: 4.17.23 motion: - specifier: ^12.34.5 - version: 12.34.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^12.35.0 + version: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.4) @@ -121,20 +121,20 @@ importers: specifier: 2.4.4 version: 2.4.4 '@tanstack/devtools-vite': - specifier: ^0.5.2 - version: 0.5.2(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) + specifier: ^0.5.3 + version: 0.5.3(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) '@tanstack/react-devtools': - specifier: ^0.9.7 - version: 0.9.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) + specifier: ^0.9.9 + version: 0.9.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) '@tanstack/react-query-devtools': specifier: ^5.91.3 version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': - specifier: ^1.163.3 - version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.166.2 + version: 1.166.2(@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': - specifier: ^1.164.0 - version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) + specifier: ^1.166.2 + version: 1.166.2(@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -306,28 +306,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.4': resolution: {integrity: sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.4': resolution: {integrity: sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.4': resolution: {integrity: sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.4': resolution: {integrity: sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==} @@ -596,105 +592,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==} @@ -803,42 +783,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==} @@ -925,79 +899,66 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -1126,28 +1087,24 @@ packages: engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [glibc] '@swc/core-linux-arm64-musl@1.15.18': resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - libc: [musl] '@swc/core-linux-x64-gnu@1.15.18': resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [glibc] '@swc/core-linux-x64-musl@1.15.18': resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] - libc: [musl] '@swc/core-win32-arm64-msvc@1.15.18': resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} @@ -1182,38 +1139,38 @@ packages: '@swc/types@0.1.25': resolution: {integrity: sha512-iAoY/qRhNH8a/hBvm3zKj9qQ4oc2+3w1unPJa2XvTK3XjeLXtzcCingVPw/9e5mn1+0yPqxcBGp9Jf0pkfMb1g==} - '@tanstack/devtools-client@0.0.5': - resolution: {integrity: sha512-hsNDE3iu4frt9cC2ppn1mNRnLKo2uc1/1hXAyY9z4UYb+o40M2clFAhiFoo4HngjfGJDV3x18KVVIq7W4Un+zA==} + '@tanstack/devtools-client@0.0.6': + resolution: {integrity: sha512-f85ZJXJnDIFOoykG/BFIixuAevJovCvJF391LPs6YjBAPhGYC50NWlx1y4iF/UmK5/cCMx+/JqI5SBOz7FanQQ==} engines: {node: '>=18'} '@tanstack/devtools-event-bus@0.4.1': resolution: {integrity: sha512-cNnJ89Q021Zf883rlbBTfsaxTfi2r73/qejGtyTa7ksErF3hyDyAq1aTbo5crK9dAL7zSHh9viKY1BtMls1QOA==} engines: {node: '>=18'} - '@tanstack/devtools-event-client@0.4.0': - resolution: {integrity: sha512-RPfGuk2bDZgcu9bAJodvO2lnZeHuz4/71HjZ0bGb/SPg8+lyTA+RLSKQvo7fSmPSi8/vcH3aKQ8EM9ywf1olaw==} + '@tanstack/devtools-event-client@0.4.1': + resolution: {integrity: sha512-GRxmPw4OHZ2oZeIEUkEwt/NDvuEqzEYRAjzUVMs+I0pd4C7k1ySOiuJK2CqF+K/yEAR3YZNkW3ExrpDarh9Vwg==} engines: {node: '>=18'} - '@tanstack/devtools-ui@0.4.4': - resolution: {integrity: sha512-5xHXFyX3nom0UaNfiOM92o6ziaHjGo3mcSGe2HD5Xs8dWRZNpdZ0Smd0B9ddEhy0oB+gXyMzZgUJb9DmrZV0Mg==} + '@tanstack/devtools-ui@0.5.0': + resolution: {integrity: sha512-nNZ14054n31fWB61jtWhZYLRdQ3yceCE3G/RINoINUB0RqIGZAIm9DnEDwOTAOfqt4/a/D8vNk8pJu6RQUp74g==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' - '@tanstack/devtools-vite@0.5.2': - resolution: {integrity: sha512-UQF6qnxZ1Ad0J/r25lGYFST3cdAY2mdW1WjRyPyHLDg85pVZ52PigWp3QUnzXsx7VNQVy4+8asADwOOHpsVF0w==} + '@tanstack/devtools-vite@0.5.3': + resolution: {integrity: sha512-S7VK2GthBrEkto0UVODx31IAvxTFUK0RGH6aZEZixsohYTytTdrwcLmPKEREwFrRN8Qc2mIqfBhENPbRG1RFXA==} engines: {node: '>=18'} peerDependencies: vite: ^6.0.0 || ^7.0.0 - '@tanstack/devtools@0.10.8': - resolution: {integrity: sha512-m2WIFihOztK6B6vQgq+i6sCSHLo5qKTr26+qiWqF0WsyyZgUNWxTAS+C8Aisw7gzT23Wow+Cgil/zt4/GWXoUg==} + '@tanstack/devtools@0.10.10': + resolution: {integrity: sha512-/SSJcyhZtq1+HB9UViz8e0Y7Io4JPIbyJ0Wns5ENwzSHsNOAheANA8QnEQBVXETY/osCcaGAVOyVfGgn5aBJKA==} engines: {node: '>=18'} peerDependencies: solid-js: '>=1.9.7' - '@tanstack/form-core@1.28.3': - resolution: {integrity: sha512-DBhnu1d5VfACAYOAZJO8tsEUHjWczZMJY8v/YrtAJNWpwvL/3ogDuz8e6yUB2m/iVTNq6K8yrnVN2nrX0/BX/w==} + '@tanstack/form-core@1.28.4': + resolution: {integrity: sha512-2eox5ePrJ6kvA1DXD5QHk/GeGr3VFZ0uYR63UgQOe7bUg6h1JfXaIMqTjZK9sdGyE4oRNqFpoW54H0pZM7nObQ==} '@tanstack/history@1.161.4': resolution: {integrity: sha512-Kp/WSt411ZWYvgXy6uiv5RmhHrz9cAml05AQPrtdAp7eUqvIDbMGPnML25OKbzR3RJ1q4wgENxDTvlGPa9+Mww==} @@ -1229,8 +1186,8 @@ packages: '@tanstack/query-devtools@5.93.0': resolution: {integrity: sha512-+kpsx1NQnOFTZsw6HAFCW3HkKg0+2cepGtAWXjiiSOJJ1CtQpt72EE2nyZb+AjAbLRPoeRmPJ8MtQd8r8gsPdg==} - '@tanstack/react-devtools@0.9.7': - resolution: {integrity: sha512-3F/KmqWqSBDNlz/GLSJtmvFWpsQHaQOGllVy9ee3azPXg85Yfc1yPBf/TOSYK0bdP8gVtYYndUs5wMFy+4ou3Q==} + '@tanstack/react-devtools@0.9.9': + resolution: {integrity: sha512-w5J5n3tPLfGVRSwV5S05Fg1awa7SJssdyNh/3KPDHU72DPUvH9v5mPGRrbTxsoFWvh3djFTtF9tCX1tCnyoOuQ==} engines: {node: '>=18'} peerDependencies: '@types/react': '>=16.8' @@ -1238,8 +1195,8 @@ packages: react: '>=16.8' react-dom: '>=16.8' - '@tanstack/react-form@1.28.3': - resolution: {integrity: sha512-84yd0swZRcyC3Q46dYBH6bHf1tlIY1flchbdG3VwArg/wLVW5RdBenIrJhleHjk2OxXuF+9HoKQbHglJyWIXQA==} + '@tanstack/react-form@1.28.4': + resolution: {integrity: sha512-ZGBwl9JM2u0kol7jAWpqAkr2JSHfXJaLPsFDZWPf+ewpVkwngTTW/rGgtoDe5uVpHoDIpOhzpPCAh6O1SjGEOg==} peerDependencies: '@tanstack/react-start': '*' react: ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -1258,31 +1215,25 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.163.3': - resolution: {integrity: sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw==} + '@tanstack/react-router-devtools@1.166.2': + resolution: {integrity: sha512-EQhFQRArwxS0OjIWWGD5wfNboJq7rIYCbioHvepgbxgblKtNLWnRr3LFj34QhXTP1aQsPYb9t8+VTi3VbFuAfA==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.163.3 - '@tanstack/router-core': ^1.163.3 + '@tanstack/react-router': ^1.166.2 + '@tanstack/router-core': ^1.166.2 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.163.3': - resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} + '@tanstack/react-router@1.166.2': + resolution: {integrity: sha512-pKhUtrvVLlhjWhsHkJSuIzh1J4LcP+8ErbIqRLORX9Js8dUFMKoT0+8oFpi+P8QRpuhm/7rzjYiWfcyTsqQZtA==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' - '@tanstack/react-store@0.8.1': - resolution: {integrity: sha512-XItJt+rG8c5Wn/2L/bnxys85rBpm0BfMbhb4zmPVLXAKY9POrp1xd6IbU4PKoOI+jSEGc3vntPRfLGSgXfE2Ig==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/react-store@0.9.1': resolution: {integrity: sha512-YzJLnRvy5lIEFTLWBAZmcOjK3+2AepnBv/sr6NZmiqJvq7zTQggyK99Gw8fqYdMdHPQWXjz0epFKJXC+9V2xDA==} peerDependencies: @@ -1302,30 +1253,30 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.163.3': - resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} + '@tanstack/router-core@1.166.2': + resolution: {integrity: sha512-zn3NhENOAX9ToQiX077UV2OH3aJKOvV2ZMNZZxZ3gDG3i3WqL8NfWfEgetEAfMN37/Mnt90PpotYgf7IyuoKqQ==} engines: {node: '>=20.19'} - '@tanstack/router-devtools-core@1.163.3': - resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} + '@tanstack/router-devtools-core@1.166.2': + resolution: {integrity: sha512-Ke8HquuwMhLYpo/6nxNgrzi9Ns2lsK9uwDba6WKA8I0K7fyYZoAUu+7AD6gdEcVU4NF6LjtMPfUCHmVtYYRTDw==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.163.3 + '@tanstack/router-core': ^1.166.2 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.164.0': - resolution: {integrity: sha512-Uiyj+RtW0kdeqEd8NEd3Np1Z2nhJ2xgLS8U+5mTvFrm/s3xkM2LYjJHoLzc6am7sKPDsmeF9a4/NYq3R7ZJP0Q==} + '@tanstack/router-generator@1.166.2': + resolution: {integrity: sha512-wbvdyP1PKKQKk4aVlGeK9S5uDy8zodTr3tEZ2gRKNavJLusXbEWqtoo42JxHFFNB6dtguehFMt8PyZPAtkgWwQ==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.164.0': - resolution: {integrity: sha512-cZPsEMhqzyzmuPuDbsTAzBZaT+cj0pGjwdhjxJfPCM06Ax8v4tFR7n/Ug0UCwnNAUEmKZWN3lA9uT+TxXnk9PQ==} + '@tanstack/router-plugin@1.166.2': + resolution: {integrity: sha512-TnyV/7//Vp5fR49mmNbOWHGz9IJTm1lqVxzPdtpzg7D5PjkW2HFmLFLtWwpJgz2R7AJJWR4Ge5kIPmC+fVZ6eQ==} engines: {node: '>=20.19'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.163.3 + '@tanstack/react-router': ^1.166.2 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1345,9 +1296,6 @@ packages: resolution: {integrity: sha512-r8TpjyIZoqrXXaf2DDyjd44gjGBoyE+/oEaaH68yLI9ySPO1gUWmQENZ1MZnmBnpUGN24NOZxdjDLc8npK0SAw==} engines: {node: '>=20.19'} - '@tanstack/store@0.8.1': - resolution: {integrity: sha512-PtOisLjUZPz5VyPRSCGjNOlwTvabdTBQ2K80DpVL1chGVr35WRxfeavAPdNq6pm/t7F8GhoR2qtmkkqtCEtHYw==} - '@tanstack/store@0.9.1': resolution: {integrity: sha512-+qcNkOy0N1qSGsP7omVCW0SDrXtaDcycPqBDE726yryiA5eTDFpjBReaYjghVJwNf1pcPMyzIwTGlYjCSQR0Fg==} @@ -1647,8 +1595,8 @@ packages: resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} engines: {node: '>=12'} - css-tree@3.1.0: - resolution: {integrity: sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==} + css-tree@3.2.0: + resolution: {integrity: sha512-t99A4LolkP0ZX9WUoaHz4YrPT1FKNlV8IDCeCPPpGaWyxegh64tt/BSUqN3u5necrYRon+ddZ6mPMjxIlfpobg==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0} cssesc@3.0.0: @@ -1869,8 +1817,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.34.5: - resolution: {integrity: sha512-Z2dQ+o7BsfpJI3+u0SQUNCrN+ajCKJen1blC4rCHx1Ta2EOHs+xKJegLT2aaD9iSMbU3OoX+WabQXkloUbZmJQ==} + framer-motion@12.35.0: + resolution: {integrity: sha512-w8hghCMQ4oq10j6aZh3U2yeEQv5K69O/seDI/41PK4HtgkLrcBovUNc0ayBC3UyyU7V1mrY2yLzvYdWJX9pGZQ==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2184,9 +2132,6 @@ packages: mdast-util-to-string@4.0.0: resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} - mdn-data@2.12.2: - resolution: {integrity: sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==} - mdn-data@2.27.1: resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==} @@ -2273,14 +2218,14 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} - motion-dom@12.34.5: - resolution: {integrity: sha512-k33CsnxO2K3gBRMUZT+vPmc4Utlb5menKdG0RyVNLtlqRaaJPRWlE9fXl8NTtfZ5z3G8TDvqSu0MENLqSTaHZA==} + motion-dom@12.35.0: + resolution: {integrity: sha512-FFMLEnIejK/zDABn+vqGVAUN4T0+3fw+cVAY8MMT65yR+j5uMuvWdd4npACWhh94OVWQs79CrBBuwOwGRZAQiA==} motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.34.5: - resolution: {integrity: sha512-N06NLJ9IeBHeielRqIvYvjPfXuRdyTxa+9++BgpGa+hY2D7TcMkI6QzV3jaRuv0aZRXgMa7cPy9YcBUBisPzAQ==} + motion@12.35.0: + resolution: {integrity: sha512-BQUhNUIGvUcwXCzwmnT1JpjUqab34lIwxHnXUyWRht1WC1vAyp7/4qgMiUXxN3K6hgUhyoR+HNnLeQMwUZjVjw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -3730,9 +3675,9 @@ snapshots: dependencies: '@swc/counter': 0.1.3 - '@tanstack/devtools-client@0.0.5': + '@tanstack/devtools-client@0.0.6': dependencies: - '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/devtools-event-client': 0.4.1 '@tanstack/devtools-event-bus@0.4.1': dependencies: @@ -3741,24 +3686,25 @@ snapshots: - bufferutil - utf-8-validate - '@tanstack/devtools-event-client@0.4.0': {} + '@tanstack/devtools-event-client@0.4.1': {} - '@tanstack/devtools-ui@0.4.4(csstype@3.2.3)(solid-js@1.9.9)': + '@tanstack/devtools-ui@0.5.0(csstype@3.2.3)(solid-js@1.9.9)': dependencies: clsx: 2.1.1 + dayjs: 1.11.19 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.9 transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.5.2(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.5.3(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 '@babel/parser': 7.29.0 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/devtools-client': 0.0.5 + '@tanstack/devtools-client': 0.0.6 '@tanstack/devtools-event-bus': 0.4.1 chalk: 5.6.2 launch-editor: 2.13.1 @@ -3769,14 +3715,14 @@ snapshots: - supports-color - utf-8-validate - '@tanstack/devtools@0.10.8(csstype@3.2.3)(solid-js@1.9.9)': + '@tanstack/devtools@0.10.10(csstype@3.2.3)(solid-js@1.9.9)': dependencies: '@solid-primitives/event-listener': 2.4.5(solid-js@1.9.9) '@solid-primitives/keyboard': 1.3.5(solid-js@1.9.9) '@solid-primitives/resize-observer': 2.1.5(solid-js@1.9.9) - '@tanstack/devtools-client': 0.0.5 + '@tanstack/devtools-client': 0.0.6 '@tanstack/devtools-event-bus': 0.4.1 - '@tanstack/devtools-ui': 0.4.4(csstype@3.2.3)(solid-js@1.9.9) + '@tanstack/devtools-ui': 0.5.0(csstype@3.2.3)(solid-js@1.9.9) clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) solid-js: 1.9.9 @@ -3785,11 +3731,11 @@ snapshots: - csstype - utf-8-validate - '@tanstack/form-core@1.28.3': + '@tanstack/form-core@1.28.4': dependencies: - '@tanstack/devtools-event-client': 0.4.0 + '@tanstack/devtools-event-client': 0.4.1 '@tanstack/pacer-lite': 0.1.1 - '@tanstack/store': 0.8.1 + '@tanstack/store': 0.9.1 '@tanstack/history@1.161.4': {} @@ -3799,9 +3745,9 @@ snapshots: '@tanstack/query-devtools@5.93.0': {} - '@tanstack/react-devtools@0.9.7(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9)': + '@tanstack/react-devtools@0.9.9(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9)': dependencies: - '@tanstack/devtools': 0.10.8(csstype@3.2.3)(solid-js@1.9.9) + '@tanstack/devtools': 0.10.10(csstype@3.2.3)(solid-js@1.9.9) '@types/react': 19.2.14 '@types/react-dom': 19.2.3(@types/react@19.2.14) react: 19.2.4 @@ -3812,10 +3758,10 @@ snapshots: - solid-js - utf-8-validate - '@tanstack/react-form@1.28.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-form@1.28.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/form-core': 1.28.3 - '@tanstack/react-store': 0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/form-core': 1.28.4 + '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 transitivePeerDependencies: - react-dom @@ -3831,35 +3777,28 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.166.2(@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.166.2)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3) + '@tanstack/react-router': 1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.166.2(@tanstack/router-core@1.166.2)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.166.2 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.166.2 isbot: 5.1.35 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/react-store@0.8.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': - dependencies: - '@tanstack/store': 0.8.1 - react: 19.2.4 - react-dom: 19.2.4(react@19.2.4) - use-sync-external-store: 1.6.0(react@19.2.4) - '@tanstack/react-store@0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/store': 0.9.1 @@ -3879,7 +3818,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/router-core@1.163.3': + '@tanstack/router-core@1.166.2': dependencies: '@tanstack/history': 1.161.4 '@tanstack/store': 0.9.1 @@ -3889,18 +3828,18 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.166.2(@tanstack/router-core@1.166.2)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.166.2 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.164.0': + '@tanstack/router-generator@1.166.2': dependencies: - '@tanstack/router-core': 1.163.3 + '@tanstack/router-core': 1.166.2 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 prettier: 3.8.1 @@ -3911,7 +3850,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/router-plugin@1.166.2(@tanstack/react-router@1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3919,15 +3858,15 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.163.3 - '@tanstack/router-generator': 1.164.0 + '@tanstack/router-core': 1.166.2 + '@tanstack/router-generator': 1.166.2 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-router': 1.166.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4) vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -3946,8 +3885,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/store@0.8.1': {} - '@tanstack/store@0.9.1': {} '@tanstack/table-core@8.21.3': {} @@ -4219,9 +4156,9 @@ snapshots: css-functions-list@3.3.3: {} - css-tree@3.1.0: + css-tree@3.2.0: dependencies: - mdn-data: 2.12.2 + mdn-data: 2.27.1 source-map-js: 1.2.1 cssesc@3.0.0: {} @@ -4418,9 +4355,9 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.34.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - motion-dom: 12.34.5 + motion-dom: 12.35.0 motion-utils: 12.29.2 tslib: 2.8.1 optionalDependencies: @@ -4790,8 +4727,6 @@ snapshots: dependencies: '@types/mdast': 4.0.4 - mdn-data@2.12.2: {} - mdn-data@2.27.1: {} meow@14.1.0: {} @@ -4942,15 +4877,15 @@ snapshots: dependencies: mime-db: 1.52.0 - motion-dom@12.34.5: + motion-dom@12.35.0: dependencies: motion-utils: 12.29.2 motion-utils@12.29.2: {} - motion@12.34.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.34.5(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.35.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: react: 19.2.4 @@ -5383,7 +5318,7 @@ snapshots: stylelint-scss@7.0.0(stylelint@17.4.0(typescript@5.9.3)): dependencies: - css-tree: 3.1.0 + css-tree: 3.2.0 is-plain-object: 5.0.0 known-css-properties: 0.37.0 mdn-data: 2.27.1 @@ -5405,7 +5340,7 @@ snapshots: colord: 2.9.3 cosmiconfig: 9.0.1(typescript@5.9.3) css-functions-list: 3.3.3 - css-tree: 3.1.0 + css-tree: 3.2.0 debug: 4.4.3 fast-glob: 3.3.3 fastest-levenshtein: 1.0.16 From 783da8d97554eb7286d68dca9a0c1aae34bd4b62 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 09:26:50 +0100 Subject: [PATCH 02/23] add test for destination delete API --- .../src/enterprise/db/models/acl.rs | 5 +- .../src/enterprise/handlers/acl.rs | 2 +- .../enterprise/handlers/acl/destination.rs | 2 +- .../tests/integration/api/acl.rs | 122 ++++++++++++++++-- 4 files changed, 113 insertions(+), 18 deletions(-) diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index efdbca7d5a..5f0beba955 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -1653,10 +1653,7 @@ impl TryFrom<&EditAclAlias> for AclAlias { impl AclAlias { /// Fetch [`AclAlias`] of a given kind. - pub(crate) async fn all_of_kind<'e, E>( - executor: E, - kind: AliasKind, - ) -> Result, sqlx::Error> + pub async fn all_of_kind<'e, E>(executor: E, kind: AliasKind) -> Result, sqlx::Error> where E: PgExecutor<'e>, { diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index 5bb9973d2d..3e92e3c26f 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -1,5 +1,5 @@ pub mod alias; -pub(crate) mod destination; +pub mod destination; use axum::{ Json, diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index f26172e973..acefd97c68 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -22,7 +22,7 @@ use crate::{ /// API representation of [`AclAlias`] used in API requests for modification operations #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, ToSchema)] -pub(crate) struct EditAclDestination { +pub struct EditAclDestination { pub name: String, pub addresses: String, pub ports: String, diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl.rs index eef7df3200..7d2c54fe54 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl.rs @@ -16,6 +16,7 @@ use defguard_core::{ handlers::acl::{ ApiAclRule, EditAclRule, alias::{ApiAclAlias, EditAclAlias}, + destination::EditAclDestination, }, license::{get_cached_license, set_cached_license}, }, @@ -94,16 +95,23 @@ fn make_alias() -> EditAclAlias { } } -fn make_destination() -> Value { - json!({ - "name": "destination", - "addresses": "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1", - "ports": "1, 2, 3, 10-20, 30-40", - "protocols": [6, 17], - "any_address": false, - "any_port": false, - "any_protocol": false - }) +fn make_destination() -> EditAclDestination { + EditAclDestination { + name: "destination".to_string(), + addresses: "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + ports: "1, 2, 3, 10-20, 30-40".to_string(), + protocols: vec![6, 17], + any_address: false, + any_port: false, + any_protocol: false, + } +} + +async fn count_destinations(pool: &PgPool) -> usize { + AclAlias::all_of_kind(pool, AliasKind::Destination) + .await + .unwrap() + .len() } fn edit_rule_data_into_api_response( @@ -1229,6 +1237,96 @@ async fn test_alias_delete(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); } +#[sqlx::test] +async fn test_destination_delete(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + // create destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + assert_eq!(count_destinations(&pool).await, 1); + + // use destination in a new rule + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.destinations = vec![destination_id]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // cannot delete destination if used by a rule + let response = client + .delete(format!("/api/v1/acl/destination/{destination_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(count_destinations(&pool).await, 1); + + // remove destination from rule + rule.use_manual_destination_settings = true; + rule.destinations = Vec::new(); + let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // delete alias + let response = client + .delete(format!("/api/v1/acl/destination/{destination_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); + + // create another destination + let mut destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(count_destinations(&pool).await, 1); + + // modify destination + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // delete pending modification + let response = client.delete("/api/v1/acl/destination/3").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 1); + + // modify destination again + destination.name = "modified again".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // delete original destination + let response = client.delete("/api/v1/acl/destination/2").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); +} + #[sqlx::test] async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1433,8 +1531,8 @@ async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::CREATED); - let mut destination_to_update = destination_1; - destination_to_update["name"] = json!("updated destination"); + let mut destination_to_update = destination.clone(); + destination_to_update.name = "updated destination".to_string(); let response = client .put(format!("/api/v1/acl/destination/{destination_1_id}")) .json(&destination_to_update) From affbdb292634dab695effd515339eb36f3646a33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 09:47:03 +0100 Subject: [PATCH 03/23] split acl API tests into separate modules --- .../tests/integration/api/acl/aliases.rs | 358 +++++++++ .../tests/integration/api/acl/destinations.rs | 195 +++++ .../tests/integration/api/acl/mod.rs | 179 +++++ .../integration/api/{acl.rs => acl/rules.rs} | 727 +----------------- 4 files changed, 733 insertions(+), 726 deletions(-) create mode 100644 crates/defguard_core/tests/integration/api/acl/aliases.rs create mode 100644 crates/defguard_core/tests/integration/api/acl/destinations.rs create mode 100644 crates/defguard_core/tests/integration/api/acl/mod.rs rename crates/defguard_core/tests/integration/api/{acl.rs => acl/rules.rs} (57%) diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs new file mode 100644 index 0000000000..9763e1d17d --- /dev/null +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -0,0 +1,358 @@ +use super::*; + +#[sqlx::test] +async fn test_alias_crud(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + + // create + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response_alias: ApiAclAlias = response.json().await; + let expected_response = edit_alias_data_into_api_response( + alias, + response_alias.id, + None, + AliasState::Applied, + AliasKind::Component, + Vec::new(), + ); + assert_eq!(response_alias, expected_response); + + // list + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_aliases: Vec = response.json().await; + assert_eq!(response_aliases.len(), 1); + let response_alias = response_aliases[0].clone(); + assert_eq!(response_alias, expected_response); + + // retrieve + let response = client.get("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_alias: ApiAclAlias = response.json().await; + assert_eq!(response_alias, expected_response); + + // update + let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + alias.name = "modified".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_alias: ApiAclAlias = response.json().await; + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(response_alias, alias); + + // delete + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_aliases: Vec = response.json().await; + assert_eq!(response_aliases.len(), 0); +} + +#[sqlx::test] +async fn test_alias_enterprise(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + exceed_enterprise_limits(&client).await; + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // try to use ACL api + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + // GET should be allowed + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_aliases: Vec = response.json().await; + assert_eq!(response_aliases.len(), 1); + let response = client.get("/api/v1/acl/alias").send().await; + assert_eq!(response.status(), StatusCode::OK); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn test_alias_create_modify_state(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + + // assert created alias has correct state + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let dbalias = AclAlias::find_by_id(&pool, 1).await.unwrap().unwrap(); + assert_eq!(dbalias.state, AliasState::Applied); + assert_eq!(dbalias.parent_id, None); + + // test APPLIED alias modification + let alias_before_mods: ApiAclAlias = + client.get("/api/v1/acl/alias/1").send().await.json().await; + let mut alias_modified = alias_before_mods.clone(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&alias_modified) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + let alias_parent: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + let alias_child: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(alias_parent, alias_before_mods); + assert_eq!(alias_parent.state, AliasState::Applied); + alias_modified.id = 2; + alias_modified.state = AliasState::Modified; + alias_modified.parent_id = Some(1); + assert_eq!(alias_child, alias_modified); + assert_eq!(alias_child.state, AliasState::Modified); + assert_eq!(alias_child.parent_id, Some(alias_parent.id)); +} + +#[sqlx::test] +async fn test_alias_delete(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + // create alias + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // use alias in a new rule + let mut rule = make_rule(); + rule.aliases = vec![1]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // cannot delete alias if used by a rule + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // remove alias from rule + rule.aliases = Vec::new(); + let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // delete alias + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); + + // create another alias + let mut alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // modify alias + alias.name = "modified".to_string(); + let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + // delete pending modification + let response = client.delete("/api/v1/acl/alias/3").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + + // modify alias again + alias.name = "modified again".to_string(); + let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + // delete original alias + let response = client.delete("/api/v1/acl/alias/2").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); +} + +#[sqlx::test] +async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // each modification of parent alias should remove the child and create a new one + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // ensure we don't duplicate already modified / deleted aliass + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + let response = client.delete("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); +} + +#[sqlx::test] +async fn test_alias_application(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + // create new alias + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // verify initial status + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + + // use alias in a new rule + let mut rule = make_rule(); + rule.aliases = vec![1]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // verify rule assignment + let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + assert_eq!(alias.rules, vec![1]); + + // modify alias + alias.name = "modified".to_string(); + let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); + + // cannot apply already applied alias + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // apply modification + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [2] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // verify alias was applied + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + assert_eq!(alias.parent_id, None); + assert_eq!(alias.rules, vec![1]); + + // initial alias was deleted + let response = client.get("/api/v1/acl/alias/1").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); +} + +#[sqlx::test] +async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let alias_1 = make_alias(); + let alias_2 = make_alias(); + let alias_3 = make_alias(); + + // create new aliass + let response = client.post("/api/v1/acl/alias").json(&alias_1).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.post("/api/v1/acl/alias").json(&alias_2).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.post("/api/v1/acl/alias").json(&alias_3).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // modify aliases + let mut alias_1: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; + alias_1.name = "modified 1".to_string(); + let response = client + .put("/api/v1/acl/alias/1") + .json(&alias_1) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut alias_2: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + alias_2.name = "modified 2".to_string(); + let response = client + .put("/api/v1/acl/alias/2") + .json(&alias_2) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut alias_3: ApiAclAlias = client.get("/api/v1/acl/alias/3").send().await.json().await; + alias_3.name = "modified 3".to_string(); + let response = client + .put("/api/v1/acl/alias/3") + .json(&alias_3) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 6); + + // apply multiple aliases + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [4, 6] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 4); + + // verify alias state + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/4").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/5").send().await.json().await; + assert_eq!(alias.state, AliasState::Modified); + let alias: ApiAclAlias = client.get("/api/v1/acl/alias/6").send().await.json().await; + assert_eq!(alias.state, AliasState::Applied); +} diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs new file mode 100644 index 0000000000..8a1b5f8def --- /dev/null +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -0,0 +1,195 @@ +use super::*; + +#[sqlx::test] +async fn test_destination_delete(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + // create destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + assert_eq!(count_destinations(&pool).await, 1); + + // use destination in a new rule + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.destinations = vec![destination_id]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // cannot delete destination if used by a rule + let response = client + .delete(format!("/api/v1/acl/destination/{destination_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(count_destinations(&pool).await, 1); + + // remove destination from rule + rule.use_manual_destination_settings = true; + rule.destinations = Vec::new(); + let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::OK); + + // delete alias + let response = client + .delete(format!("/api/v1/acl/destination/{destination_id}")) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); + + // create another destination + let mut destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(count_destinations(&pool).await, 1); + + // modify destination + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // delete pending modification + let response = client.delete("/api/v1/acl/destination/3").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 1); + + // modify destination again + destination.name = "modified again".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // delete original destination + let response = client.delete("/api/v1/acl/destination/2").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); +} + +#[sqlx::test] +async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // create destination with empty fields and no any flags + let invalid_destination = json!({ + "name": "invalid destination", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": false + }); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // try to create destinations with only some destination fields set + let invalid_destination = json!({ + "name": "invalid destination", + "addresses": "", + "ports": "22, 443", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": true + }); + let response = client + .post("/api/v1/acl/destination") + .json(&invalid_destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // create valid destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let destination: Value = response.json().await; + let destination_id = destination["id"].as_i64().unwrap(); + + // update destination with empty fields and no any flags + let invalid_update = json!({ + "name": "invalid update", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": false, + "any_port": false, + "any_protocol": false + }); + let response = client + .put(format!("/api/v1/acl/destination/{destination_id}")) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // update destination with some destination fields set + let invalid_update = json!({ + "name": "invalid update", + "addresses": "", + "ports": "5432", + "protocols": [], + "any_address": true, + "any_port": false, + "any_protocol": false + }); + let response = client + .put(format!("/api/v1/acl/destination/{destination_id}")) + .json(&invalid_update) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // create valid destination with only "any" flags enabled + let destination = json!({ + "name": "valid destination", + "addresses": "", + "ports": "", + "protocols": [], + "any_address": true, + "any_port": true, + "any_protocol": true + }); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); +} diff --git a/crates/defguard_core/tests/integration/api/acl/mod.rs b/crates/defguard_core/tests/integration/api/acl/mod.rs new file mode 100644 index 0000000000..118e544158 --- /dev/null +++ b/crates/defguard_core/tests/integration/api/acl/mod.rs @@ -0,0 +1,179 @@ +use defguard_common::{ + config::DefGuardConfig, + db::{ + Id, + models::{ + Device, DeviceType, User, WireguardNetwork, + group::Group, + settings::initialize_current_settings, + wireguard::{DEFAULT_WIREGUARD_MTU, LocationMfaMode, ServiceLocationMode}, + }, + }, +}; +use defguard_core::{ + enterprise::{ + db::models::acl::{AclAlias, AclRule, AliasKind, AliasState, RuleState}, + handlers::acl::{ + ApiAclRule, EditAclRule, + alias::{ApiAclAlias, EditAclAlias}, + destination::EditAclDestination, + }, + license::{get_cached_license, set_cached_license}, + }, + handlers::Auth, +}; +use reqwest::StatusCode; +use serde_json::{Value, json}; +use sqlx::{ + PgPool, + postgres::{PgConnectOptions, PgPoolOptions}, +}; +use tokio::net::TcpListener; + +use super::common::{ + authenticate_admin, client::TestClient, exceed_enterprise_limits, make_base_client, + make_test_client, setup_pool, +}; +use crate::common::{init_config, initialize_users}; + +mod aliases; +mod destinations; +mod rules; + +async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { + let listener = TcpListener::bind("127.0.0.1:0") + .await + .expect("Could not bind ephemeral socket"); + initialize_users(&pool, &config).await; + initialize_current_settings(&pool) + .await + .expect("Could not initialize settings"); + let (client, _) = make_base_client(pool, config, listener).await; + client +} + +fn make_rule() -> EditAclRule { + EditAclRule { + name: "rule".to_string(), + all_locations: false, + locations: Vec::new(), + expires: None, + allow_all_users: false, + deny_all_users: false, + allow_all_groups: false, + deny_all_groups: false, + allow_all_network_devices: false, + deny_all_network_devices: false, + allowed_users: vec![1], + denied_users: Vec::new(), + allowed_groups: Vec::new(), + denied_groups: Vec::new(), + allowed_network_devices: Vec::new(), + denied_network_devices: Vec::new(), + addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + aliases: Vec::new(), + destinations: Vec::new(), + enabled: true, + protocols: vec![6, 17], + ports: "1, 2, 3, 10-20, 30-40".to_string(), + any_address: false, + any_port: false, + any_protocol: false, + use_manual_destination_settings: true, + } +} + +async fn set_rule_state(pool: &PgPool, id: Id, state: RuleState, parent_id: Option) { + let mut rule = AclRule::find_by_id(pool, id).await.unwrap().unwrap(); + rule.state = state; + rule.parent_id = parent_id; + rule.save(pool).await.unwrap(); +} + +fn make_alias() -> EditAclAlias { + EditAclAlias { + name: "alias".to_string(), + addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + protocols: vec![6, 17], + ports: "1, 2, 3, 10-20, 30-40".to_string(), + } +} + +fn make_destination() -> EditAclDestination { + EditAclDestination { + name: "destination".to_string(), + addresses: "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), + ports: "1, 2, 3, 10-20, 30-40".to_string(), + protocols: vec![6, 17], + any_address: false, + any_port: false, + any_protocol: false, + } +} + +async fn count_destinations(pool: &PgPool) -> usize { + AclAlias::all_of_kind(pool, AliasKind::Destination) + .await + .unwrap() + .len() +} + +fn edit_rule_data_into_api_response( + data: &EditAclRule, + id: Id, + parent_id: Option, + state: RuleState, +) -> ApiAclRule { + ApiAclRule { + id, + parent_id, + state, + name: data.name.clone(), + all_locations: data.all_locations, + locations: data.locations.clone(), + expires: data.expires, + enabled: data.enabled, + allow_all_users: data.allow_all_users, + deny_all_users: data.deny_all_users, + allow_all_groups: data.allow_all_groups, + deny_all_groups: data.deny_all_groups, + allow_all_network_devices: data.allow_all_network_devices, + deny_all_network_devices: data.deny_all_network_devices, + allowed_users: data.allowed_users.clone(), + denied_users: data.denied_users.clone(), + allowed_groups: data.allowed_groups.clone(), + denied_groups: data.denied_groups.clone(), + allowed_network_devices: data.allowed_network_devices.clone(), + denied_network_devices: data.denied_network_devices.clone(), + addresses: data.addresses.clone(), + aliases: data.aliases.clone(), + destinations: data.destinations.clone(), + ports: data.ports.clone(), + protocols: data.protocols.clone(), + any_address: data.any_address, + any_port: data.any_port, + any_protocol: data.any_protocol, + use_manual_destination_settings: data.use_manual_destination_settings, + } +} + +fn edit_alias_data_into_api_response( + data: EditAclAlias, + id: Id, + parent_id: Option, + state: AliasState, + kind: AliasKind, + rules: Vec, +) -> ApiAclAlias { + ApiAclAlias { + id, + parent_id, + state, + name: data.name, + kind, + addresses: data.addresses, + ports: data.ports, + protocols: data.protocols, + rules, + } +} diff --git a/crates/defguard_core/tests/integration/api/acl.rs b/crates/defguard_core/tests/integration/api/acl/rules.rs similarity index 57% rename from crates/defguard_core/tests/integration/api/acl.rs rename to crates/defguard_core/tests/integration/api/acl/rules.rs index 7d2c54fe54..2ae2782bae 100644 --- a/crates/defguard_core/tests/integration/api/acl.rs +++ b/crates/defguard_core/tests/integration/api/acl/rules.rs @@ -1,178 +1,4 @@ -use defguard_common::{ - config::DefGuardConfig, - db::{ - Id, - models::{ - Device, DeviceType, User, WireguardNetwork, - group::Group, - settings::initialize_current_settings, - wireguard::{DEFAULT_WIREGUARD_MTU, LocationMfaMode, ServiceLocationMode}, - }, - }, -}; -use defguard_core::{ - enterprise::{ - db::models::acl::{AclAlias, AclRule, AliasKind, AliasState, RuleState}, - handlers::acl::{ - ApiAclRule, EditAclRule, - alias::{ApiAclAlias, EditAclAlias}, - destination::EditAclDestination, - }, - license::{get_cached_license, set_cached_license}, - }, - handlers::Auth, -}; -use reqwest::StatusCode; -use serde_json::{Value, json}; -use sqlx::{ - PgPool, - postgres::{PgConnectOptions, PgPoolOptions}, -}; -use tokio::net::TcpListener; - -use super::common::{ - authenticate_admin, client::TestClient, exceed_enterprise_limits, make_base_client, - make_test_client, setup_pool, -}; -use crate::common::{init_config, initialize_users}; - -async fn make_client_v2(pool: PgPool, config: DefGuardConfig) -> TestClient { - let listener = TcpListener::bind("127.0.0.1:0") - .await - .expect("Could not bind ephemeral socket"); - initialize_users(&pool, &config).await; - initialize_current_settings(&pool) - .await - .expect("Could not initialize settings"); - let (client, _) = make_base_client(pool, config, listener).await; - client -} - -fn make_rule() -> EditAclRule { - EditAclRule { - name: "rule".to_string(), - all_locations: false, - locations: Vec::new(), - expires: None, - allow_all_users: false, - deny_all_users: false, - allow_all_groups: false, - deny_all_groups: false, - allow_all_network_devices: false, - deny_all_network_devices: false, - allowed_users: vec![1], - denied_users: Vec::new(), - allowed_groups: Vec::new(), - denied_groups: Vec::new(), - allowed_network_devices: Vec::new(), - denied_network_devices: Vec::new(), - addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), - aliases: Vec::new(), - destinations: Vec::new(), - enabled: true, - protocols: vec![6, 17], - ports: "1, 2, 3, 10-20, 30-40".to_string(), - any_address: false, - any_port: false, - any_protocol: false, - use_manual_destination_settings: true, - } -} - -async fn set_rule_state(pool: &PgPool, id: Id, state: RuleState, parent_id: Option) { - let mut rule = AclRule::find_by_id(pool, id).await.unwrap().unwrap(); - rule.state = state; - rule.parent_id = parent_id; - rule.save(pool).await.unwrap(); -} - -fn make_alias() -> EditAclAlias { - EditAclAlias { - name: "alias".to_string(), - addresses: "10.2.2.2, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), - protocols: vec![6, 17], - ports: "1, 2, 3, 10-20, 30-40".to_string(), - } -} - -fn make_destination() -> EditAclDestination { - EditAclDestination { - name: "destination".to_string(), - addresses: "10.20.30.40, 10.0.0.1/24, 10.0.10.1-10.0.20.1".to_string(), - ports: "1, 2, 3, 10-20, 30-40".to_string(), - protocols: vec![6, 17], - any_address: false, - any_port: false, - any_protocol: false, - } -} - -async fn count_destinations(pool: &PgPool) -> usize { - AclAlias::all_of_kind(pool, AliasKind::Destination) - .await - .unwrap() - .len() -} - -fn edit_rule_data_into_api_response( - data: &EditAclRule, - id: Id, - parent_id: Option, - state: RuleState, -) -> ApiAclRule { - ApiAclRule { - id, - parent_id, - state, - name: data.name.clone(), - all_locations: data.all_locations, - locations: data.locations.clone(), - expires: data.expires, - enabled: data.enabled, - allow_all_users: data.allow_all_users, - deny_all_users: data.deny_all_users, - allow_all_groups: data.allow_all_groups, - deny_all_groups: data.deny_all_groups, - allow_all_network_devices: data.allow_all_network_devices, - deny_all_network_devices: data.deny_all_network_devices, - allowed_users: data.allowed_users.clone(), - denied_users: data.denied_users.clone(), - allowed_groups: data.allowed_groups.clone(), - denied_groups: data.denied_groups.clone(), - allowed_network_devices: data.allowed_network_devices.clone(), - denied_network_devices: data.denied_network_devices.clone(), - addresses: data.addresses.clone(), - aliases: data.aliases.clone(), - destinations: data.destinations.clone(), - ports: data.ports.clone(), - protocols: data.protocols.clone(), - any_address: data.any_address, - any_port: data.any_port, - any_protocol: data.any_protocol, - use_manual_destination_settings: data.use_manual_destination_settings, - } -} - -fn edit_alias_data_into_api_response( - data: EditAclAlias, - id: Id, - parent_id: Option, - state: AliasState, - kind: AliasKind, - rules: Vec, -) -> ApiAclAlias { - ApiAclAlias { - id, - parent_id, - state, - name: data.name, - kind, - addresses: data.addresses, - ports: data.ports, - protocols: data.protocols, - rules, - } -} +use super::*; #[sqlx::test] async fn test_rule_crud(_: PgPoolOptions, options: PgConnectOptions) { @@ -373,103 +199,6 @@ async fn test_rule_enterprise(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response.status(), StatusCode::OK); } -#[sqlx::test] -async fn test_alias_crud(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - let alias = make_alias(); - - // create - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response_alias: ApiAclAlias = response.json().await; - let expected_response = edit_alias_data_into_api_response( - alias, - response_alias.id, - None, - AliasState::Applied, - AliasKind::Component, - Vec::new(), - ); - assert_eq!(response_alias, expected_response); - - // list - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_aliases: Vec = response.json().await; - assert_eq!(response_aliases.len(), 1); - let response_alias = response_aliases[0].clone(); - assert_eq!(response_alias, expected_response); - - // retrieve - let response = client.get("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_alias: ApiAclAlias = response.json().await; - assert_eq!(response_alias, expected_response); - - // update - let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - alias.name = "modified".to_string(); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_alias: ApiAclAlias = response.json().await; - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(response_alias, alias); - - // delete - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_aliases: Vec = response.json().await; - assert_eq!(response_aliases.len(), 0); -} - -#[sqlx::test] -async fn test_alias_enterprise(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - exceed_enterprise_limits(&client).await; - - // unset the license - let license = get_cached_license().clone(); - set_cached_license(None); - - // try to use ACL api - let alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - // GET should be allowed - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::FORBIDDEN); - - // restore valid license and try again - set_cached_license(license); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let response_aliases: Vec = response.json().await; - assert_eq!(response_aliases.len(), 1); - let response = client.get("/api/v1/acl/alias").send().await; - assert_eq!(response.status(), StatusCode::OK); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); -} - #[sqlx::test] async fn test_empty_strings(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1132,356 +861,6 @@ async fn test_multiple_rules_application(_: PgPoolOptions, options: PgConnectOpt assert_eq!(rule.state, RuleState::Applied); } -#[sqlx::test] -async fn test_alias_create_modify_state(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = init_config(None, &pool).await; - let mut client = make_client_v2(pool.clone(), config).await; - authenticate_admin(&mut client).await; - - let alias = make_alias(); - - // assert created alias has correct state - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let dbalias = AclAlias::find_by_id(&pool, 1).await.unwrap().unwrap(); - assert_eq!(dbalias.state, AliasState::Applied); - assert_eq!(dbalias.parent_id, None); - - // test APPLIED alias modification - let alias_before_mods: ApiAclAlias = - client.get("/api/v1/acl/alias/1").send().await.json().await; - let mut alias_modified = alias_before_mods.clone(); - let response = client - .put("/api/v1/acl/alias/1") - .json(&alias_modified) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - let alias_parent: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - let alias_child: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(alias_parent, alias_before_mods); - assert_eq!(alias_parent.state, AliasState::Applied); - alias_modified.id = 2; - alias_modified.state = AliasState::Modified; - alias_modified.parent_id = Some(1); - assert_eq!(alias_child, alias_modified); - assert_eq!(alias_child.state, AliasState::Modified); - assert_eq!(alias_child.parent_id, Some(alias_parent.id)); -} - -#[sqlx::test] -async fn test_alias_delete(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = init_config(None, &pool).await; - let mut client = make_client_v2(pool.clone(), config).await; - authenticate_admin(&mut client).await; - - // create alias - let alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // use alias in a new rule - let mut rule = make_rule(); - rule.aliases = vec![1]; - let response = client.post("/api/v1/acl/rule").json(&rule).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // cannot delete alias if used by a rule - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // remove alias from rule - rule.aliases = Vec::new(); - let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // delete alias - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); - - // create another alias - let mut alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // modify alias - alias.name = "modified".to_string(); - let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - - // delete pending modification - let response = client.delete("/api/v1/acl/alias/3").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - - // modify alias again - alias.name = "modified again".to_string(); - let response = client.put("/api/v1/acl/alias/2").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - - // delete original alias - let response = client.delete("/api/v1/acl/alias/2").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); -} - -#[sqlx::test] -async fn test_destination_delete(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = init_config(None, &pool).await; - let mut client = make_client_v2(pool.clone(), config).await; - authenticate_admin(&mut client).await; - - // create destination - let destination = make_destination(); - let response = client - .post("/api/v1/acl/destination") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - let destination: Value = response.json().await; - let destination_id = destination["id"].as_i64().unwrap(); - assert_eq!(count_destinations(&pool).await, 1); - - // use destination in a new rule - let mut rule = make_rule(); - rule.use_manual_destination_settings = false; - rule.destinations = vec![destination_id]; - let response = client.post("/api/v1/acl/rule").json(&rule).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // cannot delete destination if used by a rule - let response = client - .delete(format!("/api/v1/acl/destination/{destination_id}")) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - assert_eq!(count_destinations(&pool).await, 1); - - // remove destination from rule - rule.use_manual_destination_settings = true; - rule.destinations = Vec::new(); - let response = client.put("/api/v1/acl/rule/1").json(&rule).send().await; - assert_eq!(response.status(), StatusCode::OK); - - // delete alias - let response = client - .delete(format!("/api/v1/acl/destination/{destination_id}")) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(count_destinations(&pool).await, 0); - - // create another destination - let mut destination = make_destination(); - let response = client - .post("/api/v1/acl/destination") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - assert_eq!(count_destinations(&pool).await, 1); - - // modify destination - destination.name = "modified".to_string(); - let response = client - .put("/api/v1/acl/destination/2") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(count_destinations(&pool).await, 2); - - // delete pending modification - let response = client.delete("/api/v1/acl/destination/3").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(count_destinations(&pool).await, 1); - - // modify destination again - destination.name = "modified again".to_string(); - let response = client - .put("/api/v1/acl/destination/2") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(count_destinations(&pool).await, 2); - - // delete original destination - let response = client.delete("/api/v1/acl/destination/2").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(count_destinations(&pool).await, 0); -} - -#[sqlx::test] -async fn test_alias_duplication(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - // each modification of parent alias should remove the child and create a new one - let config = init_config(None, &pool).await; - let mut client = make_client_v2(pool.clone(), config).await; - authenticate_admin(&mut client).await; - - let alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // ensure we don't duplicate already modified / deleted aliass - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - let response = client.delete("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 0); -} - -#[sqlx::test] -async fn test_alias_application(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = init_config(None, &pool).await; - let mut client = make_client_v2(pool.clone(), config).await; - authenticate_admin(&mut client).await; - - // create new alias - let alias = make_alias(); - let response = client.post("/api/v1/acl/alias").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // verify initial status - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - - // use alias in a new rule - let mut rule = make_rule(); - rule.aliases = vec![1]; - let response = client.post("/api/v1/acl/rule").json(&rule).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // verify rule assignment - let mut alias: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - assert_eq!(alias.rules, vec![1]); - - // modify alias - alias.name = "modified".to_string(); - let response = client.put("/api/v1/acl/alias/1").json(&alias).send().await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 2); - - // cannot apply already applied alias - let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [1] })) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // apply modification - let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [2] })) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - - // verify alias was applied - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - assert_eq!(alias.parent_id, None); - assert_eq!(alias.rules, vec![1]); - - // initial alias was deleted - let response = client.get("/api/v1/acl/alias/1").send().await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 1); -} - -#[sqlx::test] -async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let config = init_config(None, &pool).await; - let mut client = make_client_v2(pool.clone(), config).await; - authenticate_admin(&mut client).await; - - let alias_1 = make_alias(); - let alias_2 = make_alias(); - let alias_3 = make_alias(); - - // create new aliass - let response = client.post("/api/v1/acl/alias").json(&alias_1).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response = client.post("/api/v1/acl/alias").json(&alias_2).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - let response = client.post("/api/v1/acl/alias").json(&alias_3).send().await; - assert_eq!(response.status(), StatusCode::CREATED); - - // modify aliases - let mut alias_1: ApiAclAlias = client.get("/api/v1/acl/alias/1").send().await.json().await; - alias_1.name = "modified 1".to_string(); - let response = client - .put("/api/v1/acl/alias/1") - .json(&alias_1) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - let mut alias_2: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - alias_2.name = "modified 2".to_string(); - let response = client - .put("/api/v1/acl/alias/2") - .json(&alias_2) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - let mut alias_3: ApiAclAlias = client.get("/api/v1/acl/alias/3").send().await.json().await; - alias_3.name = "modified 3".to_string(); - let response = client - .put("/api/v1/acl/alias/3") - .json(&alias_3) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 6); - - // apply multiple aliases - let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [4, 6] })) - .send() - .await; - assert_eq!(response.status(), StatusCode::OK); - assert_eq!(AclAlias::all(&pool).await.unwrap().len(), 4); - - // verify alias state - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/2").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/4").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/5").send().await.json().await; - assert_eq!(alias.state, AliasState::Modified); - let alias: ApiAclAlias = client.get("/api/v1/acl/alias/6").send().await.json().await; - assert_eq!(alias.state, AliasState::Applied); -} - #[sqlx::test] async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -1567,107 +946,3 @@ async fn test_acl_count_endpoints(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(counts["applied"], json!(2)); assert_eq!(counts["pending"], json!(1)); } - -#[sqlx::test] -async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgConnectOptions) { - let pool = setup_pool(options).await; - - let (mut client, _) = make_test_client(pool).await; - authenticate_admin(&mut client).await; - - // create destination with empty fields and no any flags - let invalid_destination = json!({ - "name": "invalid destination", - "addresses": "", - "ports": "", - "protocols": [], - "any_address": false, - "any_port": false, - "any_protocol": false - }); - let response = client - .post("/api/v1/acl/destination") - .json(&invalid_destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // try to create destinations with only some destination fields set - let invalid_destination = json!({ - "name": "invalid destination", - "addresses": "", - "ports": "22, 443", - "protocols": [], - "any_address": false, - "any_port": false, - "any_protocol": true - }); - let response = client - .post("/api/v1/acl/destination") - .json(&invalid_destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // create valid destination - let destination = make_destination(); - let response = client - .post("/api/v1/acl/destination") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); - let destination: Value = response.json().await; - let destination_id = destination["id"].as_i64().unwrap(); - - // update destination with empty fields and no any flags - let invalid_update = json!({ - "name": "invalid update", - "addresses": "", - "ports": "", - "protocols": [], - "any_address": false, - "any_port": false, - "any_protocol": false - }); - let response = client - .put(format!("/api/v1/acl/destination/{destination_id}")) - .json(&invalid_update) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // update destination with some destination fields set - let invalid_update = json!({ - "name": "invalid update", - "addresses": "", - "ports": "5432", - "protocols": [], - "any_address": true, - "any_port": false, - "any_protocol": false - }); - let response = client - .put(format!("/api/v1/acl/destination/{destination_id}")) - .json(&invalid_update) - .send() - .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); - - // create valid destination with only "any" flags enabled - let destination = json!({ - "name": "valid destination", - "addresses": "", - "ports": "", - "protocols": [], - "any_address": true, - "any_port": true, - "any_protocol": true - }); - let response = client - .post("/api/v1/acl/destination") - .json(&destination) - .send() - .await; - assert_eq!(response.status(), StatusCode::CREATED); -} From 6663e84b1e89ae26819cc678e703a36a36f4d1c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 10:17:06 +0100 Subject: [PATCH 04/23] add missing destination API tests to mirror aliases --- .../enterprise/handlers/acl/destination.rs | 2 +- .../tests/integration/api/acl/destinations.rs | 378 ++++++++++++++++++ .../tests/integration/api/acl/mod.rs | 25 +- 3 files changed, 403 insertions(+), 2 deletions(-) diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index acefd97c68..1e10580818 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -80,7 +80,7 @@ impl EditAclDestination { /// API representation of [`AclAlias`] for "Destination" (not "Alias Component"). /// All relations represented as arrays of IDs. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, ToSchema)] -pub(crate) struct ApiAclDestination { +pub struct ApiAclDestination { #[serde(default)] pub id: Id, pub parent_id: Option, diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs index 8a1b5f8def..293feb22ec 100644 --- a/crates/defguard_core/tests/integration/api/acl/destinations.rs +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -1,5 +1,184 @@ use super::*; +#[sqlx::test] +async fn test_destination_crud(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + + // create + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response_destination: ApiAclDestination = response.json().await; + let expected_response = edit_destination_data_into_api_response( + destination, + response_destination.id, + None, + AliasState::Applied, + Vec::new(), + ); + assert_eq!(response_destination, expected_response); + + // list + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destinations: Vec = response.json().await; + assert_eq!(response_destinations.len(), 1); + let response_destination = response_destinations[0].clone(); + assert_eq!(response_destination, expected_response); + + // retrieve + let response = client.get("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destination: ApiAclDestination = response.json().await; + assert_eq!(response_destination, expected_response); + + // update + let mut destination: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let response_destination: ApiAclDestination = response.json().await; + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/2").send().await.json().await; + assert_eq!(response_destination, destination); + + // delete + let response = client.delete("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destinations: Vec = response.json().await; + assert_eq!(response_destinations.len(), 0); +} + +#[sqlx::test] +async fn test_destination_enterprise(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + exceed_enterprise_limits(&client).await; + + // unset the license + let license = get_cached_license().clone(); + set_cached_license(None); + + // try to use ACL api + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + // GET should be allowed + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response = client + .delete("/api/v1/acl/destination/1") + .send() + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + + // restore valid license and try again + set_cached_license(license); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let response_destinations: Vec = response.json().await; + assert_eq!(response_destinations.len(), 1); + let response = client.get("/api/v1/acl/destination").send().await; + assert_eq!(response.status(), StatusCode::OK); + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let response = client + .delete("/api/v1/acl/destination/1") + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); +} + +#[sqlx::test] +async fn test_destination_create_modify_state(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + + // assert created destination has correct state + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let dbdestination = + AclAlias::find_by_id_and_kind(&pool, 1, AliasKind::Destination) + .await + .unwrap() + .unwrap(); + assert_eq!(dbdestination.state, AliasState::Applied); + assert_eq!(dbdestination.parent_id, None); + + // test APPLIED destination modification + let destination_before_mods: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + let mut destination_modified = destination_before_mods.clone(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination_modified) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + let destination_parent: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + let destination_child: ApiAclDestination = + client.get("/api/v1/acl/destination/2").send().await.json().await; + assert_eq!(destination_parent, destination_before_mods); + assert_eq!(destination_parent.state, AliasState::Applied); + destination_modified.id = 2; + destination_modified.state = AliasState::Modified; + destination_modified.parent_id = Some(1); + assert_eq!(destination_child, destination_modified); + assert_eq!(destination_child.state, AliasState::Modified); + assert_eq!(destination_child.parent_id, Some(destination_parent.id)); +} + #[sqlx::test] async fn test_destination_delete(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; @@ -90,6 +269,205 @@ async fn test_destination_delete(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(count_destinations(&pool).await, 0); } +#[sqlx::test] +async fn test_destination_duplication(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + // each modification of parent destination should remove the child and create a new one + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // ensure we don't duplicate already modified / deleted destinations + assert_eq!(count_destinations(&pool).await, 1); + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + let response = client.delete("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 0); +} + +#[sqlx::test] +async fn test_destination_application(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + // create new destination + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // verify initial status + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + assert_eq!(destination.state, AliasState::Applied); + + // use destination in a new rule + let mut rule = make_rule(); + rule.use_manual_destination_settings = false; + rule.destinations = vec![1]; + let response = client.post("/api/v1/acl/rule").json(&rule).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // verify rule assignment + let mut destination: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + assert_eq!(destination.rules, vec![1]); + + // modify destination + destination.name = "modified".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 2); + + // cannot apply already applied destination + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // apply modification + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [2] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + + // verify destination was applied + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/2").send().await.json().await; + assert_eq!(destination.state, AliasState::Applied); + assert_eq!(destination.parent_id, None); + assert_eq!(destination.rules, vec![1]); + + // initial destination was deleted + let response = client.get("/api/v1/acl/destination/1").send().await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!(count_destinations(&pool).await, 1); +} + +#[sqlx::test] +async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool.clone(), config).await; + authenticate_admin(&mut client).await; + + let destination_1 = make_destination(); + let destination_2 = make_destination(); + let destination_3 = make_destination(); + + // create new destinations + let response = client + .post("/api/v1/acl/destination") + .json(&destination_1) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client + .post("/api/v1/acl/destination") + .json(&destination_2) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let response = client + .post("/api/v1/acl/destination") + .json(&destination_3) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + // modify destinations + let mut destination_1: ApiAclDestination = + client.get("/api/v1/acl/destination/1").send().await.json().await; + destination_1.name = "modified 1".to_string(); + let response = client + .put("/api/v1/acl/destination/1") + .json(&destination_1) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut destination_2: ApiAclDestination = + client.get("/api/v1/acl/destination/2").send().await.json().await; + destination_2.name = "modified 2".to_string(); + let response = client + .put("/api/v1/acl/destination/2") + .json(&destination_2) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + let mut destination_3: ApiAclDestination = + client.get("/api/v1/acl/destination/3").send().await.json().await; + destination_3.name = "modified 3".to_string(); + let response = client + .put("/api/v1/acl/destination/3") + .json(&destination_3) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 6); + + // apply multiple destinations + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [4, 6] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(count_destinations(&pool).await, 4); + + // verify destination state + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/2").send().await.json().await; + assert_eq!(destination.state, AliasState::Applied); + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/4").send().await.json().await; + assert_eq!(destination.state, AliasState::Applied); + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/5").send().await.json().await; + assert_eq!(destination.state, AliasState::Modified); + let destination: ApiAclDestination = + client.get("/api/v1/acl/destination/6").send().await.json().await; + assert_eq!(destination.state, AliasState::Applied); +} + #[sqlx::test] async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/acl/mod.rs b/crates/defguard_core/tests/integration/api/acl/mod.rs index 118e544158..2a763907ca 100644 --- a/crates/defguard_core/tests/integration/api/acl/mod.rs +++ b/crates/defguard_core/tests/integration/api/acl/mod.rs @@ -16,7 +16,7 @@ use defguard_core::{ handlers::acl::{ ApiAclRule, EditAclRule, alias::{ApiAclAlias, EditAclAlias}, - destination::EditAclDestination, + destination::{ApiAclDestination, EditAclDestination}, }, license::{get_cached_license, set_cached_license}, }, @@ -177,3 +177,26 @@ fn edit_alias_data_into_api_response( rules, } } + +fn edit_destination_data_into_api_response( + data: EditAclDestination, + id: Id, + parent_id: Option, + state: AliasState, + rules: Vec, +) -> ApiAclDestination { + ApiAclDestination { + id, + parent_id, + state, + name: data.name, + kind: AliasKind::Destination, + addresses: data.addresses, + ports: data.ports, + protocols: data.protocols, + rules, + any_address: data.any_address, + any_port: data.any_port, + any_protocol: data.any_protocol, + } +} From ec4e6a4b51e86f01badd79c832531fe71cf79462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 11:14:09 +0100 Subject: [PATCH 05/23] validate aliases in API --- .../src/enterprise/handlers/acl/alias.rs | 15 ++++++++ .../tests/integration/api/acl/aliases.rs | 37 +++++++++++++++++++ 2 files changed, 52 insertions(+) diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index e314b3e0ab..c9458f760e 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -16,6 +16,7 @@ use crate::{ AclAlias, AclAliasDestinationRange, AclAliasInfo, AclError, AliasKind, AliasState, Protocol, acl_delete_related_objects, parse_destination_addresses, }, + error::WebError, handlers::{ApiResponse, ApiResult}, }; @@ -29,6 +30,18 @@ pub struct EditAclAlias { } impl EditAclAlias { + fn validate(&self) -> Result<(), WebError> { + if self.addresses.trim().is_empty() + && self.ports.trim().is_empty() + && self.protocols.is_empty() + { + return Err(WebError::BadRequest( + "Must provide alias addresses, ports, or protocols".to_string(), + )); + } + Ok(()) + } + /// Creates relation objects for a given [`AclAlias`] based on [`AclAliasInfo`] object. pub(crate) async fn create_related_objects( &self, @@ -289,6 +302,7 @@ pub(crate) async fn create_acl_alias( Json(data): Json, ) -> ApiResult { debug!("User {} creating ACL alias {data:?}", session.user.username); + data.validate()?; let alias = ApiAclAlias::create_from_api(&appstate.pool, &data) .await .map_err(|err| { @@ -324,6 +338,7 @@ pub(crate) async fn update_acl_alias( Json(data): Json, ) -> ApiResult { debug!("User {} updating ACL alias {data:?}", session.user.username); + data.validate()?; let alias = ApiAclAlias::update_from_api(&appstate.pool, id, &data) .await .map_err(|err| { diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs index 9763e1d17d..7f6bd6086a 100644 --- a/crates/defguard_core/tests/integration/api/acl/aliases.rs +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -356,3 +356,40 @@ async fn test_multiple_aliases_application(_: PgPoolOptions, options: PgConnectO let alias: ApiAclAlias = client.get("/api/v1/acl/alias/6").send().await.json().await; assert_eq!(alias.state, AliasState::Applied); } + +#[sqlx::test] +async fn test_alias_requires_any_value(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, _) = make_test_client(pool).await; + authenticate_admin(&mut client).await; + + // all fields empty + let mut alias = make_alias(); + alias.addresses = String::new(); + alias.ports = String::new(); + alias.protocols = Vec::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // only addresses set + let mut alias = make_alias(); + alias.ports = String::new(); + alias.protocols = Vec::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // only ports set + let mut alias = make_alias(); + alias.addresses = String::new(); + alias.protocols = Vec::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + // only protocols set + let mut alias = make_alias(); + alias.addresses = String::new(); + alias.ports = String::new(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); +} From 47c27ad76f8c6a2b4285ece39a09bf4361f76013 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 11:14:23 +0100 Subject: [PATCH 06/23] formatting --- .../tests/integration/api/acl/destinations.rs | 155 ++++++++++++------ 1 file changed, 108 insertions(+), 47 deletions(-) diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs index 293feb22ec..51d4b2d55a 100644 --- a/crates/defguard_core/tests/integration/api/acl/destinations.rs +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -41,8 +41,12 @@ async fn test_destination_crud(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(response_destination, expected_response); // update - let mut destination: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let mut destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; destination.name = "modified".to_string(); let response = client .put("/api/v1/acl/destination/1") @@ -51,8 +55,12 @@ async fn test_destination_crud(_: PgPoolOptions, options: PgConnectOptions) { .await; assert_eq!(response.status(), StatusCode::OK); let response_destination: ApiAclDestination = response.json().await; - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/2").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; assert_eq!(response_destination, destination); // delete @@ -94,10 +102,7 @@ async fn test_destination_enterprise(_: PgPoolOptions, options: PgConnectOptions // GET should be allowed let response = client.get("/api/v1/acl/destination").send().await; assert_eq!(response.status(), StatusCode::OK); - let response = client - .delete("/api/v1/acl/destination/1") - .send() - .await; + let response = client.delete("/api/v1/acl/destination/1").send().await; assert_eq!(response.status(), StatusCode::FORBIDDEN); // restore valid license and try again @@ -114,18 +119,19 @@ async fn test_destination_enterprise(_: PgPoolOptions, options: PgConnectOptions assert_eq!(response_destinations.len(), 1); let response = client.get("/api/v1/acl/destination").send().await; assert_eq!(response.status(), StatusCode::OK); - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; let response = client .put("/api/v1/acl/destination/1") .json(&destination) .send() .await; assert_eq!(response.status(), StatusCode::OK); - let response = client - .delete("/api/v1/acl/destination/1") - .send() - .await; + let response = client.delete("/api/v1/acl/destination/1").send().await; assert_eq!(response.status(), StatusCode::OK); } @@ -146,17 +152,20 @@ async fn test_destination_create_modify_state(_: PgPoolOptions, options: PgConne .send() .await; assert_eq!(response.status(), StatusCode::CREATED); - let dbdestination = - AclAlias::find_by_id_and_kind(&pool, 1, AliasKind::Destination) - .await - .unwrap() - .unwrap(); + let dbdestination = AclAlias::find_by_id_and_kind(&pool, 1, AliasKind::Destination) + .await + .unwrap() + .unwrap(); assert_eq!(dbdestination.state, AliasState::Applied); assert_eq!(dbdestination.parent_id, None); // test APPLIED destination modification - let destination_before_mods: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let destination_before_mods: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; let mut destination_modified = destination_before_mods.clone(); let response = client .put("/api/v1/acl/destination/1") @@ -165,10 +174,18 @@ async fn test_destination_create_modify_state(_: PgPoolOptions, options: PgConne .await; assert_eq!(response.status(), StatusCode::OK); assert_eq!(count_destinations(&pool).await, 2); - let destination_parent: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; - let destination_child: ApiAclDestination = - client.get("/api/v1/acl/destination/2").send().await.json().await; + let destination_parent: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; + let destination_child: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; assert_eq!(destination_parent, destination_before_mods); assert_eq!(destination_parent.state, AliasState::Applied); destination_modified.id = 2; @@ -288,8 +305,12 @@ async fn test_destination_duplication(_: PgPoolOptions, options: PgConnectOption // ensure we don't duplicate already modified / deleted destinations assert_eq!(count_destinations(&pool).await, 1); - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; let response = client .put("/api/v1/acl/destination/1") .json(&destination) @@ -327,8 +348,12 @@ async fn test_destination_application(_: PgPoolOptions, options: PgConnectOption assert_eq!(response.status(), StatusCode::CREATED); // verify initial status - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; assert_eq!(destination.state, AliasState::Applied); // use destination in a new rule @@ -339,8 +364,12 @@ async fn test_destination_application(_: PgPoolOptions, options: PgConnectOption assert_eq!(response.status(), StatusCode::CREATED); // verify rule assignment - let mut destination: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let mut destination: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; assert_eq!(destination.rules, vec![1]); // modify destination @@ -370,8 +399,12 @@ async fn test_destination_application(_: PgPoolOptions, options: PgConnectOption assert_eq!(response.status(), StatusCode::OK); // verify destination was applied - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/2").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; assert_eq!(destination.state, AliasState::Applied); assert_eq!(destination.parent_id, None); assert_eq!(destination.rules, vec![1]); @@ -415,8 +448,12 @@ async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgCon assert_eq!(response.status(), StatusCode::CREATED); // modify destinations - let mut destination_1: ApiAclDestination = - client.get("/api/v1/acl/destination/1").send().await.json().await; + let mut destination_1: ApiAclDestination = client + .get("/api/v1/acl/destination/1") + .send() + .await + .json() + .await; destination_1.name = "modified 1".to_string(); let response = client .put("/api/v1/acl/destination/1") @@ -424,8 +461,12 @@ async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgCon .send() .await; assert_eq!(response.status(), StatusCode::OK); - let mut destination_2: ApiAclDestination = - client.get("/api/v1/acl/destination/2").send().await.json().await; + let mut destination_2: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; destination_2.name = "modified 2".to_string(); let response = client .put("/api/v1/acl/destination/2") @@ -433,8 +474,12 @@ async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgCon .send() .await; assert_eq!(response.status(), StatusCode::OK); - let mut destination_3: ApiAclDestination = - client.get("/api/v1/acl/destination/3").send().await.json().await; + let mut destination_3: ApiAclDestination = client + .get("/api/v1/acl/destination/3") + .send() + .await + .json() + .await; destination_3.name = "modified 3".to_string(); let response = client .put("/api/v1/acl/destination/3") @@ -454,17 +499,33 @@ async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgCon assert_eq!(count_destinations(&pool).await, 4); // verify destination state - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/2").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/2") + .send() + .await + .json() + .await; assert_eq!(destination.state, AliasState::Applied); - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/4").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/4") + .send() + .await + .json() + .await; assert_eq!(destination.state, AliasState::Applied); - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/5").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/5") + .send() + .await + .json() + .await; assert_eq!(destination.state, AliasState::Modified); - let destination: ApiAclDestination = - client.get("/api/v1/acl/destination/6").send().await.json().await; + let destination: ApiAclDestination = client + .get("/api/v1/acl/destination/6") + .send() + .await + .json() + .await; assert_eq!(destination.state, AliasState::Applied); } From bac1a0f953a7b6b292ca735cabff05774ee7963c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 21:43:20 +0100 Subject: [PATCH 07/23] add dedicated destination apply endpoint --- .../src/enterprise/db/models/acl.rs | 20 +++++++--- .../src/enterprise/handlers/acl.rs | 39 +------------------ .../src/enterprise/handlers/acl/alias.rs | 38 ++++++++++++++++++ .../enterprise/handlers/acl/destination.rs | 39 +++++++++++++++++++ crates/defguard_core/src/handlers/mod.rs | 4 ++ crates/defguard_core/src/lib.rs | 15 +++---- crates/defguard_core/src/openapi.rs | 12 +++--- .../tests/integration/api/acl/aliases.rs | 24 ++++++++++++ .../tests/integration/api/acl/destinations.rs | 32 ++++++++++++--- 9 files changed, 162 insertions(+), 61 deletions(-) diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index 5f0beba955..9a871262f1 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -57,6 +57,8 @@ pub enum AclError { RuleAlreadyAppliedError(Id), #[error("AliasAlreadyAppliedError: {0}")] AliasAlreadyAppliedError(Id), + #[error("AliasKindMismatchError: {0} is not {1:?}")] + AliasKindMismatchError(Id, AliasKind), #[error("AliasUsedByRulesError: {0}")] AliasUsedByRulesError(Id), #[error(transparent)] @@ -1560,13 +1562,21 @@ impl AclAlias { Ok(()) } - /// Applies pending changes for all specified aliases + /// Applies pending changes for all specified aliases of a given kind /// /// # Errors /// /// - `AclError::AliasNotFoundError` - pub(crate) async fn apply_aliases(aliases: &[Id], appstate: &AppState) -> Result<(), AclError> { - debug!("Applying {} ACL aliases: {aliases:?}", aliases.len()); + pub(crate) async fn apply_by_kind( + aliases: &[Id], + kind: AliasKind, + appstate: &AppState, + ) -> Result<(), AclError> { + debug!( + "Applying {} ACL aliases of kind {:?}: {aliases:?}", + aliases.len(), + kind + ); let mut transaction = appstate.pool.begin().await?; // prepare variable for collecting affected rules @@ -1574,9 +1584,9 @@ impl AclAlias { let mut affected_rules = Vec::new(); for id in aliases { - let alias = AclAlias::find_by_id(&mut *transaction, *id) + let alias = AclAlias::find_by_id_and_kind(&mut *transaction, *id, kind.clone()) .await? - .ok_or_else(|| AclError::AliasNotFoundError(*id))?; + .ok_or_else(|| AclError::AliasKindMismatchError(*id, kind.clone()))?; // run `apply` before fetching relations, since they'll get updated alias.clone().apply(&mut transaction).await?; diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index 3e92e3c26f..41330cb1a3 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -16,7 +16,7 @@ use super::LicenseInfo; use crate::{ appstate::AppState, auth::{AdminRole, SessionInfo}, - enterprise::db::models::acl::{AclAlias, AclRule, AclRuleInfo, Protocol, RuleState}, + enterprise::db::models::acl::{AclRule, AclRuleInfo, Protocol, RuleState}, error::WebError, handlers::{ApiResponse, ApiResult}, }; @@ -204,10 +204,6 @@ pub(crate) struct ApplyAclRulesData { rules: Vec, } -#[derive(Debug, Deserialize, ToSchema)] -pub(crate) struct ApplyAclAliasesData { - aliases: Vec, -} #[derive(Debug, Serialize, ToSchema, sqlx::FromRow)] pub struct AclStateCount { @@ -443,36 +439,3 @@ pub(crate) async fn apply_acl_rules( ); Ok(ApiResponse::default()) } - -/// Apply ACL aliases. -#[utoipa::path( - put, - path = "/api/v1/acl/alias/apply", - request_body = ApplyAclAliasesData, - responses( - (status = OK, description = "ACL alias"), - ) -)] -pub(crate) async fn apply_acl_aliases( - _license: LicenseInfo, - _admin: AdminRole, - State(appstate): State, - session: SessionInfo, - Json(data): Json, -) -> ApiResult { - debug!( - "User {} applying ACL aliases: {:?}", - session.user.username, data.aliases - ); - AclAlias::apply_aliases(&data.aliases, &appstate) - .await - .map_err(|err| { - error!("Error applying ACL aliases {data:?}: {err}"); - err - })?; - info!( - "User {} applied ACL aliases: {:?}", - session.user.username, data.aliases - ); - Ok(ApiResponse::default()) -} diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index c9458f760e..4ec060c5cb 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -82,6 +82,11 @@ pub struct ApiAclAlias { pub rules: Vec, } +#[derive(Debug, Deserialize, ToSchema)] +pub(crate) struct ApplyAclAliasesData { + aliases: Vec, +} + impl ApiAclAlias { /// Creates new [`AclAlias`] with all related objects based on [`AclAliasInfo`]. pub(crate) async fn create_from_api( @@ -377,3 +382,36 @@ pub(crate) async fn delete_acl_alias( info!("User {} deleted ACL alias {id}", session.user.username); Ok(ApiResponse::default()) } + +/// Apply ACL aliases. +#[utoipa::path( + put, + path = "/api/v1/acl/alias/apply", + request_body = ApplyAclAliasesData, + responses( + (status = OK, description = "ACL alias"), + ) +)] +pub(crate) async fn apply_acl_aliases( + _license: LicenseInfo, + _admin: AdminRole, + State(appstate): State, + session: SessionInfo, + Json(data): Json, +) -> ApiResult { + debug!( + "User {} applying ACL aliases: {:?}", + session.user.username, data.aliases + ); + AclAlias::apply_by_kind(&data.aliases, AliasKind::Component, &appstate) + .await + .map_err(|err| { + error!("Error applying ACL aliases {data:?}: {err}"); + err + })?; + info!( + "User {} applied ACL aliases: {:?}", + session.user.username, data.aliases + ); + Ok(ApiResponse::default()) +} diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index 1e10580818..fdd45b8063 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -96,6 +96,11 @@ pub struct ApiAclDestination { pub any_protocol: bool, } +#[derive(Debug, Deserialize, ToSchema)] +pub(crate) struct ApplyAclDestinationsData { + destinations: Vec, +} + impl ApiAclDestination { /// Creates new [`AclAlias`] with all related objects based on [`AclAliasInfo`]. pub(crate) async fn create_from_api( @@ -412,3 +417,37 @@ pub(crate) async fn delete_acl_destination( ); Ok(ApiResponse::default()) } + +/// Apply ACL destinations. +#[utoipa::path( + put, + path = "/api/v1/acl/destination/apply", + request_body = ApplyAclDestinationsData, + responses( + (status = OK, description = "ACL destination"), + ) +)] +pub(crate) async fn apply_acl_destinations( + _license: LicenseInfo, + _admin: AdminRole, + State(appstate): State, + session: SessionInfo, + Json(data): Json, +) -> ApiResult { + debug!( + "User {} applying ACL destinations: {:?}", + session.user.username, data.destinations + ); + + AclAlias::apply_by_kind(&data.destinations, AliasKind::Destination, &appstate) + .await + .map_err(|err| { + error!("Error applying ACL destinations {data:?}: {err}"); + err + })?; + info!( + "User {} applied ACL destinations: {:?}", + session.user.username, data.destinations + ); + Ok(ApiResponse::default()) +} diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index c82707d69d..245297aa32 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -172,6 +172,10 @@ impl From for ApiResponse { json!({"msg": format!("Alias {id} already applied")}), StatusCode::BAD_REQUEST, ), + AclError::AliasKindMismatchError(id, kind) => ApiResponse::new( + json!({"msg": format!("Alias {id} is not {kind:?}")}), + StatusCode::BAD_REQUEST, + ), AclError::AliasUsedByRulesError(id) => ApiResponse::new( json!({"msg": format!("Alias {id} is used by some existing ACL rules")}), StatusCode::BAD_REQUEST, diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index 165cc0552c..24a136650e 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -81,14 +81,14 @@ use crate::{ handlers::{ acl::{ alias::{ - count_acl_aliases, create_acl_alias, delete_acl_alias, get_acl_alias, - list_acl_aliases, update_acl_alias, + apply_acl_aliases, count_acl_aliases, create_acl_alias, delete_acl_alias, + get_acl_alias, list_acl_aliases, update_acl_alias, }, - apply_acl_aliases, apply_acl_rules, count_acl_rules, create_acl_rule, - delete_acl_rule, + apply_acl_rules, count_acl_rules, create_acl_rule, delete_acl_rule, destination::{ - count_acl_destinations, create_acl_destination, delete_acl_destination, - get_acl_destination, list_acl_destinations, update_acl_destination, + apply_acl_destinations, count_acl_destinations, create_acl_destination, + delete_acl_destination, get_acl_destination, list_acl_destinations, + update_acl_destination, }, get_acl_rule, list_acl_rules, update_acl_rule, }, @@ -482,7 +482,8 @@ pub fn build_webapp( get(get_acl_destination) .put(update_acl_destination) .delete(delete_acl_destination), - ), + ) + .route("/destination/apply", put(apply_acl_destinations)), ); let webapp = webapp.nest( diff --git a/crates/defguard_core/src/openapi.rs b/crates/defguard_core/src/openapi.rs index f38234204e..bd2b5eabf3 100644 --- a/crates/defguard_core/src/openapi.rs +++ b/crates/defguard_core/src/openapi.rs @@ -1,13 +1,13 @@ use defguard_common::{ db::models::{ - Device, device::{AddDevice, ModifyDevice, UserDevice}, + Device, }, types::user_info::UserInfo, }; use utoipa::{ - Modify, OpenApi, openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, + Modify, OpenApi, }; use super::{ @@ -17,12 +17,13 @@ use super::{ }, error::WebError, handlers::{ - ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, - SESSION_COOKIE_NAME, StartEnrollmentRequest, Username, auth, + auth, group::{self, BulkAssignToGroupsRequest, Groups}, user::{self, UserDetails}, wireguard as device, wireguard as network, wireguard::AddDeviceResult, + ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, + StartEnrollmentRequest, Username, SESSION_COOKIE_NAME, }, }; @@ -97,7 +98,7 @@ use super::{ acl::alias::get_acl_alias, acl::alias::update_acl_alias, acl::alias::delete_acl_alias, - acl::apply_acl_aliases, + acl::alias::apply_acl_aliases, // /acl/destination acl::destination::list_acl_destinations, acl::destination::count_acl_destinations, @@ -105,6 +106,7 @@ use super::{ acl::destination::get_acl_destination, acl::destination::update_acl_destination, acl::destination::delete_acl_destination, + acl::destination::apply_acl_destinations, ), components( schemas( diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs index 7f6bd6086a..6eb9499af7 100644 --- a/crates/defguard_core/tests/integration/api/acl/aliases.rs +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -393,3 +393,27 @@ async fn test_alias_requires_any_value(_: PgPoolOptions, options: PgConnectOptio let response = client.post("/api/v1/acl/alias").json(&alias).send().await; assert_eq!(response.status(), StatusCode::CREATED); } + +#[sqlx::test] +async fn test_alias_apply_rejects_destination(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool, config).await; + authenticate_admin(&mut client).await; + + let destination = make_destination(); + let response = client + .post("/api/v1/acl/destination") + .json(&destination) + .send() + .await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = client + .put("/api/v1/acl/alias/apply") + .json(&json!({ "aliases": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs index 51d4b2d55a..77789a8341 100644 --- a/crates/defguard_core/tests/integration/api/acl/destinations.rs +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -384,16 +384,16 @@ async fn test_destination_application(_: PgPoolOptions, options: PgConnectOption // cannot apply already applied destination let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [1] })) + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [1] })) .send() .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); // apply modification let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [2] })) + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [2] })) .send() .await; assert_eq!(response.status(), StatusCode::OK); @@ -491,8 +491,8 @@ async fn test_multiple_destinations_application(_: PgPoolOptions, options: PgCon // apply multiple destinations let response = client - .put("/api/v1/acl/alias/apply") - .json(&json!({ "aliases": [4, 6] })) + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [4, 6] })) .send() .await; assert_eq!(response.status(), StatusCode::OK); @@ -632,3 +632,23 @@ async fn test_destination_requires_any_or_values(_: PgPoolOptions, options: PgCo .await; assert_eq!(response.status(), StatusCode::CREATED); } + +#[sqlx::test] +async fn test_destination_apply_rejects_alias(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let config = init_config(None, &pool).await; + let mut client = make_client_v2(pool, config).await; + authenticate_admin(&mut client).await; + + let alias = make_alias(); + let response = client.post("/api/v1/acl/alias").json(&alias).send().await; + assert_eq!(response.status(), StatusCode::CREATED); + + let response = client + .put("/api/v1/acl/destination/apply") + .json(&json!({ "destinations": [1] })) + .send() + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} From 4fa6118f8ff81d061da70cb1d430caf56731834d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 21:46:19 +0100 Subject: [PATCH 08/23] update frontend API call --- web/src/shared/api/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/shared/api/api.ts b/web/src/shared/api/api.ts index e475a051a0..cb599b655c 100644 --- a/web/src/shared/api/api.ts +++ b/web/src/shared/api/api.ts @@ -476,8 +476,8 @@ const api = { deleteDestination: (destinationId: number | string) => client.delete(`/acl/destination/${destinationId}`), applyDestinations: (destinations: number[]) => - client.put(`/acl/alias/apply`, { - aliases: destinations, + client.put(`/acl/destination/apply`, { + destinations, }), }, alias: { From 467158928b8201b6b1f62ce90cd096270e21b869 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 5 Mar 2026 21:59:15 +0100 Subject: [PATCH 09/23] add dedicated Destination errors --- .../src/enterprise/db/models/acl.rs | 38 ++++++++++++++----- .../src/enterprise/handlers/acl.rs | 1 - .../src/enterprise/handlers/acl/alias.rs | 2 +- .../enterprise/handlers/acl/destination.rs | 2 +- crates/defguard_core/src/handlers/mod.rs | 14 ++++++- crates/defguard_core/src/openapi.rs | 9 ++--- .../tests/integration/api/acl/aliases.rs | 2 +- .../tests/integration/api/acl/destinations.rs | 2 +- 8 files changed, 49 insertions(+), 21 deletions(-) diff --git a/crates/defguard_core/src/enterprise/db/models/acl.rs b/crates/defguard_core/src/enterprise/db/models/acl.rs index 9a871262f1..760ad133c3 100644 --- a/crates/defguard_core/src/enterprise/db/models/acl.rs +++ b/crates/defguard_core/src/enterprise/db/models/acl.rs @@ -53,14 +53,18 @@ pub enum AclError { RuleNotFoundError(Id), #[error("AliasNotFoundError: {0}")] AliasNotFoundError(Id), + #[error("DestinationNotFoundError: {0}")] + DestinationNotFoundError(Id), #[error("RuleAlreadyAppliedError: {0}")] RuleAlreadyAppliedError(Id), #[error("AliasAlreadyAppliedError: {0}")] AliasAlreadyAppliedError(Id), - #[error("AliasKindMismatchError: {0} is not {1:?}")] - AliasKindMismatchError(Id, AliasKind), + #[error("DestinationAlreadyAppliedError: {0}")] + DestinationAlreadyAppliedError(Id), #[error("AliasUsedByRulesError: {0}")] AliasUsedByRulesError(Id), + #[error("DestinationUsedByRulesError: {0}")] + DestinationUsedByRulesError(Id), #[error(transparent)] FirewallError(#[from] FirewallError), #[error("InvalidIpRangeError: {0}")] @@ -1522,16 +1526,23 @@ impl AclAlias { /// 2. The alias itself is deleted from the database /// /// Since these aliases were not yet applied, we can safely remove them. - pub(crate) async fn delete_from_api(pool: &PgPool, id: Id) -> Result<(), AclError> { - debug!("Deleting alias {id}"); + pub(crate) async fn delete_by_kind( + pool: &PgPool, + id: Id, + kind: AliasKind, + ) -> Result<(), AclError> { + debug!("Deleting alias {id} of kind {kind:?}"); let mut transaction = pool.begin().await?; // find the existing alias - let existing_alias = AclAlias::find_by_id(&mut *transaction, id) + let existing_alias = AclAlias::find_by_id_and_kind(&mut *transaction, id, kind.clone()) .await? .ok_or_else(|| { error!("Deletion of nonexistent alias ({id}) failed"); - AclError::AliasNotFoundError(id) + match kind { + AliasKind::Component => AclError::AliasNotFoundError(id), + AliasKind::Destination => AclError::DestinationNotFoundError(id), + } })?; // check if any rules are using this alias @@ -1540,7 +1551,10 @@ impl AclAlias { error!( "Deletion of alias ({id}) failed. Alias is currently used by following ACL rules: {rules:?}" ); - return Err(AclError::AliasUsedByRulesError(id)); + return Err(match kind { + AliasKind::Component => AclError::AliasUsedByRulesError(id), + AliasKind::Destination => AclError::DestinationUsedByRulesError(id), + }); } // delete all modifications of this alias if any exist @@ -1586,7 +1600,10 @@ impl AclAlias { for id in aliases { let alias = AclAlias::find_by_id_and_kind(&mut *transaction, *id, kind.clone()) .await? - .ok_or_else(|| AclError::AliasKindMismatchError(*id, kind.clone()))?; + .ok_or_else(|| match kind { + AliasKind::Component => AclError::AliasNotFoundError(*id), + AliasKind::Destination => AclError::DestinationNotFoundError(*id), + })?; // run `apply` before fetching relations, since they'll get updated alias.clone().apply(&mut transaction).await?; @@ -1851,7 +1868,10 @@ impl AclAlias { } AliasState::Applied => { error!("ACL alias {alias_id} already applied"); - return Err(AclError::AliasAlreadyAppliedError(self.id)); + return Err(match self.kind { + AliasKind::Component => AclError::AliasAlreadyAppliedError(self.id), + AliasKind::Destination => AclError::DestinationAlreadyAppliedError(self.id), + }); } } diff --git a/crates/defguard_core/src/enterprise/handlers/acl.rs b/crates/defguard_core/src/enterprise/handlers/acl.rs index 41330cb1a3..3230a72b07 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl.rs @@ -204,7 +204,6 @@ pub(crate) struct ApplyAclRulesData { rules: Vec, } - #[derive(Debug, Serialize, ToSchema, sqlx::FromRow)] pub struct AclStateCount { pub applied: i64, diff --git a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs index 4ec060c5cb..d6c3edc0fa 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/alias.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/alias.rs @@ -373,7 +373,7 @@ pub(crate) async fn delete_acl_alias( Path(id): Path, ) -> ApiResult { debug!("User {} deleting ACL alias {id}", session.user.username); - AclAlias::delete_from_api(&appstate.pool, id) + AclAlias::delete_by_kind(&appstate.pool, id, AliasKind::Component) .await .map_err(|err| { error!("Error deleting ACL alias {id}: {err}"); diff --git a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs index fdd45b8063..9745c9ac5e 100644 --- a/crates/defguard_core/src/enterprise/handlers/acl/destination.rs +++ b/crates/defguard_core/src/enterprise/handlers/acl/destination.rs @@ -405,7 +405,7 @@ pub(crate) async fn delete_acl_destination( "User {} deleting ACL destination {id}", session.user.username ); - AclAlias::delete_from_api(&appstate.pool, id) + AclAlias::delete_by_kind(&appstate.pool, id, AliasKind::Destination) .await .map_err(|err| { error!("Error deleting ACL destination {id}: {err}"); diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index 245297aa32..5d6c135ebb 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -168,18 +168,28 @@ impl From for ApiResponse { json!({"msg": format!("Alias {id} not found")}), StatusCode::NOT_FOUND, ), + AclError::DestinationNotFoundError(id) => ApiResponse::new( + json!({"msg": format!("Destination {id} not found")}), + StatusCode::NOT_FOUND, + ), AclError::AliasAlreadyAppliedError(id) => ApiResponse::new( json!({"msg": format!("Alias {id} already applied")}), StatusCode::BAD_REQUEST, ), - AclError::AliasKindMismatchError(id, kind) => ApiResponse::new( - json!({"msg": format!("Alias {id} is not {kind:?}")}), + AclError::DestinationAlreadyAppliedError(id) => ApiResponse::new( + json!({"msg": format!("Destination {id} already applied")}), StatusCode::BAD_REQUEST, ), AclError::AliasUsedByRulesError(id) => ApiResponse::new( json!({"msg": format!("Alias {id} is used by some existing ACL rules")}), StatusCode::BAD_REQUEST, ), + AclError::DestinationUsedByRulesError(id) => ApiResponse::new( + json!({ + "msg": format!("Destination {id} is used by some existing ACL rules") + }), + StatusCode::BAD_REQUEST, + ), AclError::DbError(_) | AclError::FirewallError(_) => { error!("{err}"); ApiResponse::new( diff --git a/crates/defguard_core/src/openapi.rs b/crates/defguard_core/src/openapi.rs index bd2b5eabf3..6017ec6399 100644 --- a/crates/defguard_core/src/openapi.rs +++ b/crates/defguard_core/src/openapi.rs @@ -1,13 +1,13 @@ use defguard_common::{ db::models::{ - device::{AddDevice, ModifyDevice, UserDevice}, Device, + device::{AddDevice, ModifyDevice, UserDevice}, }, types::user_info::UserInfo, }; use utoipa::{ - openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, Modify, OpenApi, + openapi::security::{ApiKey, ApiKeyValue, HttpAuthScheme, HttpBuilder, SecurityScheme}, }; use super::{ @@ -17,13 +17,12 @@ use super::{ }, error::WebError, handlers::{ - auth, + ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, + SESSION_COOKIE_NAME, StartEnrollmentRequest, Username, auth, group::{self, BulkAssignToGroupsRequest, Groups}, user::{self, UserDetails}, wireguard as device, wireguard as network, wireguard::AddDeviceResult, - ApiResponse, EditGroupInfo, GroupInfo, PasswordChange, PasswordChangeSelf, - StartEnrollmentRequest, Username, SESSION_COOKIE_NAME, }, }; diff --git a/crates/defguard_core/tests/integration/api/acl/aliases.rs b/crates/defguard_core/tests/integration/api/acl/aliases.rs index 6eb9499af7..0b80304bdf 100644 --- a/crates/defguard_core/tests/integration/api/acl/aliases.rs +++ b/crates/defguard_core/tests/integration/api/acl/aliases.rs @@ -415,5 +415,5 @@ async fn test_alias_apply_rejects_destination(_: PgPoolOptions, options: PgConne .json(&json!({ "aliases": [1] })) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } diff --git a/crates/defguard_core/tests/integration/api/acl/destinations.rs b/crates/defguard_core/tests/integration/api/acl/destinations.rs index 77789a8341..c653c35ca1 100644 --- a/crates/defguard_core/tests/integration/api/acl/destinations.rs +++ b/crates/defguard_core/tests/integration/api/acl/destinations.rs @@ -650,5 +650,5 @@ async fn test_destination_apply_rejects_alias(_: PgPoolOptions, options: PgConne .json(&json!({ "destinations": [1] })) .send() .await; - assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!(response.status(), StatusCode::NOT_FOUND); } From c49abb633ceb24ebae6d1b62fd57ef4c583c4fe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 6 Mar 2026 10:00:38 +0100 Subject: [PATCH 10/23] add a deletion blocked modal --- .../DeletionBlockedModal.tsx | 42 +++++++++++++++++++ .../DeletionBlockedModal/style.scss | 27 ++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx create mode 100644 web/src/pages/Acl/components/DeletionBlockedModal/style.scss diff --git a/web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx b/web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx new file mode 100644 index 0000000000..3dc0d24548 --- /dev/null +++ b/web/src/pages/Acl/components/DeletionBlockedModal/DeletionBlockedModal.tsx @@ -0,0 +1,42 @@ +import { Modal } from '../../../../shared/defguard-ui/components/Modal/Modal'; +import { ModalControls } from '../../../../shared/defguard-ui/components/ModalControls/ModalControls'; +import './style.scss'; + +type Props = { + isOpen: boolean; + title: string; + description: string; + rules: string[]; + onClose: () => void; +}; + +export const DeletionBlockedModal = ({ + isOpen, + title, + description, + rules, + onClose, +}: Props) => ( + +
+

{description}

+
    + {rules.map((rule) => ( +
  • {rule}
  • + ))} +
+ +
+
+); diff --git a/web/src/pages/Acl/components/DeletionBlockedModal/style.scss b/web/src/pages/Acl/components/DeletionBlockedModal/style.scss new file mode 100644 index 0000000000..24ed1f200b --- /dev/null +++ b/web/src/pages/Acl/components/DeletionBlockedModal/style.scss @@ -0,0 +1,27 @@ +.deletion-blocked-modal-content { + display: flex; + flex-direction: column; +} + +.deletion-blocked-modal-description { + margin: 0 0 var(--spacing-lg) 0; + font: var(--t-body-sm-400); + color: var(--fg-default); +} + +.deletion-blocked-modal-list { + display: flex; + flex-flow: column; + row-gap: var(--spacing-sm); + margin: 0; + padding: 0 0 0 var(--spacing-lg); + list-style: disc; + overflow: hidden auto; +} + +.deletion-blocked-modal-list li { + margin: 0; + padding: 0; + font: var(--t-body-sm-400); + color: var(--fg-default); +} From 5a31df20792619519182603e975dd656c6b5d5d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 6 Mar 2026 10:03:41 +0100 Subject: [PATCH 11/23] display delete blocked modal --- web/src/pages/AliasesPage/AliasTable.tsx | 56 +++++++++++++++++-- .../components/DestinationsTable.tsx | 38 ++++++++++++- 2 files changed, 88 insertions(+), 6 deletions(-) diff --git a/web/src/pages/AliasesPage/AliasTable.tsx b/web/src/pages/AliasesPage/AliasTable.tsx index 270bf9cc06..d1ea5e22f6 100644 --- a/web/src/pages/AliasesPage/AliasTable.tsx +++ b/web/src/pages/AliasesPage/AliasTable.tsx @@ -6,7 +6,7 @@ import { getSortedRowModel, useReactTable, } from '@tanstack/react-table'; -import { useMemo } from 'react'; +import { useMemo, useState } from 'react'; import { m } from '../../paraglide/messages'; import api from '../../shared/api/api'; import { type AclAlias, AclProtocolName } from '../../shared/api/types'; @@ -19,6 +19,8 @@ import { TableCell } from '../../shared/defguard-ui/components/table/TableCell/T import { isPresent } from '../../shared/defguard-ui/utils/isPresent'; import { getLicenseInfoQueryOptions } from '../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../shared/utils/license'; +import { resourceById } from '../../shared/utils/resourceById'; +import { DeletionBlockedModal } from '../Acl/components/DeletionBlockedModal/DeletionBlockedModal'; type RowData = AclAlias; @@ -35,12 +37,25 @@ export const AliasTable = ({ data: rowData }: Props) => { getLicenseInfoQueryOptions, ); - const { data: rules } = useQuery({ + const { + data: rules, + isLoading: rulesLoading, + isFetching: rulesFetching, + } = useQuery({ queryFn: api.acl.rule.getRules, queryKey: ['acl', 'rule'], select: (resp) => resp.data, }); + const rulesReady = !rulesLoading && !rulesFetching && isPresent(rules); + const rulesById = useMemo(() => resourceById(rules), [rules]); + + const [blockedModal, setBlockedModal] = useState<{ + title: string; + description: string; + rules: string[]; + } | null>(null); + const { mutate: deleteAlias } = useMutation({ mutationFn: api.acl.alias.deleteAlias, meta: { @@ -145,9 +160,22 @@ export const AliasTable = ({ data: rowData }: Props) => { text: m.controls_delete(), icon: 'delete', variant: 'danger', + disabled: !rulesReady, onClick: () => { if (licenseInfo === undefined) return; licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + if (row.rules.length > 0) { + const ruleNames = row.rules.map( + (ruleId) => rulesById?.[ruleId]?.name ?? `Rule ${ruleId}`, + ); + setBlockedModal({ + title: 'Deletion blocked', + description: + 'This alias is currently in use by the following rule(s) and cannot be deleted. To proceed, remove it from these rules first:', + rules: ruleNames, + }); + return; + } deleteAlias(row.id); }); }, @@ -179,7 +207,16 @@ export const AliasTable = ({ data: rowData }: Props) => { }, }), ], - [rules, applyAliases, deleteAlias, navigate, isLicenseFetching, licenseInfo], + [ + rules, + rulesById, + rulesReady, + applyAliases, + deleteAlias, + navigate, + isLicenseFetching, + licenseInfo, + ], ); const table = useReactTable({ @@ -201,5 +238,16 @@ export const AliasTable = ({ data: rowData }: Props) => { getSortedRowModel: getSortedRowModel(), }); - return ; + return ( + <> + + setBlockedModal(null)} + /> + + ); }; diff --git a/web/src/pages/DestinationsPage/components/DestinationsTable.tsx b/web/src/pages/DestinationsPage/components/DestinationsTable.tsx index 0cc5cf3e79..baa4830f0b 100644 --- a/web/src/pages/DestinationsPage/components/DestinationsTable.tsx +++ b/web/src/pages/DestinationsPage/components/DestinationsTable.tsx @@ -20,9 +20,11 @@ import { tableEditColumnSize } from '../../../shared/defguard-ui/components/tabl import { TableBody } from '../../../shared/defguard-ui/components/table/TableBody/TableBody'; import { TableCell } from '../../../shared/defguard-ui/components/table/TableCell/TableCell'; import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/TableTop'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { getLicenseInfoQueryOptions, getRulesQueryOptions } from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { resourceById } from '../../../shared/utils/resourceById'; +import { DeletionBlockedModal } from '../../Acl/components/DeletionBlockedModal/DeletionBlockedModal'; type Props = { title: string; @@ -41,10 +43,20 @@ export const DestinationsTable = ({ title, search, }: Props) => { - const { data: rules } = useQuery(getRulesQueryOptions); + const { + data: rules, + isLoading: rulesLoading, + isFetching: rulesFetching, + } = useQuery(getRulesQueryOptions); + const rulesReady = !rulesLoading && !rulesFetching && isPresent(rules); const rulesById = useMemo(() => resourceById(rules), [rules]); const [searchValue, setSearchValue] = useState(''); const navigate = useNavigate(); + const [blockedModal, setBlockedModal] = useState<{ + title: string; + description: string; + rules: string[]; + } | null>(null); const { data: licenseInfo, isFetching: licenseFetching } = useQuery( getLicenseInfoQueryOptions, @@ -157,9 +169,24 @@ export const DestinationsTable = ({ text: m.controls_delete(), icon: 'delete', variant: 'danger', + disabled: !rulesReady, onClick: () => { if (licenseInfo === undefined) return; licenseActionCheck(canUseBusinessFeature(licenseInfo), () => { + if (row.rules.length > 0) { + const ruleNames = rulesById + ? row.rules.map( + (ruleId) => rulesById[ruleId]?.name ?? `Rule ${ruleId}`, + ) + : row.rules.map((ruleId) => `Rule ${ruleId}`); + setBlockedModal({ + title: 'Deletion blocked', + description: + 'This destination is currently in use by the following rule(s) and cannot be deleted. To proceed, remove it from these rules first:', + rules: ruleNames, + }); + return; + } deleteDestination(row.id); }); }, @@ -179,7 +206,7 @@ export const DestinationsTable = ({ }, }), ], - [navigate, deleteDestination, rulesById, licenseFetching, licenseInfo], + [navigate, deleteDestination, rulesById, rulesReady, licenseFetching, licenseInfo], ); const transformedData = useMemo(() => { @@ -220,6 +247,13 @@ export const DestinationsTable = ({ subtitle={m.search_empty_common_subtitle()} /> )} + setBlockedModal(null)} + /> ); }; From 06bc27309998de795a92dd98ad2b0e3a6c2ab6a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Fri, 6 Mar 2026 10:15:38 +0100 Subject: [PATCH 12/23] add skeleton when loading rules --- .../AliasesPage/tabs/AliasesDeployedTab.tsx | 12 ++++++- .../AliasesPage/tabs/AliasesPendingTab.tsx | 14 ++++++-- .../DestinationDeployedTab.tsx | 26 +++++++++++---- .../DestinationPendingTab.tsx | 32 +++++++++++++------ 4 files changed, 65 insertions(+), 19 deletions(-) diff --git a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx index bb37dfc794..cca59d9918 100644 --- a/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx +++ b/web/src/pages/AliasesPage/tabs/AliasesDeployedTab.tsx @@ -3,14 +3,17 @@ import { useNavigate } from '@tanstack/react-router'; import { useMemo, useState } from 'react'; import { m } from '../../../paraglide/messages'; import { AclStatus } from '../../../shared/api/types'; +import { TableSkeleton } from '../../../shared/components/skeleton/TableSkeleton/TableSkeleton'; import { Button } from '../../../shared/defguard-ui/components/Button/Button'; import type { ButtonProps } from '../../../shared/defguard-ui/components/Button/types'; import { EmptyStateFlexible } from '../../../shared/defguard-ui/components/EmptyStateFlexible/EmptyStateFlexible'; import { Search } from '../../../shared/defguard-ui/components/Search/Search'; import { TableTop } from '../../../shared/defguard-ui/components/table/TableTop/TableTop'; +import { isPresent } from '../../../shared/defguard-ui/utils/isPresent'; import { getAliasesQueryOptions, getLicenseInfoQueryOptions, + getRulesQueryOptions, } from '../../../shared/query'; import { canUseBusinessFeature, licenseActionCheck } from '../../../shared/utils/license'; import { AliasTable } from '../AliasTable'; @@ -26,6 +29,12 @@ export const AliasesDeployedTab = () => { const { data: licenseInfo, isFetching: licenseFetching } = useQuery( getLicenseInfoQueryOptions, ); + const { + data: rules, + isLoading: rulesLoading, + isFetching: rulesFetching, + } = useQuery(getRulesQueryOptions); + const rulesReady = !rulesLoading && !rulesFetching && isPresent(rules); const addButtonProps = useMemo( (): ButtonProps => ({ @@ -78,7 +87,8 @@ export const AliasesDeployedTab = () => { />