Files
felhom.eu/hub/internal/api/routes_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

178 lines
5.4 KiB
Go

package api
import (
"go/ast"
"go/parser"
"go/token"
"net/http"
"strconv"
"strings"
"testing"
"gitea.dooplex.hu/admin/felhom-hub/internal/store"
)
// apiSwitchRoutes reads ServeHTTP's route switch from handler.go: one apiRoute (method + match) per case, in order.
func apiSwitchRoutes(t *testing.T) []apiRoute {
t.Helper()
f, err := parser.ParseFile(token.NewFileSet(), "handler.go", nil, 0)
if err != nil {
t.Fatal(err)
}
var sw *ast.SwitchStmt
for _, d := range f.Decls {
if fd, ok := d.(*ast.FuncDecl); ok && fd.Name.Name == "ServeHTTP" && fd.Recv != nil {
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 handler.go")
}
lit := func(e ast.Expr) string {
bl, ok := e.(*ast.BasicLit)
if !ok {
t.Fatalf("route predicate argument is not a literal at %v", e.Pos())
}
v, _ := strconv.Unquote(bl.Value)
return v
}
var conj func(e ast.Expr) []ast.Expr
conj = func(e ast.Expr) []ast.Expr {
if b, ok := e.(*ast.BinaryExpr); ok && b.Op == token.LAND {
return append(conj(b.X), conj(b.Y)...)
}
return []ast.Expr{e}
}
var out []apiRoute
for _, c := range sw.Body.List {
cc := c.(*ast.CaseClause)
if cc.List == nil {
continue // default: 404
}
if len(cc.List) != 1 {
t.Fatalf("case at %v has %d expressions; one route per case", cc.Pos(), len(cc.List))
}
var r apiRoute
for _, p := range conj(cc.List[0]) {
switch x := p.(type) {
case *ast.BinaryExpr:
if x.Op != token.EQL {
t.Fatalf("unrecognised predicate at %v", x.Pos())
}
if sel, ok := x.Y.(*ast.SelectorExpr); ok && strings.HasPrefix(sel.Sel.Name, "Method") {
r.Method = strings.ToUpper(strings.TrimPrefix(sel.Sel.Name, "Method"))
} else {
r.Exact = lit(x.Y)
}
case *ast.CallExpr:
switch x.Fun.(*ast.SelectorExpr).Sel.Name {
case "HasPrefix":
r.Prefix = lit(x.Args[1])
case "HasSuffix":
r.Suffix = lit(x.Args[1])
case "Contains":
r.Contains = lit(x.Args[1])
default:
t.Fatalf("unrecognised predicate at %v", x.Pos())
}
default:
t.Fatalf("unrecognised predicate at %v", p.Pos())
}
}
out = append(out, r)
}
return out
}
// TestAPIRouteTable_MatchesSwitch is R-952 for /api/v1: a route added to the switch without deciding who may call it
// fails here. RED-PROOF: delete the GET /customers entry from apiRoutes → FAIL at index 36.
func TestAPIRouteTable_MatchesSwitch(t *testing.T) {
sw := apiSwitchRoutes(t)
if len(sw) < 40 {
t.Fatalf("parsed only %d routes — the parser lost the switch", len(sw))
}
n := len(sw)
if len(apiRoutes) > n {
n = len(apiRoutes)
}
for i := 0; i < n; i++ {
var a, b apiRoute
if i < len(sw) {
a = sw[i]
}
if i < len(apiRoutes) {
b = apiRoutes[i]
b.Auth, b.Why = "", ""
if apiRoutes[i].Auth == "" {
t.Errorf("route %d has no Auth class", i)
}
if apiRoutes[i].Auth == authPublic && apiRoutes[i].Why == "" {
t.Errorf("public route %d has no reason", i)
}
}
if a != b {
t.Fatalf("route %d: switch has %+v, table has %+v — classify it in routes.go in the switch's order", i, a, b)
}
}
}
func apiSample(r apiRoute) string {
if r.Exact != "" {
return r.Exact
}
id := "c1"
if strings.Contains(r.Prefix, "hosts/") {
id = "h1"
}
p := r.Prefix + id
if r.Contains != "" {
p += r.Contains + "j1"
}
return p + r.Suffix
}
// TestAPIRoutes_NoCredential: every non-public route, called with no credential for a customer and a host that
// EXIST (so a 404 cannot stand in for a refusal), answers 401 or 403.
// RED-PROOF: make requireGlobalKey return true → GET /customers answers 200.
func TestAPIRoutes_NoCredential(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
n := 0
for _, r := range apiRoutes {
if r.Auth == authPublic {
continue
}
n++
// A body naming the existing household and host, so a route that reads its subject from the body
// reaches its credential check instead of a 400.
rr := do(h, r.Method, apiSample(r), "", `{"customer_id":"c1","host_id":"h1"}`)
if rr.Code != http.StatusUnauthorized && rr.Code != http.StatusForbidden {
t.Errorf("%s %s (%s) with no credential: %d %q, want 401/403", r.Method, apiSample(r), r.Auth, rr.Code, strings.TrimSpace(rr.Body.String()))
}
}
if n < 40 {
t.Fatalf("only %d routes checked", n)
}
}
// TestAPI_AccessTokenIsRefusedEverywhere: an operator access token reaches no box-API route — not a box route, not a
// global-key route, not the public one. RED-PROOF: remove the fhp_ check in ServeHTTP → the public register route
// answers something other than 403.
func TestAPI_AccessTokenIsRefusedEverywhere(t *testing.T) {
h, st, _ := newTestHandler(t)
st.SaveCustomerConfig(&store.CustomerConfig{CustomerID: "c1", APIKey: "ckey", RetrievalPassword: "pass-phrase"})
st.UpsertHost(&store.Host{HostID: "h1", CustomerID: "c1", APIKey: "HKEY"})
for _, r := range apiRoutes {
rr := do(h, r.Method, apiSample(r), store.APITokenPrefix+"abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrst", "{}")
if rr.Code != http.StatusForbidden || !strings.Contains(rr.Body.String(), "access tokens are not accepted") {
t.Errorf("%s %s with an access token: %d %q", r.Method, apiSample(r), rr.Code, strings.TrimSpace(rr.Body.String()))
}
}
}