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") } } // TestToken_CredentialAndLinkRoutesAreSessionOnly — operator ruling 2026-10-15 13:20 (`09` §3 decision 252, R-1055, R-1059): // every route that sends, mints or reveals a household credential or link, or changes a household's e-mail, is session // only. A token holding EVERY scope gets 403 on each, and nothing changes (the e-mail, the passphrase, no new config). // The positive control: block — still customers:write — is NOT refused. // RED-PROOF: put claim-resend back to wr(grpCustomers) → FAIL on /configs/c1/claim-resend. func TestToken_CredentialAndLinkRoutesAreSessionOnly(t *testing.T) { s, st, _, _ := tokenServer(t) if err := st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", Email: "old@example.com", RetrievalPassword: "old-pass-phrase", APIKey: "ckey", ConfigJSON: "{}"}); err != nil { t.Fatal(err) } plain := mintToken(t, st, "all", time.Now().Add(time.Hour), allScopes()...) routes := []struct{ path, why string }{ {"/configs/c1/claim-resend", "sends a claim code to the household's e-mail"}, {"/configs/c1/edit", "changes the household's e-mail (a new e-mail mails the self-bind link)"}, {"/configs/new", "mints passphrase + API key, mails the self-bind link"}, {"/customers/c3/create-config", "mints passphrase + API key"}, {"/customers/c1/selfbind-link", "mints and mails the self-bind link"}, {"/configs/c1/regen-password", "mints a new owner passphrase"}, {"/appliances/1/bind", "delivers the passphrase to the box that asks"}, // R-1059 (operator 2026-10-15): the off-site and PBS-DR re-issues mint a household credential for the box. {"/configs/c1/offsite-reissue", "resets the household's off-site password"}, {"/configs/c1/pbsdr-reissue", "re-keys the household's PBS-DR token"}, } form := url.Values{"email": {"attacker@example.com"}, "customer_id": {"c2"}, "customer_name": {"x"}} for _, rt := range routes { w := serve(s, tokenReq(http.MethodPost, rt.path, plain, form)) if w.Code != http.StatusForbidden || !strings.Contains(w.Body.String(), "session only") { t.Errorf("POST %s (%s) with an all-scope token: %d %q, want 403 session only", rt.path, rt.why, w.Code, w.Body.String()) } } cfg, _ := st.GetCustomerConfig("c1") if cfg == nil || cfg.Email != "old@example.com" || cfg.RetrievalPassword != "old-pass-phrase" { t.Errorf("a refused token changed c1: %+v", cfg) } for _, id := range []string{"c2", "c3"} { if c, _ := st.GetCustomerConfig(id); c != nil { t.Errorf("a refused token created customer %s", id) } } // Positive control: the group still opens block. if w := serve(s, tokenReq(http.MethodPost, "/customers/c1/block", plain, url.Values{})); w.Code == http.StatusForbidden { t.Errorf("customers:write must still open block: %d %q", w.Code, w.Body.String()) } }