fix(uploads): finish the sweeps round 1 left half-done
Deep-review round 2, on round 1's own fix commit. It found MORE than round 1 did (28 raw, against 13), and most of it was mine -- a new piece of UI attracts findings a backend-only diff never does. The pattern in almost all of them is the same: a principle applied to the site that prompted it and not to its siblings. * `display_text` reached three of the FIVE writers of a user-supplied name. `POST /api/downloads` and `POST /api/downloads/edit` still handed Postgres an unbounded string, so a 300-character name pasted into the Add-download dialog or the editor was a bare 500 (main.py re-raises every DataError but the NUL case). Four of the seven reviewers found it independently. The bound now lives in `models.py`, the three columns are DECLARED with it, and the guard for the next writer is parsed rather than remembered: every `display_name` / `display_uploader` assignment under `app/routes/` must go through the helper. That guard immediately found two more -- the Google sign-in callback writes the profile name into the same 255-wide column -- so it is six, not five. * The connection release reached one of the THREE routes that read their own body. The screenshot route (10 MiB, from a phone) and the client log sink both still held a pooled connection idle-in-transaction for the length of the upload, which is the invariant db.py sizes the pool on. All three now call `db.release_read_connection`, whose precondition -- nothing pending on the shared session -- is CHECKED rather than described, because a whole-session rollback discarding a caller's staged write is a mistake this repo has already made once. `test_body_limit.py` holds the rule for the next such route. * The AST drift registry keyed its sites by (module, limit expression), so a second route copied from the first collapsed onto the same entry and went uncovered -- the exact hole the registry exists to close. Keyed per call site now, and it carries the release requirement too. * The dialog's own refusal was announced once and never again: two over-cap picks leave an identical node with identical text, and an untouched live region says nothing. It is keyed per pick now, the drop zone reads as invalid and points at the sentence, and the reason is reachable from the control rather than only from a one-shot announcement. * Two tests rebuilt the blind spot they were written to remove: the dialog's cap test sat nowhere near the boundary (a `>` loosened to `>=` survived it, which is the mutation round 1 fixed on the server side), and the header-precedence test only covered an exact-case duplicate while a differently-cased one would have been COMBINED onto the wire rather than replaced. Both fixed, and the merge now dedupes case-insensitively because header names are. Also: the upload dialog's unstubbed usage query was firing a real request from jsdom in a neighbouring suite and leaking a delayed "connection lost" toast across test boundaries; `GET /api/downloads/usage` had no test at all though the whole client-side check hangs off it; and the comment claiming the edge 413 is readable now says what was measured -- readable to 200 MiB over loopback, a bare `onerror` through a proxy -- so the pre-send check is named as the mechanism and that branch as the backstop it is. Every test added or changed here was mutation-checked. One mutation SURVIVED and is recorded rather than papered over: the `tooBig` term inside `submit()` cannot be reached, because `ui/Button` swallows the activation itself, so it is marked as belt and braces instead of being described as the guard.
This commit is contained in:
@@ -84,30 +84,79 @@ describe("UploadDialog", () => {
|
||||
expect(btn.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it("refuses an over-cap file here, naming the limit, instead of sending it", async () => {
|
||||
// `file.size > cap`, on the boundary in both directions — the cap is inclusive server-side
|
||||
// (backend test_upload.py pins that a body of EXACTLY the cap is accepted), so a dialog that
|
||||
// refused it would make a legal file un-uploadable with no way round. Two sizes either side of
|
||||
// one cap, because a single over-cap case passes just as happily with `>` loosened to `>=`.
|
||||
const CAP = 8;
|
||||
const pickFile = (bytes: number, name = "f.bin") =>
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["x".repeat(bytes)], name)] },
|
||||
});
|
||||
|
||||
it("refuses a file one byte over the cap, naming the limit, instead of sending it", async () => {
|
||||
// Both server-side caps answer only after the transfer — and on a default install the one that
|
||||
// answers is the edge ceiling, whose refusal carries no number at all. So the dialog checks the
|
||||
// size itself: the user learns the limit before a byte moves.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 4 });
|
||||
// answers is the edge ceiling, whose refusal carries no number at all and may not even reach
|
||||
// the browser. So the dialog checks the size itself: the user learns the limit before a byte
|
||||
// moves.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
fireEvent.change(fileInput(), {
|
||||
target: { files: [new File(["far too many bytes"], "big.bin")] },
|
||||
});
|
||||
pickFile(CAP + 1, "big.bin");
|
||||
|
||||
await waitFor(() => expect(screen.getByText("downloads.errors.upload.tooLarge")).toBeTruthy());
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
expect(btn.disabled).toBe(true);
|
||||
// The drop zone carries the reason, because a natively disabled Submit is not in the tab order
|
||||
// to be asked for it.
|
||||
const zone = screen.getByText("big.bin").closest("button")!;
|
||||
expect(zone.getAttribute("aria-invalid")).toBe("true");
|
||||
expect(zone.getAttribute("aria-describedby")).toBe(screen.getByRole("alert").id);
|
||||
});
|
||||
|
||||
it("swallows a Submit press that arrives while the button still holds focus", async () => {
|
||||
// The OTHER half of "never sent": the test above covers the natively disabled button (not
|
||||
// focused), this one covers the soft-disabled one. `ui/Button` is focus-safe, so a button that
|
||||
// turns unavailable while it HOLDS focus stays in the tab order and stays clickable, and it is
|
||||
// the primitive's own click guard that refuses — measured: deleting the `tooBig` term from
|
||||
// `submit()` changes nothing here, which is why that term is documented as belt and braces
|
||||
// rather than as the mechanism. What this pins is the user-visible contract: pressing Upload
|
||||
// after dropping a bigger file does not start a transfer the server will refuse.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP, "fits.bin");
|
||||
const btn = screen.getByText("downloads.upload.submit").closest("button")!;
|
||||
btn.focus();
|
||||
fireEvent.focus(btn);
|
||||
pickFile(CAP + 1, "toobig.bin");
|
||||
await waitFor(() => expect(screen.getByRole("alert")).toBeTruthy());
|
||||
expect(btn.disabled).toBe(false); // soft-disabled, still focusable — and still clickable
|
||||
fireEvent.click(btn);
|
||||
expect(h.uploadDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends a file that fits, so the check above is not simply refusing everything", async () => {
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: 1_000_000 });
|
||||
it("sends a file of EXACTLY the cap, which the server accepts", async () => {
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
fireEvent.change(fileInput(), { target: { files: [new File(["small"], "ok.bin")] } });
|
||||
pickFile(CAP, "exact.bin");
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
fireEvent.click(screen.getByText("downloads.upload.submit"));
|
||||
await waitFor(() => expect(h.uploadDownload).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("re-announces the refusal when a SECOND over-cap file is picked", async () => {
|
||||
// Failing the same way again has to be announced again. The message names the CAP, not the
|
||||
// file, so two over-cap picks leave the node and its text identical — and a live region that
|
||||
// is never touched says nothing. See formAlerts.dom.test.tsx, which holds the same rule for
|
||||
// the three other alerts in this app that can repeat.
|
||||
h.downloadUsage.mockResolvedValue({ upload_max_bytes: CAP });
|
||||
wrap();
|
||||
await waitFor(() => expect(h.downloadUsage).toHaveBeenCalled());
|
||||
pickFile(CAP + 1, "a.bin");
|
||||
const first = await screen.findByRole("alert");
|
||||
pickFile(CAP + 2, "b.bin");
|
||||
await waitFor(() => expect(screen.getByRole("alert")).not.toBe(first));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useEffect, useId, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { UploadCloud, File as FileIcon } from "lucide-react";
|
||||
@@ -12,6 +12,7 @@ import { invalidateDownloads } from "../lib/downloads";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { navigateTo } from "../lib/nav";
|
||||
import { formatBytes } from "../lib/format";
|
||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
||||
import { inputCls } from "./ui/form";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
|
||||
@@ -40,6 +41,18 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const cap = useQuery({ queryKey: qk.downloadUsage(), queryFn: api.downloadUsage }).data
|
||||
?.upload_max_bytes;
|
||||
const tooBig = !!file && !!cap && file.size > cap;
|
||||
const refusalId = useId();
|
||||
// Every pick gets a new number, so the refusal below REMOUNTS rather than being reconciled. Two
|
||||
// over-cap picks in a row leave the node and its text identical — the message names the cap, not
|
||||
// the file — and a live region that is not touched is not announced, so the second refusal was
|
||||
// silent to a screen reader. `KeyGate`, `SetupWizard` and `Welcome` all key their alerts for the
|
||||
// same reason, and `formAlerts.dom.test.tsx` is the test that exists because of it.
|
||||
const [pickSeq, setPickSeq] = useState(0);
|
||||
const pick_ = (f: File) => {
|
||||
setFile(f);
|
||||
setName("");
|
||||
setPickSeq((n) => n + 1);
|
||||
};
|
||||
|
||||
// Closing the dialog mid-upload (X / ESC / backdrop / browser Back all unmount us) must abort the
|
||||
// in-flight XHR — otherwise it keeps streaming in the background and still toasts success. Only the
|
||||
@@ -49,6 +62,10 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
const pick = () => inputRef.current?.click();
|
||||
|
||||
const submit = async () => {
|
||||
// `tooBig` here is belt and braces, NOT the mechanism: `ui/Button` is focus-safe, so while the
|
||||
// button holds focus it swallows the activation itself, and while it does not it is natively
|
||||
// disabled. Measured — deleting this term breaks nothing, which is exactly why it must not be
|
||||
// described as the guard. It is here for the next caller of `submit`, not for the only one.
|
||||
if (!file || busy || tooBig) return;
|
||||
const ac = new AbortController();
|
||||
abortRef.current = ac;
|
||||
@@ -103,14 +120,20 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
dragDepth.current = 0;
|
||||
setDragOver(false);
|
||||
const f = e.dataTransfer.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
if (f) pick_(f);
|
||||
}}
|
||||
disabled={busy}
|
||||
// The refusal is tied to the control it invalidates, not left to a one-shot announcement:
|
||||
// the drop zone reads as invalid and points at the sentence, so a reader who tabs back to
|
||||
// it hears WHY, and the border says the same thing without colour being the only signal.
|
||||
aria-invalid={tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
className={`w-full flex flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed px-4 py-8 text-center transition disabled:opacity-60 ${
|
||||
dragOver ? "border-accent bg-accent/5" : "border-border hover:border-fg/40"
|
||||
tooBig
|
||||
? "border-danger"
|
||||
: dragOver
|
||||
? "border-accent bg-accent/5"
|
||||
: "border-border hover:border-fg/40"
|
||||
}`}
|
||||
>
|
||||
{file ? (
|
||||
@@ -134,31 +157,31 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const f = e.target.files?.[0];
|
||||
if (f) {
|
||||
setFile(f);
|
||||
setName("");
|
||||
}
|
||||
if (f) pick_(f);
|
||||
e.target.value = ""; // let re-picking the same file fire onChange again
|
||||
}}
|
||||
/>
|
||||
|
||||
{tooBig && (
|
||||
<p className="text-xs text-danger mt-2" role="alert">
|
||||
<p key={pickSeq} id={refusalId} className="text-xs text-danger mt-2" role="alert">
|
||||
{t("downloads.errors.upload.tooLarge", { size: formatBytes(cap ?? 0) })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<label className="block text-sm font-medium mt-4 mb-1">
|
||||
<label htmlFor="upload-display-name" className="block text-sm font-medium mt-4 mb-1">
|
||||
{t("downloads.upload.nameLabel")}
|
||||
</label>
|
||||
<input
|
||||
id="upload-display-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder={t("downloads.upload.namePlaceholder")}
|
||||
disabled={busy}
|
||||
// The column behind it is varchar(255) and the name rides in a REQUEST HEADER, where nothing
|
||||
// else bounds it; the server truncates to the same number (downloads.service.display_text).
|
||||
maxLength={255}
|
||||
// The column behind it is varchar(255) and the name rides in a REQUEST HEADER, where
|
||||
// nothing else bounds it; the server truncates to the same number. The number is not a
|
||||
// third copy: `downloads.contract.test.ts` holds DISPLAY_NAME_MAX against the backend's
|
||||
// own DISPLAY_TEXT_MAX.
|
||||
maxLength={DISPLAY_NAME_MAX}
|
||||
className={`${inputCls} mb-5`}
|
||||
/>
|
||||
|
||||
@@ -193,7 +216,19 @@ export default function UploadDialog({ onClose }: { onClose: () => void }) {
|
||||
>
|
||||
{t("downloads.actions.cancel")}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={submit} disabled={!file || busy || tooBig}>
|
||||
{/* Plain `disabled` — `ui/Button` is a FocusSafeButton, which already expresses it as
|
||||
`aria-disabled` + a click guard for as long as the button HOLDS focus, so this cannot
|
||||
strand a keyboard user on <body> when the cap arrives late or a second file is dropped.
|
||||
`submit` refuses independently, which is what that soft state relies on. The REASON is
|
||||
reachable from the drop zone (aria-invalid + aria-describedby above) rather than from
|
||||
here, because a natively disabled button is not in the tab order to be asked. */}
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!file || busy || tooBig}
|
||||
aria-describedby={tooBig ? refusalId : undefined}
|
||||
>
|
||||
{t("downloads.upload.submit")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -126,6 +126,17 @@ describe("the upload dialog", () => {
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
// The dialog reads the per-file cap on mount. Unstubbed it is a REAL fetch from jsdom, which
|
||||
// fails, retries once after 250 ms and then raises the "connection lost" notice — a delayed
|
||||
// toast that outlives the test that spawned it and lands as an unexpected error in whichever
|
||||
// test is running by then. The file is fast enough today that it always lands after the suite,
|
||||
// which is a coincidence, not a guarantee (deep-review round 2).
|
||||
vi.spyOn(api, "downloadUsage").mockResolvedValue({
|
||||
upload_max_bytes: 1_000_000,
|
||||
} as Awaited<ReturnType<typeof api.downloadUsage>>);
|
||||
});
|
||||
|
||||
it("names an upload the server refused", async () => {
|
||||
// A 502 from the proxy raises only the self-clearing connection notice; the next successful poll
|
||||
// wipes it, and the dialog snapped back with no error and no job in the list.
|
||||
|
||||
Reference in New Issue
Block a user