Series URL repair: owner-typed, gated by the poller's own fetch gate (#151)
This commit is contained in:
@@ -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"><script>alert(1)</script>"`) {
|
||||
t.Errorf("repair input does not carry the escaped stored URL:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user