fix(review): close 5 follow-up gaps from Stage 6 audit re-review
- F-T-003 actually closed: clients/typescript/src/subscribe.ts now bounds the 401-refresh loop at 3 consecutive refusals, surfaces an onError describing the loop, and resets on any successful stream open. New test covers the cap. The Stage 2 dashboard fix only addressed adminRequest in dashboard/src/lib/api.ts; the originally- cited TS client file was untouched. - users/invitations subtab dropped the redundant api.apps.get fetch the other 5 subtabs already shed in Stage 6. - Queue visibility-timeout validator pulled out as validate_queue_visibility_timeout, with two thresholds: hard-reject below MIN_QUEUE_VISIBILITY_TIMEOUT_SECS (30s — catches typos), warn- log between MIN and SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS (300s) so operators see when their visibility is below the dispatcher's per-message executor budget. Stage 6 only had the hard floor; the reviewer caught that a 60s handler still races a 30s visibility even after the floor. Four new unit tests cover none/above-safe/ between/below-min. - pic dead-letters count subcommand: cheap headless probe for unresolved DL totals, parallels the dashboard's badge query. - pic dead-letters replay now has a happy-path integration test (replay_against_real_dl_row_succeeds): inserts a synthetic DL row directly via the rust-postgres sync driver (added as dev-dep), drives `pic dead-letters replay`, asserts the row is resolved with reason=replayed and count drops back to 0. Plus a count smoke test. All 75 CLI integration tests + 16 TS client tests + 4 new visibility-timeout unit tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
368
Cargo.lock
generated
368
Cargo.lock
generated
@@ -8,7 +8,7 @@ version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -20,7 +20,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -139,7 +139,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -324,7 +324,7 @@ version = "0.10.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -336,6 +336,15 @@ dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bstr"
|
||||
version = "1.12.1"
|
||||
@@ -399,6 +408,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -421,7 +441,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"chrono-tz-build",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -431,7 +451,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1"
|
||||
dependencies = [
|
||||
"parse-zoneinfo",
|
||||
"phf",
|
||||
"phf 0.11.3",
|
||||
"phf_codegen",
|
||||
]
|
||||
|
||||
@@ -441,7 +461,7 @@ version = "0.4.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"inout",
|
||||
]
|
||||
|
||||
@@ -485,6 +505,12 @@ version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -506,6 +532,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||
|
||||
[[package]]
|
||||
name = "const-random"
|
||||
version = "0.1.18"
|
||||
@@ -551,6 +583,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc"
|
||||
version = "3.4.0"
|
||||
@@ -609,6 +650,15 @@ dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctr"
|
||||
version = "0.9.2"
|
||||
@@ -618,6 +668,15 @@ dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.0"
|
||||
@@ -630,7 +689,7 @@ version = "0.7.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"const-oid 0.9.6",
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
@@ -662,12 +721,24 @@ version = "0.10.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"const-oid",
|
||||
"crypto-common",
|
||||
"block-buffer 0.10.4",
|
||||
"const-oid 0.9.6",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer 0.12.0",
|
||||
"const-oid 0.10.2",
|
||||
"crypto-common 0.2.2",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "directories"
|
||||
version = "5.0.1"
|
||||
@@ -769,6 +840,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fallible-iterator"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7"
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.4.1"
|
||||
@@ -948,7 +1025,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
@@ -975,6 +1052,7 @@ dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi 6.0.0",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
]
|
||||
@@ -1033,7 +1111,7 @@ version = "0.12.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
|
||||
dependencies = [
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1042,7 +1120,16 @@ version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
|
||||
dependencies = [
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1110,6 +1197,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
@@ -1501,7 +1597,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1529,7 +1635,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1628,6 +1734,24 @@ dependencies = [
|
||||
"libm",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
@@ -1748,7 +1872,17 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||
dependencies = [
|
||||
"phf_shared 0.13.1",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1758,7 +1892,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
||||
dependencies = [
|
||||
"phf_generator",
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1767,7 +1901,7 @@ version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.6",
|
||||
]
|
||||
|
||||
@@ -1780,6 +1914,15 @@ dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "picloud"
|
||||
version = "1.1.9"
|
||||
@@ -1791,14 +1934,14 @@ dependencies = [
|
||||
"chrono",
|
||||
"figment",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"picloud-executor-core",
|
||||
"picloud-manager-core",
|
||||
"picloud-orchestrator-core",
|
||||
"picloud-shared",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@@ -1820,6 +1963,7 @@ dependencies = [
|
||||
"directories",
|
||||
"libc",
|
||||
"picloud-shared",
|
||||
"postgres",
|
||||
"predicates",
|
||||
"reqwest",
|
||||
"rpassword",
|
||||
@@ -1828,6 +1972,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"toml",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1892,7 +2037,7 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"hex",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"lettre",
|
||||
"picloud-executor-core",
|
||||
"picloud-orchestrator-core",
|
||||
@@ -1901,7 +2046,7 @@ dependencies = [
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"sqlx",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
@@ -1953,11 +2098,11 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64",
|
||||
"chrono",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"thiserror 1.0.69",
|
||||
"tokio",
|
||||
"tracing",
|
||||
@@ -2010,7 +2155,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -2021,6 +2166,52 @@ version = "1.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
|
||||
|
||||
[[package]]
|
||||
name = "postgres"
|
||||
version = "0.19.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aacf632d0554ff75f58183694f41dc8999c8a3a43a386994d0ec2d034f1dfbe1"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tokio-postgres",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-protocol"
|
||||
version = "0.6.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "56201207dac53e2f38e848e31b4b91616a6bb6e0c7205b77718994a7f49e70fc"
|
||||
dependencies = [
|
||||
"base64",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"hmac 0.13.0",
|
||||
"md-5 0.11.0",
|
||||
"memchr",
|
||||
"rand 0.10.1",
|
||||
"sha2 0.11.0",
|
||||
"stringprep",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "postgres-types"
|
||||
version = "0.2.13"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8dc729a129e682e8d24170cd30ae1aa01b336b096cbb56df6d534ffec133d186"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"postgres-protocol",
|
||||
"serde_core",
|
||||
"serde_json",
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
@@ -2220,6 +2411,17 @@ dependencies = [
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.2",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
@@ -2258,6 +2460,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.5.18"
|
||||
@@ -2426,8 +2634,8 @@ version = "0.9.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||
dependencies = [
|
||||
"const-oid",
|
||||
"digest",
|
||||
"const-oid 0.9.6",
|
||||
"digest 0.10.7",
|
||||
"num-bigint-dig",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
@@ -2626,8 +2834,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2637,8 +2845,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"digest",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest 0.10.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.0",
|
||||
"digest 0.11.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2672,7 +2891,7 @@ version = "2.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||
dependencies = [
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
@@ -2784,7 +3003,7 @@ dependencies = [
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
@@ -2823,7 +3042,7 @@ dependencies = [
|
||||
"quote",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"sqlx-core",
|
||||
"sqlx-mysql",
|
||||
"sqlx-postgres",
|
||||
@@ -2846,7 +3065,7 @@ dependencies = [
|
||||
"bytes",
|
||||
"chrono",
|
||||
"crc",
|
||||
"digest",
|
||||
"digest 0.10.7",
|
||||
"dotenvy",
|
||||
"either",
|
||||
"futures-channel",
|
||||
@@ -2856,10 +3075,10 @@ dependencies = [
|
||||
"generic-array",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"md-5 0.10.6",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"percent-encoding",
|
||||
@@ -2867,14 +3086,14 @@ dependencies = [
|
||||
"rsa",
|
||||
"serde",
|
||||
"sha1",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2896,24 +3115,24 @@ dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac",
|
||||
"hmac 0.12.1",
|
||||
"home",
|
||||
"itoa",
|
||||
"log",
|
||||
"md-5",
|
||||
"md-5 0.10.6",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"sha2 0.10.9",
|
||||
"smallvec",
|
||||
"sqlx-core",
|
||||
"stringprep",
|
||||
"thiserror 2.0.18",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"whoami",
|
||||
"whoami 1.6.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3178,6 +3397,32 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-postgres"
|
||||
version = "0.7.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4dd8df5ef180f6364759a6f00f7aadda4fbbac86cdee37480826a6ff9f3574ce"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"byteorder",
|
||||
"bytes",
|
||||
"fallible-iterator",
|
||||
"futures-channel",
|
||||
"futures-util",
|
||||
"log",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"phf 0.13.1",
|
||||
"pin-project-lite",
|
||||
"postgres-protocol",
|
||||
"postgres-types",
|
||||
"rand 0.10.1",
|
||||
"socket2",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
"whoami 2.1.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-rustls"
|
||||
version = "0.26.4"
|
||||
@@ -3436,7 +3681,7 @@ version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea"
|
||||
dependencies = [
|
||||
"crypto-common",
|
||||
"crypto-common 0.1.7",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
@@ -3530,6 +3775,15 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.3+wasi-0.2.9"
|
||||
@@ -3554,6 +3808,15 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b"
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.122"
|
||||
@@ -3688,7 +3951,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d"
|
||||
dependencies = [
|
||||
"libredox",
|
||||
"wasite",
|
||||
"wasite 0.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite 1.0.2",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -29,6 +29,14 @@ export function subscribeTopic<T = unknown>(
|
||||
let lastEventId: string | undefined;
|
||||
let controller: AbortController | null = null;
|
||||
let backoffTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// F-T-003: bound the 401-refresh loop. If onTokenExpired keeps
|
||||
// returning a token that the server rejects (mis-issued refresh,
|
||||
// server-side authz drift, …) we'd otherwise reconnect immediately
|
||||
// forever with attempt=0 each iteration — the previous behavior.
|
||||
// Three consecutive 401s within the loop give up and surface the
|
||||
// error so the caller knows the credential is bad.
|
||||
let consecutive401 = 0;
|
||||
const MAX_CONSECUTIVE_401 = 3;
|
||||
|
||||
const stop = () => {
|
||||
stopped = true;
|
||||
@@ -61,6 +69,17 @@ export function subscribeTopic<T = unknown>(
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
consecutive401 += 1;
|
||||
if (consecutive401 >= MAX_CONSECUTIVE_401) {
|
||||
opts.onError?.(
|
||||
new Error(
|
||||
`realtime subscribe stuck in 401-refresh loop (${consecutive401} consecutive); ` +
|
||||
'check that onTokenExpired returns a credential the server accepts'
|
||||
)
|
||||
);
|
||||
stop();
|
||||
return;
|
||||
}
|
||||
// Token expired / rejected — try to refresh, else give up.
|
||||
const fresh = opts.onTokenExpired ? await opts.onTokenExpired() : null;
|
||||
if (fresh) {
|
||||
@@ -79,8 +98,10 @@ export function subscribeTopic<T = unknown>(
|
||||
return;
|
||||
}
|
||||
|
||||
// Connected — reset backoff and stream frames until the body ends.
|
||||
// Connected — reset backoff and the 401 counter; a successful
|
||||
// open proves the current credential works.
|
||||
attempt = 0;
|
||||
consecutive401 = 0;
|
||||
try {
|
||||
await readStream(res.body, (frame) => {
|
||||
if (frame.id !== undefined) lastEventId = frame.id;
|
||||
|
||||
@@ -96,4 +96,30 @@ describe('subscribe', () => {
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
unsubscribe();
|
||||
});
|
||||
|
||||
it('caps the 401-refresh loop after consecutive failures', async () => {
|
||||
// onTokenExpired keeps returning a fresh-looking-but-still-rejected
|
||||
// token. Without the cap the loop would reconnect forever.
|
||||
const fetchMock = queuedFetch([
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401),
|
||||
async () => emptyResponse(401)
|
||||
]);
|
||||
const client = new PicloudClient({ baseURL: 'https://api.test', fetch: fetchMock });
|
||||
const onError = vi.fn();
|
||||
const onTokenExpired = vi.fn(() => 'never-good-enough');
|
||||
const unsubscribe = client.subscribe('chat', () => {}, {
|
||||
onTokenExpired,
|
||||
onError
|
||||
});
|
||||
await vi.waitFor(() => expect(onError).toHaveBeenCalled(), { timeout: 1000 });
|
||||
unsubscribe();
|
||||
// The cap is 3 consecutive 401s — at most 3 fetches should have
|
||||
// been issued before the loop bailed.
|
||||
expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
|
||||
const errMsg = String(onError.mock.calls[0]?.[0]);
|
||||
expect(errMsg).toContain('401-refresh loop');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -34,12 +34,51 @@ use crate::trigger_repo::{
|
||||
TriggerRepo, TriggerRepoError,
|
||||
};
|
||||
|
||||
/// Minimum allowed queue visibility timeout. Anything shorter races the
|
||||
/// dispatcher's per-message executor budget; reclaim would re-deliver
|
||||
/// the message before the handler returned. Picked to outlast a slow
|
||||
/// handler by a comfortable margin.
|
||||
/// Hard floor on queue visibility timeout. Anything shorter is a typo
|
||||
/// in practice (the dispatcher itself ticks every 100ms; reclaim ticks
|
||||
/// every 30s; an executor needs more wall-clock than this to do useful
|
||||
/// work). Reject below this with a 422 + actionable error.
|
||||
const MIN_QUEUE_VISIBILITY_TIMEOUT_SECS: u32 = 30;
|
||||
|
||||
/// Soft warning ceiling: when the configured visibility is shorter than
|
||||
/// the dispatcher's per-message executor budget (default 300s, see
|
||||
/// `DEFAULT_ASYNC_EXEC_TIMEOUT` in dispatcher.rs), a handler that runs
|
||||
/// for the full budget will be reclaimed mid-execution and the message
|
||||
/// double-delivered. Don't reject — short handlers are a valid choice
|
||||
/// — but log loudly so the operator can see the mismatch.
|
||||
const SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS: u32 = 300;
|
||||
|
||||
/// Validate the script-supplied visibility timeout. Hard-rejects below
|
||||
/// [`MIN_QUEUE_VISIBILITY_TIMEOUT_SECS`]; warn-logs (does not reject)
|
||||
/// when in `[MIN, SAFE)` because a handler running past `visibility_secs`
|
||||
/// will get double-delivered when reclaim sweeps the stale claim.
|
||||
///
|
||||
/// Extracted from the handler so it's unit-testable without spinning up
|
||||
/// the full Axum harness.
|
||||
fn validate_queue_visibility_timeout(secs: Option<u32>) -> Result<(), TriggersApiError> {
|
||||
let Some(s) = secs else {
|
||||
return Ok(()); // None → fall back to TriggerConfig's default.
|
||||
};
|
||||
if s < MIN_QUEUE_VISIBILITY_TIMEOUT_SECS {
|
||||
return Err(TriggersApiError::Invalid(format!(
|
||||
"visibility_timeout_secs must be >= {MIN_QUEUE_VISIBILITY_TIMEOUT_SECS} \
|
||||
(shorter than the dispatcher's tick + reclaim cadence; \
|
||||
a handler can't return before reclaim fires)"
|
||||
)));
|
||||
}
|
||||
if s < SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS {
|
||||
tracing::warn!(
|
||||
visibility_timeout_secs = s,
|
||||
executor_budget_secs = SAFE_VISIBILITY_VS_EXEC_BUDGET_SECS,
|
||||
"queue trigger visibility_timeout_secs is below the dispatcher's per-message \
|
||||
executor budget; a handler that runs longer than {s}s will be reclaimed \
|
||||
mid-execution and the message double-delivered. Either raise visibility or \
|
||||
accept the at-least-once semantics for this queue."
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct TriggersState {
|
||||
pub triggers: Arc<dyn TriggerRepo>,
|
||||
@@ -573,21 +612,7 @@ async fn create_queue_trigger(
|
||||
"queue_name must not be empty".into(),
|
||||
));
|
||||
}
|
||||
// Reject obviously-too-short visibility timeouts. The dispatcher
|
||||
// budgets up to ~5 minutes of executor wall-clock per message
|
||||
// (DEFAULT_ASYNC_EXEC_TIMEOUT in dispatcher.rs); a 10s visibility
|
||||
// timeout would race the executor and cause the reclaim task to
|
||||
// re-deliver the message before the first handler returned, so the
|
||||
// queue would silently double-deliver. The audit's Low finding.
|
||||
if let Some(secs) = input.visibility_timeout_secs {
|
||||
if secs < MIN_QUEUE_VISIBILITY_TIMEOUT_SECS {
|
||||
return Err(TriggersApiError::Invalid(format!(
|
||||
"visibility_timeout_secs must be >= {MIN_QUEUE_VISIBILITY_TIMEOUT_SECS} \
|
||||
(shorter than the dispatcher's per-message executor budget; \
|
||||
reclaim would race the handler)"
|
||||
)));
|
||||
}
|
||||
}
|
||||
validate_queue_visibility_timeout(input.visibility_timeout_secs)?;
|
||||
validate_trigger_target(&*s.scripts, app_id, input.script_id).await?;
|
||||
|
||||
let req = crate::trigger_repo::CreateQueueTrigger {
|
||||
@@ -736,6 +761,44 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
use crate::app_repo::{AppLookup, AppRepository};
|
||||
|
||||
#[test]
|
||||
fn visibility_timeout_none_passes() {
|
||||
assert!(validate_queue_visibility_timeout(None).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_timeout_above_safe_passes() {
|
||||
// ≥ 300s: safely above the executor budget, no warn.
|
||||
assert!(validate_queue_visibility_timeout(Some(600)).is_ok());
|
||||
assert!(validate_queue_visibility_timeout(Some(300)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_timeout_between_min_and_safe_warns_but_passes() {
|
||||
// [30, 300): valid but the operator should know. We assert the
|
||||
// call succeeds; the warn-log emission is a side effect tracing
|
||||
// would surface in real ops — covered visually, not asserted.
|
||||
assert!(validate_queue_visibility_timeout(Some(60)).is_ok());
|
||||
assert!(validate_queue_visibility_timeout(Some(MIN_QUEUE_VISIBILITY_TIMEOUT_SECS)).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visibility_timeout_below_min_rejected() {
|
||||
for too_short in [0u32, 1, 5, 10, MIN_QUEUE_VISIBILITY_TIMEOUT_SECS - 1] {
|
||||
let err = validate_queue_visibility_timeout(Some(too_short)).unwrap_err();
|
||||
match err {
|
||||
TriggersApiError::Invalid(msg) => {
|
||||
assert!(
|
||||
msg.contains(&MIN_QUEUE_VISIBILITY_TIMEOUT_SECS.to_string()),
|
||||
"error should cite the minimum: {msg}"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Invalid for {too_short}s, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
use crate::trigger_repo::{
|
||||
CreateCronTrigger, CreateEmailTrigger, CreateFilesTrigger, CreatePubsubTrigger,
|
||||
DeadLetterTriggerMatch, DocsTriggerMatch, EmailInboundTarget, FilesTriggerMatch,
|
||||
|
||||
@@ -40,3 +40,9 @@ predicates = "3"
|
||||
tempfile = "3"
|
||||
reqwest = { workspace = true, features = ["json", "blocking"] }
|
||||
libc = "0.2"
|
||||
# Synchronous Postgres driver used by the dead-letters integration test
|
||||
# to inject a row the replay path can act on. Smaller compile cost than
|
||||
# pulling sqlx into the CLI tree, and the existing tests are
|
||||
# blocking-style anyway.
|
||||
postgres = { version = "0.19", features = ["with-uuid-1", "with-serde_json-1"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
|
||||
@@ -399,6 +399,18 @@ impl Client {
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/dead_letters/count`
|
||||
pub async fn dead_letters_count(&self, app: &str) -> Result<DeadLetterCountDto> {
|
||||
let resp = self
|
||||
.request(
|
||||
Method::GET,
|
||||
&format!("/api/v1/admin/apps/{app}/dead_letters/count"),
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
decode(resp).await
|
||||
}
|
||||
|
||||
/// `GET /api/v1/admin/apps/{id}/dead_letters?unresolved={bool}&limit={n}`
|
||||
pub async fn dead_letters_list(
|
||||
&self,
|
||||
@@ -647,6 +659,11 @@ pub struct TriggerDto {
|
||||
pub details: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeadLetterCountDto {
|
||||
pub unresolved: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct DeadLetterListDto {
|
||||
pub dead_letters: Vec<DeadLetterDto>,
|
||||
|
||||
@@ -6,6 +6,18 @@ use crate::client::Client;
|
||||
use crate::config;
|
||||
use crate::output::{KvBlock, OutputMode, Table};
|
||||
|
||||
/// `pic dead-letters count --app <slug>` — print the unresolved DL
|
||||
/// count for an app. Useful for headless probes (alerting / dashboards).
|
||||
pub async fn count(app: &str, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
let resp = client.dead_letters_count(app).await?;
|
||||
let mut table = Table::new(["unresolved"]);
|
||||
table.row([resp.unresolved.to_string()]);
|
||||
table.print(mode);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn ls(app: &str, unresolved: bool, limit: u32, mode: OutputMode) -> Result<()> {
|
||||
let creds = config::resolve()?;
|
||||
let client = Client::from_creds(&creds)?;
|
||||
|
||||
@@ -466,6 +466,13 @@ enum TriggersCmd {
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum DeadLettersCmd {
|
||||
/// Print the unresolved DL count. Cheap probe for alerting /
|
||||
/// dashboards — no row payloads, just the bare integer.
|
||||
Count {
|
||||
#[arg(long)]
|
||||
app: String,
|
||||
},
|
||||
|
||||
/// List dead-letter rows for an app.
|
||||
Ls {
|
||||
#[arg(long)]
|
||||
@@ -822,6 +829,9 @@ async fn main() -> ExitCode {
|
||||
Cmd::Triggers {
|
||||
cmd: TriggersCmd::CreateFromJson { app, kind, body },
|
||||
} => cmds::triggers::create_from_json(&app, &kind, &body, mode).await,
|
||||
Cmd::DeadLetters {
|
||||
cmd: DeadLettersCmd::Count { app },
|
||||
} => cmds::dead_letters::count(&app, mode).await,
|
||||
Cmd::DeadLetters {
|
||||
cmd:
|
||||
DeadLettersCmd::Ls {
|
||||
|
||||
@@ -46,6 +46,136 @@ fn ls_empty_app_succeeds() {
|
||||
assert_eq!(stdout.lines().count(), 1, "fresh app has no DL rows");
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn count_returns_unresolved_total() {
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let slug = common::unique_slug("dl-count");
|
||||
common::pic_as(&env)
|
||||
.args(["apps", "create", &slug])
|
||||
.assert()
|
||||
.success();
|
||||
let _guard = AppGuard::new(&env.url, &env.token, &slug);
|
||||
|
||||
let out = common::pic_as(&env)
|
||||
.args(["dead-letters", "count", "--app", &slug])
|
||||
.output()
|
||||
.expect("dl count");
|
||||
assert!(out.status.success(), "count failed: {out:?}");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
let header = stdout.lines().next().expect("header");
|
||||
assert_eq!(common::cells(header), vec!["unresolved"]);
|
||||
let row = stdout.lines().nth(1).expect("data row");
|
||||
assert_eq!(common::cells(row), vec!["0"]);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn replay_against_real_dl_row_succeeds() {
|
||||
use postgres::{Client as PgClient, NoTls};
|
||||
use uuid::Uuid;
|
||||
|
||||
let Some(fx) = common::fixture_or_skip() else {
|
||||
return;
|
||||
};
|
||||
let env = common::admin_env(fx);
|
||||
let (script_id_str, guard) = common::deploy_fixture(&env, "dl-replay", "hello.rhai");
|
||||
let app_slug = guard.slug().to_string();
|
||||
|
||||
// Need the app's UUID to inject the DL row. Resolve via the admin
|
||||
// GET — picloud accepts either slug or id, returns both.
|
||||
let app_id: String = reqwest::blocking::Client::new()
|
||||
.get(format!("{}/api/v1/admin/apps/{}", env.url, app_slug))
|
||||
.bearer_auth(&env.token)
|
||||
.send()
|
||||
.expect("apps_get")
|
||||
.json::<serde_json::Value>()
|
||||
.expect("apps_get json")["id"]
|
||||
.as_str()
|
||||
.expect("app id")
|
||||
.to_string();
|
||||
|
||||
// Direct INSERT — no admin endpoint creates DL rows; the real ones
|
||||
// come from the dispatcher's retry-exhaust path, which would take
|
||||
// minutes to drive. The replay path doesn't care how the row got
|
||||
// there, only that it exists, belongs to this app, and is
|
||||
// unresolved.
|
||||
let db_url =
|
||||
std::env::var("DATABASE_URL").expect("DATABASE_URL required for replay happy-path test");
|
||||
let mut pg = PgClient::connect(&db_url, NoTls).expect("postgres connect");
|
||||
let dl_id = Uuid::new_v4();
|
||||
let app_uuid = Uuid::parse_str(&app_id).expect("app uuid");
|
||||
let script_uuid = Uuid::parse_str(&script_id_str).expect("script uuid");
|
||||
// Payload shape must round-trip through the dispatcher: source="kv"
|
||||
// matches a KV TriggerEvent, op="set" is a KvEventOp, and the
|
||||
// service_kind=`kv` outbox row gets routed by resolve_trigger.
|
||||
let payload = serde_json::json!({
|
||||
"source": "kv",
|
||||
"op": "set",
|
||||
"kv": {
|
||||
"collection": "x",
|
||||
"key": "y",
|
||||
"value": { "v": 1 }
|
||||
}
|
||||
});
|
||||
pg.execute(
|
||||
"INSERT INTO dead_letters (
|
||||
id, app_id, original_event_id, source, op, trigger_id, script_id,
|
||||
payload, attempt_count, first_attempt_at, last_attempt_at, last_error
|
||||
) VALUES ($1, $2, $3, 'kv', 'set', NULL, $4, $5, 1, NOW(), NOW(), 'forced')",
|
||||
&[&dl_id, &app_uuid, &Uuid::new_v4(), &script_uuid, &payload],
|
||||
)
|
||||
.expect("insert synthetic DL row");
|
||||
|
||||
// Verify CLI count picks the row up.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["dead-letters", "count", "--app", &app_slug])
|
||||
.output()
|
||||
.expect("dl count");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.lines().nth(1) == Some("1"),
|
||||
"count should be 1: {stdout}"
|
||||
);
|
||||
|
||||
// Replay — pic exits 0, the server resolves the row with reason=replayed.
|
||||
common::pic_as(&env)
|
||||
.args([
|
||||
"dead-letters",
|
||||
"replay",
|
||||
"--app",
|
||||
&app_slug,
|
||||
&dl_id.to_string(),
|
||||
])
|
||||
.assert()
|
||||
.success()
|
||||
.stdout(predicate::str::contains("Replayed dead-letter"));
|
||||
|
||||
// Verify the row is now resolved with reason=replayed.
|
||||
let row = pg
|
||||
.query_one(
|
||||
"SELECT resolution FROM dead_letters WHERE id = $1",
|
||||
&[&dl_id],
|
||||
)
|
||||
.expect("dl row exists post-replay");
|
||||
let resolution: Option<String> = row.get(0);
|
||||
assert_eq!(resolution.as_deref(), Some("replayed"));
|
||||
|
||||
// And the unresolved count drops back to 0.
|
||||
let out = common::pic_as(&env)
|
||||
.args(["dead-letters", "count", "--app", &app_slug])
|
||||
.output()
|
||||
.expect("dl count");
|
||||
let stdout = String::from_utf8(out.stdout).unwrap();
|
||||
assert!(
|
||||
stdout.lines().nth(1) == Some("0"),
|
||||
"count should be 0 after replay: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[ignore = "needs DATABASE_URL pointing at a running Postgres"]
|
||||
#[test]
|
||||
fn resolve_marks_row_resolved() {
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script lang="ts">
|
||||
import { base } from '$app/paths';
|
||||
import { page } from '$app/state';
|
||||
import { api, ApiError, type App, type Invitation } from '$lib/api';
|
||||
import { api, ApiError, type Invitation } from '$lib/api';
|
||||
import ConfirmModal from '$lib/ConfirmModal.svelte';
|
||||
|
||||
let slug = $derived(page.params.slug ?? '');
|
||||
let app = $state<App | null>(null);
|
||||
let invitations = $state<Invitation[]>([]);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
@@ -26,14 +25,6 @@
|
||||
let toRevoke = $state<Invitation | null>(null);
|
||||
let revoking = $state(false);
|
||||
|
||||
async function loadApp() {
|
||||
try {
|
||||
app = await api.apps.get(slug);
|
||||
} catch (e) {
|
||||
error = e instanceof ApiError ? e.message : String(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInvitations() {
|
||||
loading = true;
|
||||
error = null;
|
||||
@@ -49,7 +40,6 @@
|
||||
|
||||
$effect(() => {
|
||||
void slug;
|
||||
void loadApp();
|
||||
void loadInvitations();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user