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())) } } }