Files
2026-10-27 17:02:15 -06:00

207 lines
5.9 KiB
Go

// 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"})
}
}