d62cc08b2e
gates / gates (push) Successful in 6m44s
- 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
178 lines
5.4 KiB
Go
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()))
|
|
}
|
|
}
|
|
}
|