// Package fakehh is a small in-memory imitation of the parts of Hammerhead's API that hhctl uses, // for tests. It checks the bearer key and keeps resources in maps; it does not validate like the real server. package fakehh import ( "encoding/json" "fmt" "net/http" "net/http/httptest" "strings" "sync" ) const ( OrgID = "org1" OrgKey = "hhk_org_secret" ServerKey = "hhk_srv_secret" ) type Server struct { *httptest.Server mu sync.Mutex n int Edges map[string]map[string]any Sites map[string]map[string]any Domains map[string]map[string]any Mappings map[string]map[string]any Channels map[string]map[string]any } func New() *Server { s := &Server{Edges: map[string]map[string]any{}, Sites: map[string]map[string]any{}, Domains: map[string]map[string]any{}, Mappings: map[string]map[string]any{}, Channels: map[string]map[string]any{}} s.Server = httptest.NewServer(http.HandlerFunc(s.serve)) return s } func (s *Server) id(prefix string) string { s.n++; return fmt.Sprintf("%s%d", prefix, s.n) } func reply(w http.ResponseWriter, code int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) json.NewEncoder(w).Encode(v) } func list(m map[string]map[string]any) []map[string]any { out := []map[string]any{} for _, v := range m { out = append(out, v) } return out } func (s *Server) serve(w http.ResponseWriter, r *http.Request) { s.mu.Lock() defer s.mu.Unlock() key := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") if key != OrgKey && key != ServerKey { reply(w, 401, map[string]string{"error": "Sign in to continue."}) return } server := key == ServerKey var body map[string]any if r.Body != nil { json.NewDecoder(r.Body).Decode(&body) } path := strings.Trim(r.URL.Path, "/") seg := strings.Split(path, "/") // api, ... m := r.Method ok := map[string]bool{"ok": true} switch { case !server && (strings.HasPrefix(path, "api/admin") || m != "GET" && strings.HasPrefix(path, "api/edges") || m == "POST" && path == "api/orgs"): reply(w, 401, map[string]string{"error": "Sign in to continue."}) // the real server refuses org keys here case path == "api/orgs" && m == "GET": reply(w, 200, []map[string]any{{"id": OrgID, "name": "Acme"}}) case path == "api/orgs" && m == "POST" && server: reply(w, 200, map[string]any{"id": "org2", "name": body["name"]}) case path == "api/edges" && m == "GET": reply(w, 200, list(s.Edges)) case path == "api/edges" && m == "POST" && server: id := s.id("e") body["id"], body["token"], body["ips"], body["online"] = id, "edge-token-"+id, []string{}, false s.Edges[id] = body reply(w, 200, body) case len(seg) == 3 && seg[1] == "edges" && server: e := s.Edges[seg[2]] switch { case e == nil: reply(w, 404, map[string]string{"error": "No such edge"}) case m == "PUT": for k, v := range body { e[k] = v } reply(w, 200, e) case m == "DELETE": delete(s.Edges, seg[2]) reply(w, 200, ok) } case len(seg) >= 3 && seg[1] == "orgs" && seg[2] == OrgID || len(seg) >= 3 && seg[1] == "orgs" && server: s.org(w, m, seg[3:], body, server) case len(seg) >= 3 && seg[1] == "admin" && seg[2] == "alerts" && server: s.alerts(w, m, seg[3:], body) default: reply(w, 404, map[string]string{"error": "not found"}) } } func (s *Server) org(w http.ResponseWriter, m string, seg []string, body map[string]any, server bool) { ok := map[string]bool{"ok": true} if len(seg) == 0 { reply(w, 404, map[string]string{"error": "not found"}) return } switch seg[0] { case "state": reply(w, 200, map[string]any{"edges": list(s.Edges), "sites": list(s.Sites), "domains": list(s.Domains), "mappings": list(s.Mappings)}) case "sites", "domains", "mappings": var store map[string]map[string]any var prefix string switch seg[0] { case "sites": store, prefix = s.Sites, "s" case "domains": store, prefix = s.Domains, "d" default: store, prefix = s.Mappings, "m" } if len(seg) == 1 && m == "POST" { id := s.id(prefix) body["id"], body["orgId"] = id, OrgID if seg[0] == "sites" { body["token"], body["connected"] = "site-token-"+id, []string{} } store[id] = body reply(w, 200, body) return } if len(seg) == 2 { cur := store[seg[1]] switch { case cur == nil: reply(w, 404, map[string]string{"error": "No such " + seg[0]}) case m == "PUT": for k, v := range body { cur[k] = v } if seg[0] == "sites" { reply(w, 200, ok) } else { reply(w, 200, cur) } case m == "DELETE": delete(store, seg[1]) reply(w, 200, ok) } return } reply(w, 404, map[string]string{"error": "not found"}) case "alerts": s.alerts(w, m, seg[1:], body) case "apikeys": reply(w, 200, map[string]string{"id": "k1", "key": "hhk_k1_secret"}) default: reply(w, 404, map[string]string{"error": "not found"}) } } func (s *Server) alerts(w http.ResponseWriter, m string, seg []string, body map[string]any) { ok := map[string]bool{"ok": true} switch { case len(seg) == 0 && m == "GET": ch := list(s.Channels) for _, c := range ch { // like the real server, secrets are not sent back if c["kind"] != "email" { c["target"], c["token"] = "", "" } } reply(w, 200, map[string]any{"channels": ch, "active": []any{}, "settings": map[string]int{"debounceMin": 2, "repeatHours": 0}}) case len(seg) == 1 && seg[0] == "channels" && m == "POST": id := s.id("c") body["id"] = id s.Channels[id] = body reply(w, 200, map[string]any{"ok": true, "id": id}) case len(seg) >= 2 && seg[0] == "channels": cur := s.Channels[seg[1]] switch { case cur == nil: reply(w, 404, map[string]string{"error": "No such channel"}) case len(seg) == 3 && seg[2] == "test": reply(w, 200, ok) case m == "PUT": for k, v := range body { if v != "" { cur[k] = v } } reply(w, 200, ok) case m == "DELETE": delete(s.Channels, seg[1]) reply(w, 200, ok) } default: reply(w, 404, map[string]string{"error": "not found"}) } }