1e6f1e985d
Closes #102.
The only operational surface was /healthz and a fold-out roster inside the owner's own reading page. This adds /admin: an owner-only page carrying the Reader roster and one row per Poll Lane.
- **Poller seam.** `latest.Poller` records each Lane's last pass (`Site`, `Due`, `Checked`, `LastRun`, `Gap`, `Clamped`, `Browser`) and answers `LaneStatus()`; the page reads that snapshot, never a table. A pass that returns before computing its figures (refusal backoff, sidecar down) carries the previous pass's figures forward rather than recording zeroes, and a Lane that has never reached a pace renders no gap at all. Refusal and sidecar reachability are derived at snapshot time.
- **Owner gate at registration.** Every route reaching past the acting Reader lives in `adminRoutes()` and is wrapped in `requireOwner` when it is registered, so a missing gate is visible in the route list rather than hidden in a handler. `web.AdminPatterns()` is what the gate test walks, so a new route cannot be added without being tested. A non-owner gets 404, never 403.
- **Nil poller is a first-class state.** `main.newRouter` takes the reporter as an interface and converts a nil `*Poller` to a nil interface; no poller and no completed pass both render "No data yet" with the reason spelled out, rather than confident zeroes.
- **Roster moved** off the reading page onto /admin, with the Sighting counters and a confirm-gated `Clear marks` control. #103 fills those counters, so on delivery they read zero for everyone - deliberate ordering.
- **One accent, `--patina`** (verdigris, both colour branches): the far side of the wheel from ember's crimson and clear of the archive blue. Ember still means new chapter only; revocation still wears --danger.
Verification: `go vet ./...` and `go test ./...` green (Docker-backed); admin page screenshotted at 1100px and 390px in both colour schemes. Reviewed on both axes (spec, standards); findings on the accent hue, zero-figure honesty and three tests that could not fail are fixed in 58014eb.
Reviewed-on: #107
Co-authored-by: Sulthan Zaki <sultankiki05@gmail.com>
Co-committed-by: Sulthan Zaki <sultankiki05@gmail.com>
135 lines
5.0 KiB
Go
135 lines
5.0 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
|
|
"bookmarkmanager/backend/internal/store"
|
|
)
|
|
|
|
func getCover(t *testing.T, srv http.Handler, path string, cookie *http.Cookie) *httptest.ResponseRecorder {
|
|
t.Helper()
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
if cookie != nil {
|
|
req.AddCookie(cookie)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
return rr
|
|
}
|
|
|
|
// The acquired Cover is served from this deployment's own origin, to any
|
|
// browser rendering a third-party page — no session, no credential (ADR-0007).
|
|
func TestPublicCoverServesStoredBytesUnauthenticated(t *testing.T) {
|
|
const sourceURL = "https://cdn.asurascans.com/covers/solo.webp"
|
|
srv, st := newWebTestServer(t, testConfig())
|
|
if err := st.PutCover(sourceURL, []byte("\x00webp-bytes"), "image/webp"); err != nil {
|
|
t.Fatalf("PutCover: %v", err)
|
|
}
|
|
|
|
// The wire URL is what a client actually requests, so the path under test
|
|
// is taken from it rather than rebuilt by hand.
|
|
wire := st.CoverWireURL(store.CoverAddress(sourceURL))
|
|
path, ok := strings.CutPrefix(wire, testCoverBaseURL)
|
|
if !ok {
|
|
t.Fatalf("wire URL %q is not on the public origin %q", wire, testCoverBaseURL)
|
|
}
|
|
rr := getCover(t, srv, path, nil)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200 without any credential", rr.Code)
|
|
}
|
|
if got := rr.Body.String(); got != "\x00webp-bytes" {
|
|
t.Fatalf("body = %q, want the stored bytes", got)
|
|
}
|
|
if got := rr.Header().Get("Content-Type"); got != "image/webp" {
|
|
t.Fatalf("Content-Type = %q, want the stored one", got)
|
|
}
|
|
// Content-addressed bytes never change, so a client that has them must
|
|
// never need to ask again.
|
|
if got := rr.Header().Get("Cache-Control"); !strings.Contains(got, "immutable") {
|
|
t.Fatalf("Cache-Control = %q, want an immutable cache directive", got)
|
|
}
|
|
}
|
|
|
|
func TestPublicCoverRejectsUnknownAddress(t *testing.T) {
|
|
srv, _ := newWebTestServer(t, testConfig())
|
|
cases := map[string]string{
|
|
"unknown": "/covers/" + store.CoverAddress("https://cdn.example/never-stored.jpg"),
|
|
"malformed": "/covers/not-an-address",
|
|
"traversal": "/covers/../../etc/passwd",
|
|
"empty": "/covers/",
|
|
}
|
|
for name, path := range cases {
|
|
t.Run(name, func(t *testing.T) {
|
|
if rr := getCover(t, srv, path, nil); rr.Code == http.StatusOK {
|
|
t.Fatalf("%s: status = 200, want anything but a served body", path)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// A content type outside the image set is never echoed back. The old kagane
|
|
// proxy could fetch text/html from a challenged fetch and had to refuse it;
|
|
// the general route's only input is the store, and the store refuses to
|
|
// record anything that is not an image — but the guarantee is pinned at the
|
|
// serving boundary, not the write gate, so a poisoned row (migrated data, a
|
|
// writer that skips the gate) is also never served.
|
|
func TestPublicCoverNeverEchoesNonImage(t *testing.T) {
|
|
const sourceURL = "https://cdn.example/cover"
|
|
st, dsn := newTestStoreURL(t)
|
|
// The write gate refuses non-image content types outright.
|
|
if err := st.PutCover(sourceURL, []byte("<script>"), "text/html"); err == nil {
|
|
t.Fatal("PutCover accepted a non-image content type")
|
|
}
|
|
// A legitimate row, then the content type flipped behind the store's back:
|
|
// the bytes exist at the address, so only the type is hostile.
|
|
address := store.CoverAddress(sourceURL)
|
|
if err := st.SetSeriesCover("asura", "solo", sourceURL, []byte("<script>"), "image/png"); err != nil {
|
|
t.Fatalf("seed row: %v", err)
|
|
}
|
|
db, err := sql.Open("pgx", dsn)
|
|
if err != nil {
|
|
t.Fatalf("open %s: %v", dsn, err)
|
|
}
|
|
defer db.Close()
|
|
if _, err := db.Exec(`UPDATE covers SET content_type = 'text/html' WHERE address = $1`, address); err != nil {
|
|
t.Fatalf("poison row: %v", err)
|
|
}
|
|
rr := getCover(t, newRouter(st, testConfig(), nil), "/covers/"+address, nil)
|
|
if rr.Code == http.StatusOK {
|
|
t.Fatalf("status = 200, want a refusal for a non-image row (body %q)", rr.Body.String())
|
|
}
|
|
}
|
|
|
|
// The whole point of acquiring bytes is that the UI shows them: the card's
|
|
// <img> must carry the public address, not a third-party URL and not a
|
|
// placeholder.
|
|
func TestListRendersAcquiredCover(t *testing.T) {
|
|
const sourceURL = "https://static.comix.to/039d/i/1/34/6a6742bf15736@280.jpg"
|
|
srv, st := newWebTestServer(t, testConfig())
|
|
if _, err := st.Upsert(st.OwnerID(), store.Bookmark{
|
|
Key: "comix:n8we", Site: "comix", SeriesID: "n8we", Title: "Dungeons and Crayons",
|
|
SeriesURL: "https://comix.to/title/n8we", UpdatedAt: 1000,
|
|
}); err != nil {
|
|
t.Fatalf("seed: %v", err)
|
|
}
|
|
if err := st.SetSeriesCover("comix", "n8we", sourceURL, []byte("\xff\xd8jpeg"), "image/jpeg"); err != nil {
|
|
t.Fatalf("SetSeriesCover: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/ui/list", nil)
|
|
req.AddCookie(sessionCookie(t, st))
|
|
rr := httptest.NewRecorder()
|
|
srv.ServeHTTP(rr, req)
|
|
if rr.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200", rr.Code)
|
|
}
|
|
want := `src="` + testCoverBaseURL + "/covers/" + store.CoverAddress(sourceURL) + `"`
|
|
if !strings.Contains(rr.Body.String(), want) {
|
|
t.Fatalf("rendered list does not contain %s", want)
|
|
}
|
|
}
|