Files
peter 827df7494c fix(files): ending every session ends every API token, and the share links hold their folder
- A password reset or change, "log out everywhere" and a revoked untrusted password revoke all of the
  account's API tokens too (audited): one minted from a stolen session no longer outlives the answer.
- A bearer token or link grant with a non-ASCII character is refused (401/403), not a 500.
- Making a token's grants or a folder link holds the folder, so a racing delete cannot fail the
  foreign key; a link's view count is one atomic UPDATE.
- A folder link's card counts exactly the files its page lists (one `links.folder_files`).
- A file with no extension is served without a trailing dot.
- 0106's downgrade names a Hub folder so it never collides with one of the user's folders.
- The speed baseline reads share tokens through the app (the database keeps them hashed/encrypted).
- Tests: the token scope on the real session stack, the folder poster, the Hub refused for a link,
  0106's downgrade data step, 0107 without a key; docs/comments follow (three folder kinds, folder
  links, key change, the cleanup job's token warnings).
2026-10-15 04:58:16 +02:00

141 lines
5.7 KiB
TypeScript

import { test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { mkdirSync, writeFileSync } from "node:fs";
import { retryConnect } from "./retryConnect";
/**
* The speed baseline: numbers, not assertions — read by `scripts/audit_metrics.py --speed`, which
* runs this as the `speed` project (never part of the e2e suite: its file name is not a `.spec`).
* Each reading is the median of RUNS cold page loads (a fresh browser context, so no HTTP cache),
* against the localdev stack and the seeded E2E account. It is a baseline for before/after within
* one machine, not an absolute figure: the same measurement on a phone over the internet is slower.
*
* Not measured here: the in-app players (the E2E account owns no download, Plex item or watch
* history to open), and any iOS reading (Playwright's WebKit decodes media through the host OS —
* see playwright.config.ts) — the owner's phone gives that one by hand.
*/
const RUNS = 5;
const OUT = "e2e/.speed/latest.json";
const CONTAINER = process.env.E2E_API_CONTAINER || "siftlode-api-1";
const median = (xs: number[]) => [...xs].sort((a, b) => a - b)[Math.floor(xs.length / 2)] ?? NaN;
/** JavaScript the page pulled in, uncompressed: the same on every stack (localdev serves it raw,
* prod gzips it at the proxy), and what the browser has to parse. */
const scriptBytes = (page: Page) =>
page.evaluate(() =>
(performance.getEntriesByType("resource") as PerformanceResourceTiming[])
.filter((r) => r.initiatorType === "script" || r.name.endsWith(".js"))
.reduce((sum, r) => sum + r.decodedBodySize, 0),
);
/** Public share links on the local stack that could play: no password, not expired, asset ready.
* The page itself is the judge of the last step (its file must be on THIS disk — a localdev DB
* seeded from prod lists assets whose files never came along), so the caller probes them. Read
* through the API container: the database keeps a link's token only hashed and encrypted, and only
* the app holds the key to read it back (`links.recover`). */
const SHARE_TOKENS_PY = `
from sqlalchemy import select
from app.db import SessionLocal
from app.downloads import links
from app.models import DownloadJob, DownloadLink, MediaAsset
with SessionLocal() as db:
for link in db.execute(
select(DownloadLink)
.join(DownloadJob, DownloadJob.id == DownloadLink.job_id)
.join(MediaAsset, MediaAsset.id == DownloadJob.asset_id)
.where(DownloadLink.password_hash.is_(None), MediaAsset.status == "ready")
.order_by(DownloadLink.id)
).scalars():
token = None if links.is_expired(link) else links.recover(link)
if token:
print(token)
`;
function shareTokens(): string[] {
// Not caught: an API container this cannot reach (no docker, a renamed container) is a failed
// measurement, not "no playable share link on the local stack".
const out = execFileSync("docker", ["exec", CONTAINER, "python", "-c", SHARE_TOKENS_PY], {
encoding: "utf8",
});
return out
.split("\n")
.map((t) => t.trim())
.filter(Boolean);
}
test("speed baseline", async ({ browser, page: page0 }) => {
test.setTimeout(180_000);
const result: Record<string, number | string | null> = {
measured_at: new Date().toISOString(),
runs: RUNS,
};
// Feed: navigation start → the first video card on screen.
const feedMs: number[] = [];
let feedJs = 0;
for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext();
const page = await ctx.newPage();
await page.goto("/");
// Timed inside the page, every frame: an `expect` would read the clock on its own retry
// schedule (100 ms … 1 s steps), not when the card appeared.
const at = await page.waitForFunction(
() => document.querySelector('[data-testid="video-card"]') && performance.now(),
null,
{ polling: "raf", timeout: 30_000 },
);
feedMs.push((await at.jsonValue()) as number);
feedJs = await scriptBytes(page);
await ctx.close();
}
result.feed_first_card_ms = Math.round(median(feedMs));
result.feed_js_raw_bytes = feedJs;
// /watch: navigation start → the shared video playing (started muted by script, standing in for
// the visitor's tap — the page has no autoplay).
let token: string | null = null;
for (const t of shareTokens()) {
if ((await retryConnect(page0.request).get(`/api/public/watch/${t}`)).ok()) {
token = t;
break;
}
}
if (token) {
const watchMs: number[] = [];
let watchJs = 0;
for (let i = 0; i < RUNS; i++) {
const ctx = await browser.newContext({ storageState: { cookies: [], origins: [] } });
const page = await ctx.newPage();
await page.goto(`/watch/${token}`);
await page.waitForFunction(() => document.querySelector("video, audio"), null, {
polling: "raf",
timeout: 30_000,
});
const video = page.locator("video, audio").first();
watchMs.push(
await video.evaluate(
(el: HTMLMediaElement) =>
new Promise<number>((resolve, reject) => {
el.muted = true;
el.addEventListener("playing", () => resolve(performance.now()), { once: true });
el.play().catch(reject);
}),
),
);
watchJs = await scriptBytes(page);
await ctx.close();
}
result.watch_time_to_play_ms = Math.round(median(watchMs));
result.watch_js_raw_bytes = watchJs;
} else {
result.watch_time_to_play_ms = null;
result.watch_js_raw_bytes = null;
result.watch_note = "no playable share link on the local stack";
}
mkdirSync("e2e/.speed", { recursive: true });
writeFileSync(OUT, JSON.stringify(result, null, 2) + "\n");
});