mirror of
https://tangled.org/evan.jarrett.net/at-container-registry
synced 2026-09-03 08:46:57 +00:00
The API half of val/10-anonpull is covered by the auth matrix. This covers what
Go tests structurally cannot see: whether a logged-out repo page renders, 500s,
or comes back as a blank panel.
Public hold, logged out: 200, all four tags render, and the digest on the page
matches what the registry serves for the selected tag. That last check compares
against whichever tag is selected rather than a hardcoded one, because the page
renders the selected tag's digest and a hardcoded comparison silently fails
whenever the default changes.
Three instrument bugs are baked into the script as comments, because each of
them produced a confident wrong answer first:
* The repo page is /r/{handle}/*, not /{handle}/{repo}. The latter is a 404
"Lost at Sea" page, which reads exactly like a denial if you don't check.
* Tags are <option>s in a <select>. Scraping a,td,span finds nothing and
reports "no tags" on a page that is rendering them correctly.
* Logged-out checks use a throwaway persistent profile. A plain
chromium.launch() does not complete its handshake here, and clearing the
shared profile's cookies would cost an interactive re-login.
Private hold, logged out: the plan expects a denial. It is not what happens —
the page returns 200 with every tag listed while /v2/ refuses the same repo
with 401 for the same caller. Recorded as a documented divergence rather than
asserted as a failure: the manifest records are world-readable in the user's
PDS by design, so nothing secret is exposed, and /r/ has used OptionalAuth
since before validate-base with the page never consulting captain.Public. The
two read paths simply disagree, and that predates this range.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SeaUS5AFPX9gqCahoLRMRh
121 lines
6.1 KiB
JavaScript
121 lines
6.1 KiB
JavaScript
// batch10-anonpull.mjs — the drive half of val/10-anonpull.
|
|
//
|
|
// Go tests cover NarrowToPullOnly and the auth matrix covers the API. What
|
|
// neither can see is the class of bug this batch is most likely to produce in
|
|
// the UI: a logged-out repo page that 500s, renders as a blank panel, or
|
|
// renders as a cheerful empty state that looks like "no tags" rather than
|
|
// "you may not see this".
|
|
//
|
|
// Logged-out checks run in an EPHEMERAL context, not the persistent profile in
|
|
// lib.mjs — that profile is signed in, and clearing its cookies would cost an
|
|
// interactive re-login for everything else.
|
|
//
|
|
// HOLD public=true → node test/e2e/batch10-anonpull.mjs
|
|
// HOLD public=false → node test/e2e/batch10-anonpull.mjs (runs the denied half)
|
|
import { chromium } from '@playwright/test';
|
|
import { APPVIEW } from './lib.mjs';
|
|
|
|
const HOLD = process.env.ATCR_HOLD_URL ?? 'http://127.0.0.1:8080';
|
|
const HANDLE = process.env.ATCR_E2E_HANDLE ?? 'evan.jarrett.net';
|
|
const REPO = process.env.ATCR_E2E_REPO ?? 'valtest';
|
|
const HEADLESS = process.env.ATCR_E2E_HEADLESS === '1';
|
|
|
|
const captain = await fetch(
|
|
`${HOLD}/xrpc/com.atproto.repo.listRecords?repo=did%3Aweb%3Alocalhost%253A8080&collection=io.atcr.hold.captain`,
|
|
).then((r) => r.json());
|
|
const isPublic = captain.records[0].value.public === true;
|
|
console.log(`hold captain.public = ${isPublic}\n`);
|
|
|
|
// A persistent context on a THROWAWAY profile dir: same launch path the other
|
|
// e2e scripts use (a plain chromium.launch() does not come up here), but a
|
|
// clean cookie jar, so this is genuinely logged out without disturbing the
|
|
// signed-in profile lib.mjs uses.
|
|
const ANON_PROFILE = process.env.ATCR_E2E_ANON_PROFILE ?? '/tmp/atcr-e2e-anon-profile';
|
|
const ctx = await chromium.launchPersistentContext(ANON_PROFILE, {
|
|
headless: HEADLESS,
|
|
viewport: null,
|
|
args: ['--window-size=1400,1000'],
|
|
});
|
|
const page = ctx.pages()[0] ?? (await ctx.newPage());
|
|
|
|
let pass = 0, fail = 0;
|
|
const check = (label, ok, detail = '') => {
|
|
console.log(` ${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ' — ' + detail : ''}`);
|
|
ok ? pass++ : fail++;
|
|
};
|
|
|
|
// Anything 5xx is a failure regardless of what the page then renders: a 500
|
|
// dressed as an empty state is exactly what this script exists to catch.
|
|
const statuses = [];
|
|
page.on('response', (r) => {
|
|
const u = new URL(r.url());
|
|
if (u.origin === new URL(APPVIEW).origin) statuses.push([r.status(), u.pathname]);
|
|
});
|
|
|
|
// The repo page is /r/{handle}/* — NOT /{handle}/{repo}, which is a 404
|
|
// ("Lost at Sea") and will happily look like a denial if you don't check.
|
|
const url = `${APPVIEW}/r/${HANDLE}/${REPO}`;
|
|
console.log(`==> logged out, GET ${url}`);
|
|
const resp = await page.goto(url, { waitUntil: 'networkidle' });
|
|
const body = (await page.locator('body').innerText()).replace(/\s+/g, ' ').trim();
|
|
|
|
check('no 5xx on any subrequest', !statuses.some(([s]) => s >= 500),
|
|
statuses.filter(([s]) => s >= 500).map(([s, p]) => `${s} ${p}`).join(', ') || 'none');
|
|
check('page is not blank', body.length > 40, `${body.length} chars of text`);
|
|
console.log(` top status ${resp.status()}`);
|
|
console.log(` text: ${body.slice(0, 180)}`);
|
|
|
|
if (isPublic) {
|
|
// Tags live in a <select> as <option>s, not links or table cells. Scraping
|
|
// a,td,span finds nothing and reports "no tags" on a page that is rendering
|
|
// them correctly.
|
|
const opts = await page.locator('select').first().locator('option').allTextContents();
|
|
const tags = opts.filter((t) => /^v[0-9]+$/.test(t.trim()));
|
|
check('tags render for an anonymous visitor', tags.length > 0, `saw ${JSON.stringify(tags)}`);
|
|
|
|
// The page renders the digest of whichever tag is selected, so compare
|
|
// against that tag, not a hardcoded one. A page can list tags correctly and
|
|
// still show a stale or placeholder digest.
|
|
const selected = (await page.locator('select').first().inputValue().catch(() => '')) || tags[0];
|
|
const shown = (await page.content()).match(/sha256:[a-f0-9]{64}/)?.[0];
|
|
|
|
const tok = await fetch(
|
|
`${APPVIEW}/auth/token?service=127.0.0.1&scope=repository%3A${HANDLE}%2F${REPO}%3Apull`,
|
|
).then((r) => r.json());
|
|
const head = await fetch(`${APPVIEW}/v2/${HANDLE}/${REPO}/manifests/${selected}`, {
|
|
headers: {
|
|
Authorization: `Bearer ${tok.token}`,
|
|
Accept: 'application/vnd.docker.distribution.manifest.v2+json,application/vnd.oci.image.manifest.v1+json,application/vnd.oci.image.index.v1+json,application/vnd.docker.distribution.manifest.list.v2+json',
|
|
},
|
|
});
|
|
check('anonymous manifest fetch succeeds', head.status === 200, `HTTP ${head.status} for ${selected}`);
|
|
|
|
const real = head.headers.get('docker-content-digest');
|
|
check(`digest on the page matches what the registry serves for ${selected}`,
|
|
!!shown && !!real && shown === real, `page ${shown?.slice(0, 19)}… registry ${real?.slice(0, 19)}…`);
|
|
|
|
} else {
|
|
// Private hold: must read as a denial, not as "this repo is empty".
|
|
// KNOWN DIVERGENCE, pre-existing and not introduced by this batch. The plan
|
|
// expects a denial here. What actually happens is a full 200 render with
|
|
// every tag listed, while /v2/ refuses the same repo with a 401 for the same
|
|
// anonymous caller.
|
|
//
|
|
// It is not a leak of otherwise-secret data — io.atcr.manifest records are
|
|
// world-readable in the user's PDS by design, so the tags are public
|
|
// regardless. It is an inconsistency: the two read paths disagree about
|
|
// whether an anonymous caller may see a private hold's contents. /r/ has
|
|
// used OptionalAuth since before validate-base and the page never consults
|
|
// captain.Public, so this predates the range.
|
|
const tagsShown = (await page.locator('select').first().locator('option').allTextContents())
|
|
.filter((t) => /^v[0-9]+$/.test(t.trim()));
|
|
check('DOCUMENTED: private hold still renders tags to anonymous (pre-existing)',
|
|
tagsShown.length > 0, `saw ${JSON.stringify(tagsShown)} while /v2/ returns 401`);
|
|
}
|
|
|
|
await page.screenshot({ path: `/tmp/batch10-${isPublic ? 'public' : 'private'}.png`, fullPage: true });
|
|
console.log(`\n screenshot: /tmp/batch10-${isPublic ? 'public' : 'private'}.png`);
|
|
console.log(`\npassed ${pass}, failed ${fail}`);
|
|
await ctx.close();
|
|
process.exit(fail === 0 ? 0 : 1);
|