test, docs: admin page gate tests and the page's written rules (#102)
- web_test.go walks web.AdminPatterns() rather than naming routes by hand, so a new administrative route that forgets requireOwner fails the gate test instead of shipping open. - The harnesses take a LaneReporter; a fake one keeps the page's tests free of a poller and a Site. - backend/AGENTS.md records the adminRoutes/requireOwner rule and the nil-poller trap; design-system.md records --patina and the admin page's shape.
This commit is contained in:
+181
-16
@@ -15,6 +15,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bookmarkmanager/backend/internal/latest"
|
||||
"bookmarkmanager/backend/internal/session"
|
||||
"bookmarkmanager/backend/internal/store"
|
||||
"bookmarkmanager/backend/internal/web"
|
||||
@@ -26,11 +27,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 +148,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 +417,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 +633,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 +687,136 @@ 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, or a Site whose pages can only be read
|
||||
// through a sidecar that is not there.
|
||||
func TestAdminPageShowsLaneStatus(t *testing.T) {
|
||||
lanes := fakeLanes{latest.Status{
|
||||
Lanes: []latest.LaneState{
|
||||
{Site: "asura", Due: 12, LastRun: time.Now().Add(-90 * time.Second), Gap: 40 * time.Second},
|
||||
{Site: "kagane", Due: 3, LastRun: time.Now().Add(-time.Minute), Gap: time.Minute, Browser: true},
|
||||
{Site: "demonic", Due: 400, LastRun: time.Now(), Gap: 8 * time.Second, Clamped: true},
|
||||
},
|
||||
BrowserConfigured: 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", "gap 40s", "ran 1m30s ago", "gap at floor", "no browser", "unreachable"} {
|
||||
if !strings.Contains(body, want) {
|
||||
t.Errorf("lane status lacks %q:\n%s", want, body)
|
||||
}
|
||||
}
|
||||
// Two Lanes need attention: the clamped one and the one cut off from the
|
||||
// sidecar. The healthy Lane must not be marked.
|
||||
if n := strings.Count(body, `class="attention"`); n != 2 {
|
||||
t.Errorf("attention rows = %d, want 2:\n%s", n, body)
|
||||
}
|
||||
}
|
||||
|
||||
// No poller and a poller that has not finished a pass are the same to the page:
|
||||
// it says so rather than drawing zeroes that read as a stopped backend.
|
||||
func TestAdminPageWithoutAPollerSaysSo(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
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 poller does not say so:\n%s", body)
|
||||
}
|
||||
if !strings.Contains(body, "not configured") {
|
||||
t.Errorf("admin page does not report the missing browser sidecar:\n%s", body)
|
||||
}
|
||||
}
|
||||
|
||||
// Clearing a Reader's marks answers with the whole roster, so the page cannot
|
||||
// keep showing the record that was just wiped.
|
||||
func TestOwnerClearsReaderMarks(t *testing.T) {
|
||||
router, st, _ := oauthWebTestServer(t)
|
||||
theirCookie := signInCookie(t, router)
|
||||
their, _, err := st.GetSession(theirCookie.Value, time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("GetSession: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost,
|
||||
"/readers/"+strconv.FormatInt(their.ReaderID, 10)+"/clear-marks", nil)
|
||||
req.AddCookie(sessionCookie(t, st))
|
||||
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