Files
felhom.eu/hub/internal/web/tokens_test.go
T
admin d62cc08b2e
gates / gates (push) Successful in 6m44s
hub v0.152.0: operator access tokens with scopes (09 §3 decision 251, operator 2026-10-15 11:38)
- Configuration → Access tokens: named, scoped (group read/write), expiring (≤365 d), shown once,
  stored SHA-256 only, every use logged by name. Bearer fhp_… on the web surface only; /api/v1 refuses it.
- Route table = the code's single source (web/routes.go), pinned to ServeHTTP's switch; session-only:
  secrets shown/minted, deletes/resets, password, tokens.
- R-952 closed: /api/v1 route table names each caller, pinned to the switch; no-credential refusal test.
- Fail-open fixed: an empty operator password served the UI open (main skipped RequireAuth). Live hub never open.
- 05 §17, 04 §6.2, 13, 09 §3 251; register 155→157 (+R-1054, R-1055, R-1056, −R-952); STATUS.
- website: prowlarr-logo.svg (byte copy from felhom.eu-media) — the site gate refused every push.
Not deployed — waits for the operator's yes.

Claude-Session: https://claude.ai/code/session_012qRErfCoiTkvDK9N5XHbzb
2026-10-15 12:20:35 +02:00

553 lines
20 KiB
Go

