Series URL repair: owner-typed, gated by the poller's own fetch gate (#151)

This commit is contained in:
2026-08-22 09:21:00 +07:00
parent 448631c78e
commit 424d2c6600
15 changed files with 265 additions and 22 deletions
+121
View File
@@ -3371,3 +3371,124 @@ func TestAdminSeriesDetailCorrectionMarker(t *testing.T) {
t.Errorf("detail page does not show the machine-written number:\n%s", body)
}
}
// The series URL repair validates with the poller's own fetch gate and
// answers 400 before anything reaches the store; a URL that passes the gate
// is stored where an Upsert would have ignored it. The request performs no
// outbound fetch — no fetcher is ever constructed on this path (the web
// router has no fetcher seam at all, and the handler only calls the store),
// so "storing is not verifying" is enforced by construction (#151).
func TestSeriesURLRepairRoute(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:solo", url: "https://asurascans.com/comics/solo", checkedAt: 9000, bookmarks: 1,
})
router := newRouter(st, testConfig())
cookie := sessionCookie(t, st)
// A URL the gate refuses — foreign host, http scheme, host of another
// Site — answers 400 and never reaches the store.
for _, body := range []string{
"series_url=https://evil.example/solo",
"series_url=http://asurascans.com/stories/solo",
"series_url=https://kagane.to/series/solo",
"series_url=",
} {
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url", strings.NewReader(body))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Errorf("POST series-url with body %q: status = %d, want 400", body, rr.Code)
}
}
capReq := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url",
strings.NewReader("series_url=https://asurascans.com/stories/"+strings.Repeat("a", 1<<17)))
capReq.Header.Set("Content-Type", "application/x-www-form-urlencoded")
capReq.AddCookie(cookie)
capRR := httptest.NewRecorder()
router.ServeHTTP(capRR, capReq)
if capRR.Code != http.StatusBadRequest {
t.Errorf("POST series-url with an oversized body: status = %d, want 400", capRR.Code)
}
var stored string
if err := db.QueryRow(`SELECT series_url FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != "https://asurascans.com/comics/solo" {
t.Fatalf("after 400s the stored URL = %q, want the seeded one untouched", stored)
}
// A URL that passes the gate lands, and the press answers with the meta
// fragment just like the other detail-page actions.
repair := "https://asurascans.com/stories/solo-renumbered"
req := httptest.NewRequest(http.MethodPost, "/admin/series/asura:solo/series-url",
strings.NewReader("series_url="+repair))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.AddCookie(cookie)
rr := httptest.NewRecorder()
router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("POST series-url status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
}
if body := rr.Body.String(); !strings.Contains(body, `id="detail-meta"`) {
t.Errorf("repair answer is not the meta fragment:\n%s", body)
}
if err := db.QueryRow(`SELECT series_url FROM series WHERE site = 'asura' AND series_id = 'solo'`).
Scan(&stored); err != nil {
t.Fatalf("read back: %v", err)
}
if stored != repair {
t.Fatalf("stored URL = %q, want %q", stored, repair)
}
}
// The detail page offers the repair input prefilled with the stored address,
// states the honest limit — a Site-wide host change is a SQL migration, not a
// per-Series form — and a stored string renders back into the input escaped
// (issue #151).
func TestAdminSeriesDetailRepairForm(t *testing.T) {
st, dsn := newTestStoreURL(t)
db, err := sql.Open("pgx", dsn)
if err != nil {
t.Fatalf("open %s: %v", dsn, err)
}
defer db.Close()
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:solo", url: "https://asurascans.com/stories/solo", bookmarks: 1,
})
seedSeriesRow(t, st, db, seriesRowSeed{
key: "asura:evil", url: `https://asurascans.com/x"><script>alert(1)</script>`, bookmarks: 1,
})
router := newRouter(st, testConfig())
body := seriesDetailPage(t, router, st, "asura:solo")
for _, want := range []string{
`name="series_url"`,
`hx-post="/admin/series/asura:solo/series-url"`,
`value="https://asurascans.com/stories/solo"`,
"A Site-wide host change", "SQL migration",
} {
if !strings.Contains(body, want) {
t.Errorf("detail page lacks %q:\n%s", want, body)
}
}
// The stored value that is markup stays markup in the input's value
// attribute, never executable HTML.
body = seriesDetailPage(t, router, st, "asura:evil")
if strings.Contains(body, `<script>alert(1)</script>`) {
t.Errorf("repair input renders stored URL unescaped:\n%s", body)
}
if !strings.Contains(body, `value="https://asurascans.com/x&#34;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"`) {
t.Errorf("repair input does not carry the escaped stored URL:\n%s", body)
}
}