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>
This commit was merged in pull request #107.
This commit is contained in:
+246
-16
@@ -1,6 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -15,6 +16,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/session"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/web"
|
||||
@@ -26,11 +28,17 @@ import (
|
||||
const testOwnerID = "owner-snowflake"
|
||||
|
||||
// newWebTestServer returns the full router plus the store behind it, so tests
|
||||
// can seed rows and assert on what the handlers wrote back.
|
||||
func newWebTestServer(t *testing.T, cfg Config) (http.Handler, *store.Store) {
|
||||
// can seed rows and assert on what the handlers wrote back. An optional lane
|
||||
// reporter stands in for the running poller; omitted means none is running,
|
||||
// which is what every test that is not about the admin page wants.
|
||||
func newWebTestServer(t *testing.T, cfg Config, lanes ...web.LaneReporter) (http.Handler, *store.Store) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
return newRouter(st, cfg), st
|
||||
var reporter web.LaneReporter
|
||||
if len(lanes) > 0 {
|
||||
reporter = lanes[0]
|
||||
}
|
||||
return newRouter(st, cfg, reporter), st
|
||||
}
|
||||
|
||||
// sessionCookie mints a live session row for the owner and returns the cookie
|
||||
@@ -141,12 +149,12 @@ func discordConfig(stubURL string) web.DiscordConfig {
|
||||
|
||||
// oauthWebTestServer returns the full router, its store, and a Discord stub
|
||||
// wired as the configured API — the starting point for sign-in tests.
|
||||
func oauthWebTestServer(t *testing.T) (http.Handler, *store.Store, *discordStub) {
|
||||
func oauthWebTestServer(t *testing.T, lanes ...web.LaneReporter) (http.Handler, *store.Store, *discordStub) {
|
||||
t.Helper()
|
||||
stub, srv := newDiscordStub(t)
|
||||
cfg := testConfig()
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
router, st := newWebTestServer(t, cfg)
|
||||
router, st := newWebTestServer(t, cfg, lanes...)
|
||||
return router, st, stub
|
||||
}
|
||||
|
||||
@@ -410,7 +418,7 @@ func TestDiscordLoginRefusesNonMember(t *testing.T) {
|
||||
cfg.Discord = discordConfig(srv.URL)
|
||||
cfg.Discord.RequiredRole = tc.require
|
||||
st := newTestStore(t)
|
||||
router := newRouter(st, cfg)
|
||||
router := newRouter(st, cfg, nil)
|
||||
|
||||
rr := completeSignIn(t, router, startSignIn(t, router))
|
||||
if rr.Code != http.StatusForbidden {
|
||||
@@ -626,24 +634,52 @@ func TestOwnerRevokesAnotherReadersSessions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The owner's own page carries the roster; nobody else's does.
|
||||
func TestOwnerSeesReadersPanel(t *testing.T) {
|
||||
// fakeLanes is the admin page's poller stand-in: one fixed snapshot, so the
|
||||
// page's tests need neither a poller nor a Site.
|
||||
type fakeLanes struct{ status latest.Status }
|
||||
|
||||
func (f fakeLanes) LaneStatus() latest.Status { return f.status }
|
||||
|
||||
// The roster moved off the reading page onto its own address: the owner gets a
|
||||
// link, everyone else gets nothing, and the page itself lists every Reader with
|
||||
// the counters and the two controls.
|
||||
func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
signInCookie(t, router)
|
||||
theirCookie := signInCookie(t, router)
|
||||
ownerCookie := sessionCookie(t, st)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
req.AddCookie(ownerCookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, `id="readers"`) {
|
||||
t.Fatal("the owner's page lacks the Readers panel")
|
||||
if strings.Contains(body, `id="readers"`) {
|
||||
t.Error("the reading page still carries the roster; it belongs on /admin")
|
||||
}
|
||||
if !strings.Contains(body, testOwnerID) {
|
||||
t.Fatalf("the roster does not list the registered Reader:\n%s", body)
|
||||
if !strings.Contains(body, `href="/admin"`) {
|
||||
t.Error("the owner's reading page offers no link to the admin page")
|
||||
}
|
||||
if !strings.Contains(body, "Revoke sessions") {
|
||||
t.Fatal("the roster offers no revocation control for a signed-in Reader")
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
req.AddCookie(theirCookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if strings.Contains(rr.Body.String(), `href="/admin"`) {
|
||||
t.Error("a non-owner was offered the admin link")
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
req.AddCookie(ownerCookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /admin status = %d, want 200", rr.Code)
|
||||
}
|
||||
body = rr.Body.String()
|
||||
for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("admin page lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Exactly one revocable row: the other Reader's. The owner's own row carries
|
||||
// the same session count and no button.
|
||||
@@ -652,6 +688,200 @@ func TestOwnerSeesReadersPanel(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Every administrative route is gated the same way, so the test walks the list
|
||||
// the router registers rather than naming routes by hand: no session is 401,
|
||||
// a signed-in non-owner is 404, and the address is not confirmed to either.
|
||||
func TestAdminRoutesAreOwnerOnly(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
theirCookie := signInCookie(t, router)
|
||||
ownerCookie := sessionCookie(t, st)
|
||||
target := strconv.FormatInt(st.OwnerID(), 10)
|
||||
|
||||
patterns := web.AdminPatterns()
|
||||
if len(patterns) == 0 {
|
||||
t.Fatal("no administrative routes to test")
|
||||
}
|
||||
for _, pattern := range patterns {
|
||||
method, path, ok := strings.Cut(pattern, " ")
|
||||
if !ok {
|
||||
t.Fatalf("route pattern %q has no method", pattern)
|
||||
}
|
||||
path = strings.Replace(path, "{id}", target, 1)
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
cookie *http.Cookie
|
||||
want int
|
||||
}{
|
||||
{"no session", nil, http.StatusUnauthorized},
|
||||
{"non-owner", theirCookie, http.StatusNotFound},
|
||||
} {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
if tc.cookie != nil {
|
||||
req.AddCookie(tc.cookie)
|
||||
}
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != tc.want {
|
||||
t.Errorf("%s %s as %s: status = %d, want %d", method, path, tc.name, rr.Code, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.AddCookie(ownerCookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code == http.StatusUnauthorized {
|
||||
t.Errorf("%s %s as the owner: status = 401, the gate rejects the owner", method, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Lane block reports what the poller says, and marks the Lanes that need
|
||||
// attention — a clamped gap, a refusal, a Site whose pages can only be read
|
||||
// through a sidecar that is not there, and a Lane with Series waiting that its
|
||||
// last pass did not read.
|
||||
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "asura", Due: 12, Checked: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
|
||||
{Site: "kagane", Due: 3, Checked: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
|
||||
{Site: "demonic", Due: 400, Checked: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
|
||||
{Site: "comix", Due: 7, LastRun: time.Now(), Gap: time.Minute, Browser: true},
|
||||
},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("GET /ui/admin/lanes status = %d, want 200", rr.Code)
|
||||
}
|
||||
body := rr.Body.String()
|
||||
for _, want := range []string{"asura", "kagane", "12 due", "12 checked", "gap 40s", "ran 1m30s ago", "gap at floor", "not checking", "reachable"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane status lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Nothing is refusing and the sidecar is up, so neither mark may appear:
|
||||
// a mark the owner cannot act on is worse than none.
|
||||
for _, unwanted := range []string{"refusing", "no browser"} {
|
||||
if strings.Contains(body, unwanted) {
|
||||
t.Errorf("lane status marks %q on a healthy run:\n%s", unwanted, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A Lane whose pass never reached a figure must not have that figure drawn as
|
||||
// a zero: a refusing Lane still reports the due count and gap its last real
|
||||
// pass saw, and a Lane that has never reached one omits it entirely.
|
||||
func TestLaneStatusOmitsUnknownGap(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{{Site: "comix", LastRun: time.Now(), Refusing: true, Browser: true}},
|
||||
BrowserConfigured: true,
|
||||
BrowserReachable: true,
|
||||
}}
|
||||
router, st, _ := oauthWebTestServer(t, lanes)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/ui/admin/lanes", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if strings.Contains(body, "gap 0s") {
|
||||
t.Errorf("a Lane with no pace yet states a zero gap:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "refusing") {
|
||||
t.Errorf("a refusing Lane is not marked as such:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// No poller and a poller that has not finished a pass both render "no data
|
||||
// yet" rather than zeroes that read as a stopped backend — but they are not
|
||||
// the same fact, so the page must not blame the sidecar when nothing polls.
|
||||
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
lanes []web.LaneReporter
|
||||
want, unwant string
|
||||
}{
|
||||
{"no poller", nil, "Polling is switched off", "not configured"},
|
||||
{"poller, no pass yet", []web.LaneReporter{fakeLanes{}}, "not configured", "Polling is switched off"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t, tc.lanes...)
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "No data yet") {
|
||||
t.Errorf("admin page with no Lane data does not say so:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, tc.want) {
|
||||
t.Errorf("admin page lacks %q:\n%s", tc.want, body)
|
||||
}
|
||||
if strings.Contains(body, tc.unwant) {
|
||||
t.Errorf("admin page states %q, which is not what is wrong:\n%s", tc.unwant, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A Reader past the disagreement threshold is rendered as blocked, and
|
||||
// clearing their marks both zeroes the counters and lifts the block in the
|
||||
// roster the response carries back.
|
||||
func TestOwnerClearsReaderMarks(t *testing.T) {
|
||||
st, dsn := newTestStoreURL(t)
|
||||
router := newRouter(st, testConfig(), nil)
|
||||
cookie := sessionCookie(t, st)
|
||||
// The counters are filled by issue #103; until it lands the only way to
|
||||
// stand a marked Reader up is to write the columns directly.
|
||||
db, err := sql.Open("pgx", dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open %s: %v", dsn, err)
|
||||
}
|
||||
defer db.Close()
|
||||
if _, err := db.Exec(`UPDATE readers SET sighting_agreements = 4, sighting_disagreements = 3 WHERE id = $1`, st.OwnerID()); err != nil {
|
||||
t.Fatalf("mark reader: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr := httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
body := rr.Body.String()
|
||||
if !strings.Contains(body, "4 confirmed / 3 contradicted") {
|
||||
t.Errorf("roster does not report the Reader's marks:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "deferral blocked") {
|
||||
t.Errorf("a Reader at the threshold is not rendered as blocked:\n%s", body)
|
||||
}
|
||||
|
||||
req = httptest.NewRequest(http.MethodPost,
|
||||
"/readers/"+strconv.FormatInt(st.OwnerID(), 10)+"/clear-marks", nil)
|
||||
req.AddCookie(cookie)
|
||||
rr = httptest.NewRecorder()
|
||||
router.ServeHTTP(rr, req)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("clear marks: status = %d, want 200 (body %s)", rr.Code, rr.Body.String())
|
||||
}
|
||||
body = rr.Body.String()
|
||||
if !strings.Contains(body, `id="readers"`) {
|
||||
t.Fatalf("clear marks did not re-render the roster:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "0 confirmed / 0 contradicted") {
|
||||
t.Errorf("roster does not report the cleared counters:\n%s", body)
|
||||
}
|
||||
if strings.Contains(body, "deferral blocked") {
|
||||
t.Errorf("a cleared Reader is still marked blocked:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiscordLoginTokenEndpointDown(t *testing.T) {
|
||||
stub, srv := newDiscordStub(t)
|
||||
stub.tokenStatus = http.StatusInternalServerError
|
||||
|
||||
Reference in New Issue
Block a user