package web
import (
"bytes"
"context"
"go/ast"
"go/parser"
"go/token"
"io"
"log"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
"golang.org/x/crypto/bcrypt"
)
// Operator access tokens (hub v0.152.0, `05` §17). Red-proofs are recorded in the hub CHANGELOG v0.152.0 entry and
// felhom.eu/REPORT-hub-tokens.md.
// tokenServer is a hub web server with an operator password, a log buffer, and its DB path.
func tokenServer(t *testing.T) (*Server, *store.Store, *bytes.Buffer, string) {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "t.db")
st, err := store.New(dbPath, log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
hash, _ := bcrypt.GenerateFromPassword([]byte("operator-pw"), bcrypt.MinCost)
var buf bytes.Buffer
s := New(st, string(hash), "", "test", 30*time.Minute, log.New(&buf, "", 0))
return s, st, &buf, dbPath
}
// mintToken stores a token with scopes and returns its plain value.
func mintToken(t *testing.T, st *store.Store, name string, expires time.Time, scopes ...string) string {
t.Helper()
plain, err := newPlainToken()
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateAPIToken(name, plain, scopes, "test", expires); err != nil {
t.Fatal(err)
}
return plain
}
func tokenReq(method, path, plain string, form url.Values) *http.Request {
var body io.Reader
if form != nil {
body = strings.NewReader(form.Encode())
}
r := httptest.NewRequest(method, path, body)
if form != nil {
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
}
if plain != "" {
r.Header.Set("Authorization", "Bearer "+plain)
}
return r
}
func serve(s *Server, r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
s.Handler().ServeHTTP(w, r)
return w
}
// --- the route table is the switch -------------------------------------------------------------------------
// switchRoutes reads ServeHTTP's route switch from server.go's syntax tree: one routeEntry (match only) per
// disjunct of each case, in order.
func switchRoutes(t *testing.T) []routeEntry {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), "server.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var sw *ast.SwitchStmt
for _, d := range f.Decls {
fd, ok := d.(*ast.FuncDecl)
if !ok || fd.Name.Name != "ServeHTTP" || fd.Recv == nil {
continue
}
for _, st := range fd.Body.List {
if s, ok := st.(*ast.SwitchStmt); ok && s.Tag == nil {
sw = s
break
}
}
}
if sw == nil {
t.Fatal("ServeHTTP route switch not found in server.go")
}
var out []routeEntry
var disjuncts func(e ast.Expr) []ast.Expr
disjuncts = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LOR {
return append(disjuncts(b.X), disjuncts(b.Y)...)
}
return []ast.Expr{e}
}
var conjuncts func(e ast.Expr) []ast.Expr
conjuncts = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
return append(conjuncts(b.X), conjuncts(b.Y)...)
}
return []ast.Expr{e}
}
lit := func(e ast.Expr) string {
bl, ok := e.(*ast.BasicLit)
if !ok {
t.Fatalf("route predicate argument is not a string literal: %#v", e)
}
v, _ := strconv.Unquote(bl.Value)
return v
}
for _, c := range sw.Body.List {
cc := c.(*ast.CaseClause)
for _, e := range cc.List {
for _, d := range disjuncts(e) {
var re routeEntry
for _, p := range conjuncts(d) {
switch x := p.(type) {
case *ast.BinaryExpr:
if id, ok := x.X.(*ast.Ident); !ok || id.Name != "path" || x.Op != token.EQL {
t.Fatalf("unrecognised route predicate at %v", x.Pos())
}
re.Exact = lit(x.Y)
case *ast.CallExpr:
name := ""
switch fn := x.Fun.(type) {
case *ast.SelectorExpr:
name = fn.Sel.Name
case *ast.Ident:
name = fn.Name
}
switch name {
case "HasPrefix":
re.Prefix = lit(x.Args[1])
case "HasSuffix":
re.Suffix = lit(x.Args[1])
case "Contains":
re.Contains = lit(x.Args[1])
case "isPublicBindPath":
re.BindFn = true
default:
t.Fatalf("unrecognised route predicate %s at %v", name, x.Pos())
}
default:
t.Fatalf("unrecognised route predicate at %v", p.Pos())
}
}
out = append(out, re)
}
}
}
return out
}
// TestRouteTable_MatchesServeHTTPSwitch is R-952 for the web side: a route added to the switch without a class in
// operatorRoutes — or a reorder that would change which handler a path reaches — fails here.
// RED-PROOF: delete the /hosts/{id}/operator-action entry from operatorRoutes → FAIL at index 21.
func TestRouteTable_MatchesServeHTTPSwitch(t *testing.T) {
sw := switchRoutes(t)
if len(sw) < 60 {
t.Fatalf("parsed only %d switch routes — the parser lost the switch", len(sw))
}
strip := func(e routeEntry) routeEntry {
return routeEntry{Exact: e.Exact, Prefix: e.Prefix, Suffix: e.Suffix, Contains: e.Contains, BindFn: e.BindFn}
}
n := len(sw)
if len(operatorRoutes) > n {
n = len(operatorRoutes)
}
for i := 0; i < n; i++ {
var a, b routeEntry
if i < len(sw) {
a = sw[i]
}
if i < len(operatorRoutes) {
b = strip(operatorRoutes[i])
}
if a != b {
t.Fatalf("route %d: switch has %+v, table has %+v — classify the route in routes.go in the switch's order", i, a, b)
}
}
}
// samplePath builds a path that entry e matches.
func samplePath(e routeEntry) string {
switch {
case e.BindFn:
return "/bind/tok"
case e.Exact != "":
return e.Exact
}
p := e.Prefix + "x1"
if e.Contains != "" {
p += e.Contains + "y1"
}
return p + e.Suffix
}
// every (method, path) pair the table names, with its class.
type routeSample struct {
method, path string
class routeClass
}
func routeSamples(t *testing.T) []routeSample {
var out []routeSample
for i, e := range operatorRoutes {
p := samplePath(e)
// The sample must reach THIS entry, or the class tested is not the class served.
for j, f := range operatorRoutes {
if f.matches(p) {
if j != i {
t.Fatalf("sample %s for entry %d reaches entry %d first", p, i, j)
}
break
}
}
for _, m := range []string{http.MethodGet, http.MethodPost} {
out = append(out, routeSample{m, p, classifyRoute(m, p)})
}
}
return out
}
// TestScopeGroups_EveryOfferedTickOpensARoute: a tick on the card that opens nothing would be a promise the hub
// does not keep; a group a route names but the card does not offer could never be granted.
func TestScopeGroups_EveryOfferedTickOpensARoute(t *testing.T) {
used := map[string]bool{}
for _, s := range routeSamples(t) {
if s.class.Group != "" {
used[s.class.scope()] = true
}
}
for _, g := range scopeGroups {
if g.Read && !used[g.Key+":read"] {
t.Errorf("card offers %s:read but no route needs it", g.Key)
}
if g.Write && !used[g.Key+":write"] {
t.Errorf("card offers %s:write but no route needs it", g.Key)
}
}
for sc := range used {
if !validScope(sc) && !(strings.HasSuffix(sc, ":read") && validScope(strings.TrimSuffix(sc, ":read")+":write")) {
t.Errorf("route needs %s, which the card cannot grant", sc)
}
}
}
// --- what a token may and may not do -----------------------------------------------------------------------
func allScopes() []string {
var out []string
for _, g := range scopeGroups {
if g.Read {
out = append(out, g.Key+":read")
}
if g.Write {
out = append(out, g.Key+":write")
}
}
return out
}
// TestToken_SessionOnlyRoutesRefuseEveryToken: a token holding EVERY scope gets 403 on every session-only route —
// the reveal, the deletes, the resets, the password, the token routes, the customer page with its passphrase.
// RED-PROOF: make tokenAllows return true for SessionOnly → FAIL on /hosts/x1/reveal-recovery-credential.
func TestToken_SessionOnlyRoutesRefuseEveryToken(t *testing.T) {
s, st, _, _ := tokenServer(t)
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
n := 0
for _, rs := range routeSamples(t) {
if !rs.class.SessionOnly {
continue
}
n++
w := serve(s, tokenReq(rs.method, rs.path, plain, url.Values{}))
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "session only") {
t.Errorf("%s %s with an all-scope token: %d %q, want 403 session only", rs.method, rs.path, w.Code, w.Body.String())
}
}
for _, must := range []string{"/hosts/x1/reveal-recovery-credential", "/configs/x1/delete", "/configs/x1/reset",
"/hosts/x1/delete", "/configuration/password", "/configuration/tokens", "/customers/x1", "/configs/x1/preview"} {
if c := classifyRoute(http.MethodPost, must); !c.SessionOnly {
if c2 := classifyRoute(http.MethodGet, must); !c2.SessionOnly {
t.Errorf("%s must be session only", must)
}
}
}
if n < 20 {
t.Fatalf("only %d session-only samples — the table lost its classes", n)
}
}
// TestToken_NoTokenUnknownExpiredRevoked401: a token the hub does not hold, an expired one, a revoked one → 401.
// RED-PROOF: drop the Expired branch in serveWithToken → the expired case answers 200.
func TestToken_NoTokenUnknownExpiredRevoked401(t *testing.T) {
s, st, _, _ := tokenServer(t)
unknown, _ := newPlainToken()
expired := mintToken(t, st, "old", time.Now().Add(-time.Minute), "overview:read")
revoked := mintToken(t, st, "gone", time.Now().Add(time.Hour), "overview:read")
list, _ := st.ListAPITokens()
for _, tk := range list {
if tk.Name == "gone" {
if err := st.RevokeAPIToken(tk.ID); err != nil {
t.Fatal(err)
}
}
}
for name, plain := range map[string]string{"unknown": unknown, "expired": expired, "revoked": revoked} {
w := serve(s, tokenReq(http.MethodGet, "/", plain, nil))
if w.Code != http.StatusUnauthorized {
t.Errorf("%s token: %d, want 401", name, w.Code)
}
}
// No credential at all: unchanged (browser → login page, script → 401).
r := tokenReq(http.MethodGet, "/", "", nil)
r.Header.Set("Accept", "application/json")
if w := serve(s, r); w.Code != http.StatusUnauthorized {
t.Errorf("no credential: %d, want 401", w.Code)
}
}
// TestToken_ScopeDecides: a token without the group → 403 naming the scope; read on a write route → 403; the right
// scope → the handler runs AND its effect lands (the floor is stored), with no CSRF token needed.
// RED-PROOF: make tokenAllows ignore Write (accept group:read for a write route) → the read-token floor save lands.
func TestToken_ScopeDecides(t *testing.T) {
s, st, logs, _ := tokenServer(t)
overview := mintToken(t, st, "ov", time.Now().Add(time.Hour), "overview:read")
floorRead := mintToken(t, st, "fr", time.Now().Add(time.Hour), "overview:read", "floor:read")
cc := mintToken(t, st, "cc-releases", time.Now().Add(time.Hour), "overview:read", "floor:write")
form := url.Values{"min_controller_version": {"0.300.0"}, "min_agent": {"0.150.0"}}
w := serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", overview, form))
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "missing scope floor:write") {
t.Errorf("overview token on floor save: %d %q", w.Code, w.Body.String())
}
w = serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", floorRead, form))
if w.Code != http.StatusForbidden {
t.Errorf("floor:read token on floor save: %d, want 403", w.Code)
}
if got := st.GetGlobalMinControllerVersion(); got == "0.300.0" {
t.Fatal("a refused token changed the floor")
}
// The vouch is not CC's: floor:write does not open it.
w = serve(s, tokenReq(http.MethodPost, "/configuration/artifacts", cc, url.Values{"agent_version": {"0.1.0"}}))
if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "missing scope vouch:write") {
t.Errorf("cc token on the vouch: %d %q", w.Code, w.Body.String())
}
// Allowed — and the consequence is real.
w = serve(s, tokenReq(http.MethodPost, "/configuration/global-floor", cc, form))
if w.Code != http.StatusSeeOther {
t.Fatalf("cc token on floor save: %d %q", w.Code, w.Body.String())
}
if got := st.GetGlobalMinControllerVersion(); got != "0.300.0" {
t.Fatalf("floor after the cc token's save = %q, want 0.300.0", got)
}
if got := st.GlobalFloorDeclaredMinAgent(); got != "0.150.0" {
t.Errorf("declared MinAgent = %q, want 0.150.0", got)
}
// write includes read: the Configuration page opens for floor:write.
if w := serve(s, tokenReq(http.MethodGet, "/configuration", cc, nil)); w.Code != http.StatusOK {
t.Errorf("cc token GET /configuration: %d", w.Code)
} else if strings.Contains(w.Body.String(), `action="/configuration/tokens"`) {
t.Error("a token-authenticated read of /configuration must not render the token card")
}
// The log names the token, never the token value.
out := logs.String()
if !strings.Contains(out, `access token "cc-releases": POST /configuration/global-floor → 303`) {
t.Errorf("no allowed-use log line naming the token:\n%s", out)
}
if !strings.Contains(out, `access token "ov" refused: missing scope floor:write`) {
t.Errorf("no refusal log line naming the token:\n%s", out)
}
for _, p := range []string{overview, floorRead, cc} {
if strings.Contains(out, p) || strings.Contains(out, p[len(TokenPrefix):]) {
t.Fatal("a plain token reached the log")
}
}
list, _ := st.ListAPITokens()
for _, tk := range list {
if tk.Name == "cc-releases" && (tk.LastUsedAt == nil || tk.LastUsedFrom == "") {
t.Errorf("cc-releases last-used not recorded: %+v", tk)
}
}
}
// TestAPIToken_StoredRowHasNoPlainToken: neither the row nor the database file holds the plain token.
// RED-PROOF: store plain instead of HashAPIToken(plain) → FAIL (the file holds it).
func TestAPIToken_StoredRowHasNoPlainToken(t *testing.T) {
_, st, _, dbPath := tokenServer(t)
plain := mintToken(t, st, "x", time.Now().Add(time.Hour), "overview:read")
st.Close()
secret := plain[len(TokenPrefix):]
for _, p := range []string{dbPath, dbPath + "-wal"} {
b, err := os.ReadFile(p)
if err != nil {
continue
}
if bytes.Contains(b, []byte(secret)) {
t.Fatalf("%s holds the plain token", filepath.Base(p))
}
}
// Positive control: the hash IS stored (so the scan above looked at the bytes the row lives in).
var found bool
for _, p := range []string{dbPath, dbPath + "-wal"} {
if b, err := os.ReadFile(p); err == nil && bytes.Contains(b, []byte(store.HashAPIToken(plain))) {
found = true
}
}
if !found {
t.Fatal("positive control failed: the token hash is in neither the db nor its wal")
}
}
// TestToken_CSRFRuleUnchangedForBasic: Basic without the operator header is still refused on a POST; with it, it
// passes; a session without its form token is still refused. Only the token path skips the form token.
func TestToken_CSRFRuleUnchangedForBasic(t *testing.T) {
s, st, _, _ := tokenServer(t)
form := url.Values{"min_controller_version": {"0.301.0"}, "min_agent": {"0.150.0"}}
r := tokenReq(http.MethodPost, "/configuration/global-floor", "", form)
r.SetBasicAuth("", "operator-pw")
if w := serve(s, r); w.Code != http.StatusForbidden {
t.Errorf("Basic without %s: %d, want 403", OperatorCLIHeader, w.Code)
}
r = tokenReq(http.MethodPost, "/configuration/global-floor", "", form)
r.SetBasicAuth("", "operator-pw")
r.Header.Set(OperatorCLIHeader, "cli")
if w := serve(s, r); w.Code != http.StatusSeeOther || st.GetGlobalMinControllerVersion() != "0.301.0" {
t.Errorf("Basic with %s: %d floor=%q", OperatorCLIHeader, w.Code, st.GetGlobalMinControllerVersion())
}
}
// TestToken_CannotManageTokens: even with every scope, a token cannot create or revoke one (the route is session
// only AND the handlers check again).
func TestToken_CannotManageTokens(t *testing.T) {
s, st, _, _ := tokenServer(t)
plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...)
w := serve(s, tokenReq(http.MethodPost, "/configuration/tokens", plain, url.Values{"name": {"evil"}, "scope": {"vouch:write"}}))
if w.Code != http.StatusForbidden {
t.Errorf("token creating a token: %d", w.Code)
}
// The handler's own check, bypassing the table.
r := tokenReq(http.MethodPost, "/configuration/tokens", "", url.Values{"name": {"evil"}, "scope": {"vouch:write"}})
tk, _ := st.GetAPITokenByPlain(plain)
w = httptest.NewRecorder()
s.handleTokenCreate(w, r.WithContext(contextWithToken(r, tk)))
if w.Code != http.StatusForbidden {
t.Errorf("handleTokenCreate under a token: %d", w.Code)
}
list, _ := st.ListAPITokens()
if len(list) != 1 {
t.Fatalf("a token was created by a token: %d rows", len(list))
}
}
func contextWithToken(r *http.Request, tk *store.APIToken) context.Context {
return context.WithValue(r.Context(), tokenCtxKey{}, tk)
}
// TestTokenCreate_SessionShowsItOnce: from a session the card creates a token, shows it in this one response
// (no-store), stores only its hash, and the token works with exactly the ticked scopes.
func TestTokenCreate_SessionShowsItOnce(t *testing.T) {
s, st, logs, _ := tokenServer(t)
form := url.Values{"name": {"cc-releases"}, "scope": {"overview:read", "floor:write"}, "days": {"90"}}
r := tokenReq(http.MethodPost, "/configuration/tokens", "", form)
w := httptest.NewRecorder()
s.handleTokenCreate(w, r)
if w.Code != http.StatusOK || w.Header().Get("Cache-Control") != "no-store" {
t.Fatalf("create: %d cache=%q", w.Code, w.Header().Get("Cache-Control"))
}
body := w.Body.String()
i := strings.Index(body, TokenPrefix)
if i < 0 {
t.Fatal("the new token is not shown")
}
plain := body[i : i+len(TokenPrefix)+52]
if strings.Contains(logs.String(), plain[len(TokenPrefix):]) {
t.Fatal("the plain token reached the log")
}
tk, _ := st.GetAPITokenByPlain(plain)
if tk == nil || strings.Join(tk.Scopes, ",") != "overview:read,floor:write" {
t.Fatalf("stored token: %+v", tk)
}
if d := time.Until(tk.ExpiresAt); d < 89*24*time.Hour || d > 91*24*time.Hour {
t.Errorf("expiry %v, want ~90 days", d)
}
// A second render of the page does not show it again.
r2 := tokenReq(http.MethodGet, "/configuration", "", nil)
w2 := httptest.NewRecorder()
s.handleConfiguration(w2, r2)
if strings.Contains(w2.Body.String(), plain) {
t.Fatal("the token is shown a second time")
}
if !strings.Contains(w2.Body.String(), "cc-releases") {
t.Error("the token list does not name the token")
}
// Refusals: no scope, unknown scope, never, over a year.
for _, bad := range []url.Values{
{"name": {"a"}},
{"name": {"a"}, "scope": {"tokens:write"}},
{"name": {"a"}, "scope": {"overview:read"}, "days": {"0"}},
{"name": {"a"}, "scope": {"overview:read"}, "days": {"366"}},
{"name": {""}, "scope": {"overview:read"}},
} {
w := httptest.NewRecorder()
s.handleTokenCreate(w, tokenReq(http.MethodPost, "/configuration/tokens", "", bad))
if w.Code != http.StatusSeeOther {
t.Errorf("bad create %v: %d, want a refusal redirect", bad, w.Code)
}
}
if list, _ := st.ListAPITokens(); len(list) != 1 {
t.Errorf("refused creates stored rows: %d", len(list))
}
}
// TestRequireAuth_NoPasswordRefusesEveryOperatorRoute: with no operator password anywhere, every operator route
// answers 503 — never the page. /bind/ stays public.
// RED-PROOF: restore the old `if effectivePasswordHash() == "" { next.ServeHTTP }` → the dashboard answers 200.
func TestRequireAuth_NoPasswordRefusesEveryOperatorRoute(t *testing.T) {
st, err := store.New(filepath.Join(t.TempDir(), "t.db"), log.New(io.Discard, "", 0))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
s := New(st, "", "", "test", 30*time.Minute, log.New(io.Discard, "", 0))
for _, rs := range routeSamples(t) {
if rs.class.Exempt {
continue
}
w := serve(s, tokenReq(rs.method, rs.path, "", url.Values{}))
if w.Code != http.StatusServiceUnavailable {
t.Errorf("no password: %s %s → %d, want 503", rs.method, rs.path, w.Code)
}
}
if w := serve(s, tokenReq(http.MethodGet, "/bind/sometoken", "", nil)); w.Code == http.StatusServiceUnavailable {
t.Error("the public /bind/ surface must not depend on the operator password")
}
}