From 5a36c5d488651b417584a645570d94d9d44b41c5 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 2 Sep 2026 15:04:03 +0000 Subject: [PATCH 1/3] fix(guardrails): refuse an unscannable /v1/files upload instead of forwarding it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan_input_blob` scanned `String::from_utf8_lossy(&file_bytes)` while `create_file` built the outbound multipart part from the ORIGINAL bytes. Every invalid sequence became U+FFFD before the guardrail saw it, so a term written in a non-UTF-8 encoding never appeared in the scanned text — and the real bytes went to the provider anyway. The guardrail decided about a redacted copy of a payload that left the boundary intact. With a chain attached, a blob that is not valid UTF-8 is now refused before the upstream call, using the same fail-closed arm the LLM routes already take on a body the scanner cannot read (`messages.rs`, `count_tokens.rs`, `responses.rs`, `mcp.rs`): `guardrail_block_error` with the `unscannable_body` tag. Uploads that do decode are scanned exactly as before. BEHAVIOUR CHANGE: a `POST /v1/files` upload whose `file` part is not valid UTF-8 is accepted and forwarded today; it now returns 422 with `error.type: content_filter` and `error.code: guardrail_unavailable` whenever an input guardrail chain resolves for the request, and the file is not forwarded to the provider. Deployments with no guardrail attached to the upload are unaffected — this is a guardrail refusal, not structural validation of the file, so non-UTF-8 uploads keep working wherever nothing is screening them. Fixes #1022 --- crates/aisix-proxy/src/jobs.rs | 195 ++++++++++++++- .../files-unscannable-upload-e2e.test.ts | 233 ++++++++++++++++++ 2 files changed, 423 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index a5403dbf7..4b4e945f0 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -448,7 +448,9 @@ async fn send_upstream( } /// Run the resolved input-guardrail chain over an opaque blob (whole-body -/// lossy text scan — the `/passthrough` precedent from #911 [6]). +/// text scan — the `/passthrough` precedent from #911 [6]). A blob that is +/// not valid UTF-8 is refused rather than scanned lossily; see the arm +/// below. async fn scan_input_blob( state: &ProxyState, auth: &AuthenticatedKey, @@ -472,11 +474,37 @@ async fn scan_input_blob( if chain.is_empty() { return Ok(()); } + // Fail closed on a blob the scanner cannot read — the same arm as + // `messages.rs` / `count_tokens.rs` / `mcp.rs`, and the reason the + // scan below no longer decodes lossily. `from_utf8_lossy` replaces + // every invalid sequence with U+FFFD, so a term written in a + // non-UTF-8 encoding never appeared in the scanned text while + // `create_file` forwarded the ORIGINAL bytes to the provider (#1022): + // the guardrail was offered a redacted copy of the payload and the + // payload left the boundary anyway. + // + // Reached only once a chain is attached, which is deliberate. An + // upload nobody screens keeps forwarding whatever it forwards today; + // this is a guardrail refusal, not a structural check on the file. + let text = match std::str::from_utf8(blob) { + Ok(text) => text, + Err(err) => { + tracing::warn!( + guardrail_hook = "input", + model = %target.display_name(), + error = %err, + "cannot scan jobs upload for guardrails; blocking", + ); + return Err(crate::error::guardrail_block_error( + "request", + None, + Some(crate::error::TAG_UNSCANNABLE_BODY), + )); + } + }; let chat = aisix_gateway::ChatFormat::new( target.display_name(), - vec![aisix_gateway::ChatMessage::user( - String::from_utf8_lossy(blob).into_owned(), - )], + vec![aisix_gateway::ChatMessage::user(text.to_owned())], ); let (verdict, hits) = aisix_guardrails::Guardrail::check_input_observed(&chain, &chat).await; monitor_hits.extend(hits); @@ -2189,6 +2217,12 @@ mod tests { // ---- files ---- fn multipart_body(boundary: &str, model: Option<&str>) -> Vec { + multipart_body_with_file(boundary, model, br#"{"custom_id":"r1"}"#) + } + + /// [`multipart_body`] with the `file` part's bytes chosen by the + /// caller, so a test can upload something that is not valid UTF-8. + fn multipart_body_with_file(boundary: &str, model: Option<&str>, file: &[u8]) -> Vec { let mut b = Vec::new(); if let Some(m) = model { b.extend_from_slice( @@ -2206,10 +2240,12 @@ mod tests { ); b.extend_from_slice( format!( - "--{boundary}\r\ncontent-disposition: form-data; name=\"file\"; filename=\"input.jsonl\"\r\ncontent-type: application/jsonl\r\n\r\n{{\"custom_id\":\"r1\"}}\r\n" + "--{boundary}\r\ncontent-disposition: form-data; name=\"file\"; filename=\"input.jsonl\"\r\ncontent-type: application/jsonl\r\n\r\n" ) .as_bytes(), ); + b.extend_from_slice(file); + b.extend_from_slice(b"\r\n"); b.extend_from_slice(format!("--{boundary}--\r\n").as_bytes()); b } @@ -2760,4 +2796,153 @@ mod tests { let wire = serde_json::to_string(&ev).unwrap(); assert!(!wire.contains("custom_id"), "{wire}"); } + + /// GBK bytes for 你好 embedded in an otherwise well-formed JSONL line. + /// `0xC4` opens a two-byte sequence and `0xE3` is not a continuation + /// byte, so the blob is not valid UTF-8 — and `from_utf8_lossy` used + /// to hand the guardrail `\u{FFFD}` in place of the term while the + /// original bytes went to the provider untouched (#1022). + const NON_UTF8_UPLOAD: &[u8] = b"{\"custom_id\":\"r1\",\"note\":\"\xc4\xe3\xba\xc3\"}"; + + /// A keyword row that cannot match anything in these fixtures. The + /// refusal below must come from the blob being unscannable, not from + /// a hit — otherwise the test would also pass for a fix that simply + /// started blocking every upload. + fn seed_never_matching_guardrail(snap: &AisixSnapshot) { + let g: aisix_core::Guardrail = serde_json::from_str( + r#"{"name":"never-matches","enabled":true,"hook_point":"input","fail_open":false,"kind":"keyword","patterns":[{"kind":"literal","value":"zzz-no-such-term-zzz"}]}"#, + ) + .unwrap(); + crate::seed_env_scoped_guardrail( + snap, + aisix_core::resource::ResourceEntry::new("g-1", g, 1), + ); + } + + fn upload_request(boundary: &str, file: &[u8]) -> Request { + Request::builder() + .method("POST") + .uri("/v1/files") + .header("authorization", "Bearer sk-caller") + .header( + "content-type", + format!("multipart/form-data; boundary={boundary}"), + ) + .body(axum::body::Body::from(multipart_body_with_file( + boundary, + Some("jobs-a"), + file, + ))) + .unwrap() + } + + fn files_upstream_mock() -> Mock { + Mock::given(wm_method("POST")) + .and(path("/v1/files")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "id": "file-abc", + "object": "file", + "purpose": "batch", + "filename": "input.jsonl" + }))) + } + + /// #1022: with a chain attached, a blob the scanner cannot read is + /// refused and never reaches the provider. `expect(0)` is the + /// load-bearing half — the old code scanned a lossy copy and then + /// forwarded the original bytes, so the guardrail decided about + /// content that was not what left the boundary. + #[tokio::test] + async fn non_utf8_upload_is_refused_and_never_forwarded() { + let upstream = MockServer::start().await; + files_upstream_mock().expect(0).mount(&upstream).await; + + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(openai_pk(PK_A, &upstream.uri())); + snap.models.insert(model("m-a", "jobs-a", PK_A)); + snap.apikeys.insert(apikey_entry(&["*"])); + seed_never_matching_guardrail(&snap); + let app = build_app(snap); + + let resp = app + .oneshot(upload_request("XBOUNDARYX", NON_UTF8_UPLOAD)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNPROCESSABLE_ENTITY); + let bytes = to_bytes(resp.into_body(), 65536).await.unwrap(); + let v: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(v["error"]["type"], "content_filter", "{v}"); + assert_eq!(v["error"]["code"], "guardrail_unavailable", "{v}"); + assert!( + v["error"]["message"] + .as_str() + .unwrap() + .contains(crate::error::TAG_UNSCANNABLE_BODY), + "the caller must be able to tell an unscannable body from a \ + policy hit: {v}" + ); + } + + /// The refusal is conditional on a chain being attached. With no + /// guardrail in the index the same upload behaves exactly as it does + /// today — this is not structural validation of the file. + #[tokio::test] + async fn non_utf8_upload_without_a_guardrail_still_forwards() { + let upstream = MockServer::start().await; + files_upstream_mock().expect(1).mount(&upstream).await; + + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(openai_pk(PK_A, &upstream.uri())); + snap.models.insert(model("m-a", "jobs-a", PK_A)); + snap.apikeys.insert(apikey_entry(&["*"])); + let app = build_app(snap); + + let resp = app + .oneshot(upload_request("XBOUNDARYX", NON_UTF8_UPLOAD)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + assert!( + received[0] + .body + .windows(NON_UTF8_UPLOAD.len()) + .any(|w| w == NON_UTF8_UPLOAD), + "the original bytes must still forward byte-for-byte" + ); + } + + /// The existing whole-blob scan is unchanged for an upload that does + /// decode: the chain runs, finds nothing, and the file forwards. + /// (`blocked_upload_names_the_policy_on_the_usage_event` pins the + /// other half — a matching pattern still blocks.) + #[tokio::test] + async fn clean_utf8_upload_still_scans_and_forwards() { + let upstream = MockServer::start().await; + files_upstream_mock().expect(1).mount(&upstream).await; + + let snap = AisixSnapshot::new(); + snap.provider_keys.insert(openai_pk(PK_A, &upstream.uri())); + snap.models.insert(model("m-a", "jobs-a", PK_A)); + snap.apikeys.insert(apikey_entry(&["*"])); + seed_never_matching_guardrail(&snap); + let app = build_app(snap); + + // The same term as `NON_UTF8_UPLOAD`, correctly encoded. + let clean = r#"{"custom_id":"r1","note":"你好"}"#.as_bytes(); + let resp = app + .oneshot(upload_request("XBOUNDARYX", clean)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + let received = upstream.received_requests().await.unwrap(); + assert_eq!(received.len(), 1); + assert!( + received[0].body.windows(clean.len()).any(|w| w == clean), + "a decodable upload must still forward" + ); + } } diff --git a/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts b/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts new file mode 100644 index 000000000..06554dce7 --- /dev/null +++ b/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts @@ -0,0 +1,233 @@ +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + EtcdClient, + SeedClient, + spawnApp, + waitConfigPropagation, + type SpawnedApp, +} from "../harness/index.js"; + +// E2E: #1022 — `/v1/files` used to scan a LOSSY decode of the uploaded +// blob and then forward the ORIGINAL bytes. Every invalid sequence became +// U+FFFD before the guardrail ever saw it, so a term written in a +// non-UTF-8 encoding was invisible to the scan while the real bytes left +// the boundary anyway. With a chain attached, an undecodable upload is now +// refused with the same `unscannable_body` posture the LLM routes take. +// +// The upstream recorder is the load-bearing assertion in all three legs: +// the whole bug was that a "scanned" upload still reached the provider. +// +// The scope boundary is pinned here too, deliberately. The refusal is +// conditional on a guardrail being attached — this is NOT structural +// validation of the file, and an operator running no guardrails sees no +// behaviour change at all. The second leg is the one that fails if someone +// later widens this into an unconditional UTF-8 check on the files API. + +const GUARDED_KEY = "sk-files-unscannable-guarded"; +const UNGUARDED_KEY = "sk-files-unscannable-open"; +const sha256 = (s: string) => createHash("sha256").update(s).digest("hex"); + +/** GBK bytes for 你好: `0xC4` opens a two-byte sequence and `0xE3` is not + * a continuation byte, so the blob is not valid UTF-8. */ +const NON_UTF8_LINE = Buffer.concat([ + Buffer.from('{"custom_id":"r1","note":"', "utf8"), + Buffer.from([0xc4, 0xe3, 0xba, 0xc3]), + Buffer.from('"}\n', "utf8"), +]); + +/** The same term, correctly encoded. */ +const CLEAN_LINE = Buffer.from('{"custom_id":"r1","note":"你好"}\n', "utf8"); + +/** A keyword row that cannot match these fixtures: the refusal must come + * from the blob being unscannable, not from a hit. A fix that simply + * started blocking every upload fails the third leg. */ +const NEVER_MATCHING_GUARDRAIL = { + name: "files-unscannable-e2e", + enabled: true, + hook_point: "input", + fail_open: false, + kind: "keyword", + patterns: [{ kind: "literal", value: "zzz-no-such-term-zzz" }], +}; + +interface FilesUpstream { + baseUrl: string; + uploads: Buffer[]; + close(): Promise; +} + +/** Minimal `POST /v1/files` mock that keeps the RAW request bytes, so a + * test can assert byte-for-byte forwarding rather than a lossy re-read. */ +async function startFilesUpstream(): Promise { + const uploads: Buffer[] = []; + const server: Server = createServer((req, res) => { + res.on("error", () => {}); + const chunks: Buffer[] = []; + req.on("data", (c: Buffer) => chunks.push(c)); + req.on("end", () => { + const path = (req.url ?? "/").split("?")[0]; + if (req.method === "POST" && path === "/v1/files") { + uploads.push(Buffer.concat(chunks)); + res.statusCode = 200; + res.setHeader("content-type", "application/json"); + return res.end( + JSON.stringify({ + id: "file-e2e-in", + object: "file", + purpose: "batch", + filename: "input.jsonl", + }), + ); + } + res.statusCode = 404; + res.end("{}"); + }); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + return { + baseUrl: `http://127.0.0.1:${port}`, + uploads, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +/** Hand-built multipart so the `file` part's bytes survive verbatim. */ +function multipartUpload(boundary: string, model: string, file: Buffer): Buffer { + return Buffer.concat([ + Buffer.from( + `--${boundary}\r\ncontent-disposition: form-data; name="model"\r\n\r\n${model}\r\n` + + `--${boundary}\r\ncontent-disposition: form-data; name="purpose"\r\n\r\nbatch\r\n` + + `--${boundary}\r\ncontent-disposition: form-data; name="file"; filename="input.jsonl"\r\n` + + `content-type: application/jsonl\r\n\r\n`, + "utf8", + ), + file, + Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"), + ]); +} + +interface Env { + app: SpawnedApp; + upstream: FilesUpstream; + key: string; + model: string; +} + +const startEnv = async ( + etcd: EtcdClient, + key: string, + guarded: boolean, +): Promise => { + const upstream = await startFilesUpstream(); + const app = await spawnApp(); + const seed = new SeedClient(etcd, app.etcdPrefix); + const model = guarded ? "files-guarded" : "files-open"; + + const pk = await seed.createProviderKey({ + display_name: `${model}-pk`, + secret: "sk-upstream-files", + api_base: `${upstream.baseUrl}/v1`, + }); + await seed.createModel({ + display_name: model, + provider: "openai", + model_name: "gpt-4o", + provider_key_id: pk.id, + }); + if (guarded) { + await seed.createGuardrail(NEVER_MATCHING_GUARDRAIL); + } + // Written LAST: the key authenticating implies every row above it + // landed (etcd applies in revision order). + await seed.createApiKey({ key_hash: sha256(key), allowed_models: ["*"] }); + + await waitConfigPropagation(async () => { + const res = await fetch(`${app.proxyUrl}/v1/models`, { + headers: { authorization: `Bearer ${key}` }, + }); + if (res.status !== 200) return false; + const body = (await res.json()) as { data?: Array<{ id?: string }> }; + return (body.data ?? []).some((m) => m.id === model); + }); + + return { app, upstream, key, model }; +}; + +const upload = (env: Env, file: Buffer) => { + const boundary = "XFILESUNSCANNABLEX"; + return fetch(`${env.app.proxyUrl}/v1/files`, { + method: "POST", + headers: { + authorization: `Bearer ${env.key}`, + "content-type": `multipart/form-data; boundary=${boundary}`, + }, + body: multipartUpload(boundary, env.model, file), + }); +}; + +describe("files: an unscannable upload is refused, not forwarded", () => { + let guarded: Env | undefined; + let open: Env | undefined; + let etcdReachable = false; + + beforeAll(async () => { + const etcd = new EtcdClient(); + etcdReachable = await etcd.ping(); + if (!etcdReachable) return; + guarded = await startEnv(etcd, GUARDED_KEY, true); + open = await startEnv(etcd, UNGUARDED_KEY, false); + }); + + afterAll(async () => { + await guarded?.app.exit(); + await guarded?.upstream.close(); + await open?.app.exit(); + await open?.upstream.close(); + }); + + test("non-UTF-8 blob with a guardrail attached: refused, provider never contacted", async (ctx) => { + if (!etcdReachable || !guarded) { + ctx.skip(); + return; + } + const res = await upload(guarded, NON_UTF8_LINE); + expect(res.status).toBe(422); + const body = (await res.json()) as { + error?: { type?: string; code?: string; message?: string }; + }; + expect(body.error?.type).toBe("content_filter"); + expect(body.error?.code).toBe("guardrail_unavailable"); + // The caller must be able to tell an unscannable body from a policy + // hit — they are the same status and type. + expect(body.error?.message).toContain("unscannable_body"); + expect(guarded.upstream.uploads).toHaveLength(0); + }); + + test("the same blob with NO guardrail attached is unaffected", async (ctx) => { + if (!etcdReachable || !open) { + ctx.skip(); + return; + } + const res = await upload(open, NON_UTF8_LINE); + expect(res.status).toBe(200); + expect(open.upstream.uploads).toHaveLength(1); + expect(open.upstream.uploads[0].includes(NON_UTF8_LINE)).toBe(true); + }); + + test("a clean UTF-8 blob still scans and forwards", async (ctx) => { + if (!etcdReachable || !guarded) { + ctx.skip(); + return; + } + // Counted rather than asserted at length 1: this env is shared with + // the first leg, whose whole point is that it forwarded nothing. + const before = guarded.upstream.uploads.length; + const res = await upload(guarded, CLEAN_LINE); + expect(res.status).toBe(200); + expect(guarded.upstream.uploads).toHaveLength(before + 1); + expect(guarded.upstream.uploads[before].includes(CLEAN_LINE)).toBe(true); + }); +}); From 6799c6cacbb60e8e9002c62a7cfe1857a8d2186a Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 2 Sep 2026 15:13:42 +0000 Subject: [PATCH 2/3] test(e2e): drain the non-ready response body in the files propagation gate An unread body holds the socket, and a failure while reading it should surface from the gate rather than as a propagation timeout. --- tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts b/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts index 06554dce7..9571ed332 100644 --- a/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts +++ b/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts @@ -148,7 +148,12 @@ const startEnv = async ( const res = await fetch(`${app.proxyUrl}/v1/models`, { headers: { authorization: `Bearer ${key}` }, }); - if (res.status !== 200) return false; + if (res.status !== 200) { + // Drain it: an unread body leaves the socket held, and a failure + // while reading should surface here rather than as a gate timeout. + await res.text(); + return false; + } const body = (await res.json()) as { data?: Array<{ id?: string }> }; return (body.data ?? []).some((m) => m.id === model); }); From ec2ad8954e744f55739be3494de21b014624aca9 Mon Sep 17 00:00:00 2001 From: Jarvis Date: Wed, 2 Sep 2026 15:17:56 +0000 Subject: [PATCH 3/3] docs(jobs): record the input/output asymmetry the #1022 fix leaves behind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scan_output_blob` was documented as the "output-side twin" of `scan_input_blob`. After the #1022 fix that is false: the input side refuses a blob it cannot decode, while the output side still scans `from_utf8_lossy` and still relays the original bytes, so the same evasion remains open on `GET /v1/files/{id}/content`. The asymmetry is deliberate — a download carries whatever the provider holds under a file id, so failing closed there would refuse lawful binary, whereas an upload's bytes come from the caller and a batch/fine-tune input is contractually UTF-8 JSONL. Closing it needs a product decision, not a mirrored match. State that where the next reader will look, instead of leaving a comment asserting a symmetry that no longer holds. Also renames the two "still scans and forwards" cases: neither asserts the scanning half, and a never-matching keyword leaves nothing to assert it with (no enforced hit, no monitor hit, no `applied` on the jobs usage event). They now say what they check. --- crates/aisix-proxy/src/jobs.rs | 28 +++++++++++++++---- .../files-unscannable-upload-e2e.test.ts | 2 +- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/crates/aisix-proxy/src/jobs.rs b/crates/aisix-proxy/src/jobs.rs index 4b4e945f0..c318f5019 100644 --- a/crates/aisix-proxy/src/jobs.rs +++ b/crates/aisix-proxy/src/jobs.rs @@ -533,7 +533,19 @@ async fn scan_input_blob( Ok(()) } -/// Output-side twin of [`scan_input_blob`]. +/// Output-side counterpart of [`scan_input_blob`] — but NOT a symmetric +/// one, and deliberately not renamed to hide that. +/// +/// The input side refuses a blob it cannot decode (#1022). This side still +/// scans `from_utf8_lossy` and still relays the original bytes, so the same +/// evasion is open on `GET /v1/files/{id}/content`, whose `relay_raw_body` +/// arm returns the provider's bytes untouched. That is not an oversight to +/// tidy up in passing: the download path legitimately carries binary the +/// caller never chose (whatever the provider holds under a file id), +/// whereas an upload's bytes come from the caller and a batch/fine-tune +/// input is contractually UTF-8 JSONL. Failing closed here would refuse +/// lawful downloads, so the direction needs a product decision rather than +/// a mirrored `match`. Tracked in #1022. async fn scan_output_blob( state: &ProxyState, auth: &AuthenticatedKey, @@ -2914,12 +2926,16 @@ mod tests { ); } - /// The existing whole-blob scan is unchanged for an upload that does - /// decode: the chain runs, finds nothing, and the file forwards. - /// (`blocked_upload_names_the_policy_on_the_usage_event` pins the - /// other half — a matching pattern still blocks.) + /// An upload that DOES decode is still forwarded with a chain + /// attached — the new arm must not catch it. + /// + /// Named for what it asserts: that the chain actually ran is not + /// observable here, because a never-matching keyword produces no + /// enforced hit, no monitor hit, and no `applied` field on the jobs + /// usage event. `blocked_upload_names_the_policy_on_the_usage_event` + /// is what pins that the chain runs at all. #[tokio::test] - async fn clean_utf8_upload_still_scans_and_forwards() { + async fn decodable_upload_with_a_chain_attached_still_forwards() { let upstream = MockServer::start().await; files_upstream_mock().expect(1).mount(&upstream).await; diff --git a/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts b/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts index 9571ed332..3c45c9698 100644 --- a/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts +++ b/tests/e2e/src/cases/files-unscannable-upload-e2e.test.ts @@ -222,7 +222,7 @@ describe("files: an unscannable upload is refused, not forwarded", () => { expect(open.upstream.uploads[0].includes(NON_UTF8_LINE)).toBe(true); }); - test("a clean UTF-8 blob still scans and forwards", async (ctx) => { + test("a decodable blob with a guardrail attached is still forwarded", async (ctx) => { if (!etcdReachable || !guarded) { ctx.skip(); return;