package api_test import ( "bytes" "crypto/sha256" "encoding/hex" "encoding/json" "io" "net/http" "net/http/cookiejar" "strings" "testing" "halcyon/api" ) const adminPW = "correct horse battery" // admin is a browser-like client (it keeps the session cookie) for the admin API. type admin struct { t *testing.T base string c *http.Client } func newAdmin(t *testing.T, base string) *admin { jar, _ := cookiejar.New(nil) return &admin{t: t, base: base, c: &http.Client{Jar: jar}} } func (a *admin) req(method, path string, body any, headers map[string]string) (*http.Response, []byte) { a.t.Helper() var rdr io.Reader if body != nil { b, _ := json.Marshal(body) rdr = bytes.NewReader(b) } req, _ := http.NewRequest(method, a.base+path, rdr) for k, v := range headers { req.Header.Set(k, v) } resp, err := a.c.Do(req) if err != nil { a.t.Fatal(err) } defer resp.Body.Close() b, _ := io.ReadAll(resp.Body) return resp, b } func (a *admin) get(path string, want int) []byte { a.t.Helper() resp, b := a.req("GET", path, nil, nil) if resp.StatusCode != want { a.t.Fatalf("GET %s: status %d want %d: %s", path, resp.StatusCode, want, b) } return b } func (a *admin) login() { a.t.Helper(); a.mustLogin(adminPW) } func (a *admin) mustLogin(pw string) { a.t.Helper() resp, b := a.req("POST", "/admin/api/login", map[string]string{"password": pw}, nil) if resp.StatusCode != 200 { a.t.Fatalf("admin login: %d %s", resp.StatusCode, b) } } type auditRow struct { ID int64 `json:"id"` At int64 `json:"at"` PlayerID int64 `json:"player_id"` Name string `json:"name"` Action string `json:"action"` Ship int64 `json:"ship"` Slot int `json:"slot"` Detail map[string]any `json:"detail"` IP string `json:"ip"` } func (a *admin) audit(query string) []auditRow { a.t.Helper() var rows []auditRow if err := json.Unmarshal(a.get("/admin/api/audit"+query, 200), &rows); err != nil { a.t.Fatal(err) } return rows } func actionsOf(rows []auditRow) []string { var out []string for i := len(rows) - 1; i >= 0; i-- { // oldest first out = append(out, rows[i].Action) } return out } func TestAdminIsOffWithoutAPassword(t *testing.T) { srv, _ := newSite(t, "") a := newAdmin(t, srv.URL) resp, b := a.req("POST", "/admin/api/login", map[string]string{"password": ""}, nil) if resp.StatusCode != 404 || !strings.Contains(string(b), "HALCYON_ADMIN_PASSWORD") { t.Fatalf("login with the interface off: %d %s", resp.StatusCode, b) } a.get("/admin/api/accounts", 404) a.get("/admin/api/audit", 404) } func TestAdminLoginAndSession(t *testing.T) { srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) a := newAdmin(t, srv.URL) a.get("/admin/api/session", 401) a.get("/admin/api/accounts", 401) a.get("/admin/api/audit", 401) if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": "wrong"}, nil); resp.StatusCode != 401 { t.Fatalf("wrong password: %d", resp.StatusCode) } if resp, _ := a.req("POST", "/admin/api/login", "not json", nil); resp.StatusCode != 400 { t.Fatalf("malformed body: %d", resp.StatusCode) } a.get("/admin/api/session", 401) // failed attempts do not log anyone in resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, nil) if resp.StatusCode != 200 { t.Fatalf("login: %d", resp.StatusCode) } var cookie *http.Cookie for _, c := range resp.Cookies() { if c.Name == "halcyon_admin" { cookie = c } } if cookie == nil || !cookie.HttpOnly || cookie.SameSite != http.SameSiteStrictMode || cookie.Path != "/admin" || cookie.MaxAge <= 0 { t.Fatalf("session cookie = %+v: it must be HttpOnly, SameSite=Strict, scoped to /admin and expire", cookie) } if cookie.Secure { t.Error("a plain-HTTP request should not get a Secure cookie (the browser would drop it)") } a.get("/admin/api/session", 200) a.get("/admin/api/accounts", 200) // Behind the TLS-terminating proxy the cookie is Secure. b := newAdmin(t, srv.URL) resp, _ = b.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, map[string]string{"X-Forwarded-Proto": "https"}) if c := resp.Cookies(); len(c) == 0 || !c[0].Secure { t.Errorf("cookie behind https = %+v, want Secure", c) } // Every admin response is uncacheable. if resp, _ := a.req("GET", "/admin/api/accounts", nil, nil); resp.Header.Get("Cache-Control") != "no-store" { t.Errorf("Cache-Control = %q", resp.Header.Get("Cache-Control")) } // A tampered cookie is refused. tampered := newAdmin(t, srv.URL) req, _ := http.NewRequest("GET", srv.URL+"/admin/api/accounts", nil) req.AddCookie(&http.Cookie{Name: "halcyon_admin", Value: "9999999999." + strings.Repeat("a", 64)}) if resp, err := tampered.c.Do(req); err != nil || resp.StatusCode != 401 { t.Fatalf("forged cookie: %v %v", resp, err) } // Logout ends the session. if resp, _ := a.req("POST", "/admin/api/logout", nil, nil); resp.StatusCode != 204 { t.Fatalf("logout: %d", resp.StatusCode) } a.get("/admin/api/session", 401) } func TestAdminLoginIsRateLimitedPerAddress(t *testing.T) { srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) a := newAdmin(t, srv.URL) // Behind the proxy the client's address is X-Forwarded-For; the test server's own peer is loopback. from := func(ip string) map[string]string { return map[string]string{"X-Forwarded-For": ip} } for i := 0; i < 5; i++ { if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": "guess"}, from("203.0.113.9")); resp.StatusCode != 401 { t.Fatalf("attempt %d: %d", i+1, resp.StatusCode) } } // Now even the right password is refused for that address... resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, from("203.0.113.9")) if resp.StatusCode != 429 || resp.Header.Get("Retry-After") == "" { t.Fatalf("locked out address: %d retry-after=%q", resp.StatusCode, resp.Header.Get("Retry-After")) } // ...but not for anyone else. if resp, _ := a.req("POST", "/admin/api/login", map[string]string{"password": adminPW}, from("198.51.100.7")); resp.StatusCode != 200 { t.Fatalf("a different address was locked out too: %d", resp.StatusCode) } } func TestAdminAndPlayerCredentialsAreSeparate(t *testing.T) { srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) p := newPlayer(t, srv.URL, "ann") a := newAdmin(t, srv.URL) a.login() // A player's API key opens nothing under /admin, and the admin session opens nothing of a player's. resp, _ := http.DefaultClient.Do(mustReq("GET", srv.URL+"/admin/api/accounts", "Bearer "+p.key)) if resp.StatusCode != 401 { t.Fatalf("a player key on the admin API: %d", resp.StatusCode) } if resp, _ := a.req("GET", "/ships", nil, nil); resp.StatusCode != 401 { t.Fatalf("the admin cookie on a player endpoint: %d", resp.StatusCode) } // The admin password is not an API key. resp, _ = http.DefaultClient.Do(mustReq("GET", srv.URL+"/me", "Bearer "+adminPW)) if resp.StatusCode != 401 { t.Fatalf("the admin password as an API key: %d", resp.StatusCode) } } func mustReq(method, url, auth string) *http.Request { r, _ := http.NewRequest(method, url, nil) r.Header.Set("Authorization", auth) return r } func TestAuditTrailOfPlayerActions(t *testing.T) { srv, cfg := newSite(t, "", api.WithAdminPassword(adminPW)) a := newAdmin(t, srv.URL) a.login() if rows := a.audit(""); len(rows) != 0 { t.Fatalf("a new world has %d audit entries", len(rows)) } p := newPlayer(t, srv.URL, "ann") // register post := func(path string, body any, want int, hdr map[string]string) { t.Helper() var rdr io.Reader if body != nil { b, _ := json.Marshal(body) rdr = bytes.NewReader(b) } req, _ := http.NewRequest("POST", srv.URL+path, rdr) req.Header.Set("Authorization", "Bearer "+p.key) for k, v := range hdr { req.Header.Set(k, v) } resp, err := http.DefaultClient.Do(req) if err != nil { t.Fatal(err) } resp.Body.Close() if resp.StatusCode != want { t.Fatalf("POST %s: %d, want %d", path, resp.StatusCode, want) } } post("/login", nil, 200, map[string]string{"User-Agent": "TestBrowser/1.0", "X-Forwarded-For": "203.0.113.55, 10.0.0.1"}) src := "ldi r1, 5\nhalt" p.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": src}, 200) p.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200) p.do("DELETE", "/slots/5", nil, 204) p.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": src}, 200) post("/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 6}, 202, nil) msg := []byte{0x48, 0x45, 0x4c, 0x4c, 0x4f, 0x00, 0xff} req, _ := http.NewRequest("PUT", srv.URL+"/ships/"+itoa(p.ship)+"/uplink", bytes.NewReader(msg)) req.Header.Set("Authorization", "Bearer "+p.key) if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 204 { t.Fatalf("uplink: %v %v", resp, err) } rows := a.audit("") want := []string{"register", "login", "code_save", "code_save", "code_clear", "code_save", "launch", "uplink"} if got := actionsOf(rows); strings.Join(got, ",") != strings.Join(want, ",") { t.Fatalf("audit trail = %v\nwant %v", got, want) } if rows[0].Action != "uplink" || rows[len(rows)-1].Action != "register" { t.Fatal("entries should be listed newest first") } byAction := map[string][]auditRow{} for i := len(rows) - 1; i >= 0; i-- { byAction[rows[i].Action] = append(byAction[rows[i].Action], rows[i]) } for _, r := range rows { if r.Name != "ann" || r.PlayerID != 1 || r.At == 0 || r.IP == "" { t.Errorf("entry %d lacks who/when/where: %+v", r.ID, r) } } if d := byAction["register"][0].Detail; d["name"] != "ann" || byAction["register"][0].Ship != p.ship { t.Errorf("register: %+v", byAction["register"][0]) } lg := byAction["login"][0] if lg.IP != "203.0.113.55" || lg.Detail["user_agent"] != "TestBrowser/1.0" { t.Errorf("login: ip=%q detail=%v (the client address behind the proxy, and its browser)", lg.IP, lg.Detail) } s1 := byAction["code_save"][0] if s1.Slot != 5 || s1.Detail["name"] != "MINER" || s1.Detail["bytes"] != float64(len(src)) || s1.Detail["sha256"] != sha256hex(src) { t.Errorf("first save: %+v", s1) } if s2 := byAction["code_save"][1]; s2.Slot != 5 || s2.Detail["name"] != "MINER 2" { t.Errorf("second save: %+v", s2) } if c := byAction["code_clear"][0]; c.Slot != 5 { t.Errorf("clear: %+v", c) } l := byAction["launch"][0] if l.Ship != p.ship || l.Slot != 6 || l.Detail["program_bytes"] != float64(8) || l.Detail["slot_name"] != "TINY" { t.Errorf("launch: %+v", l) } u := byAction["uplink"][0] if u.Ship != p.ship || u.Detail["bytes"] != float64(len(msg)) || u.Detail["hex"] != hex.EncodeToString(msg) { t.Errorf("uplink: %+v (the data itself must be recorded)", u) } // Filters and paging. if got := actionsOf(a.audit("?action=code_save")); len(got) != 3 { t.Errorf("action filter returned %v", got) } if got := a.audit("?action=CODE_SAVE"); len(got) != 3 { t.Errorf("action filters ignore case: got %d", len(got)) } if got := a.audit("?player=1"); len(got) != len(want) { t.Errorf("player filter: %d", len(got)) } if got := a.audit("?player=99"); len(got) != 0 { t.Errorf("unknown player: %d entries", len(got)) } var seen []int64 before := "" for i := 0; i < 10; i++ { page := a.audit("?limit=3" + before) if len(page) == 0 { break } for _, r := range page { seen = append(seen, r.ID) } before = "&before=" + itoa(page[len(page)-1].ID) } if len(seen) != len(want) { t.Fatalf("paging returned %d entries %v, want %d", len(seen), seen, len(want)) } for i := 1; i < len(seen); i++ { if seen[i] >= seen[i-1] { t.Fatalf("paging repeated or reordered entries: %v", seen) } } for _, q := range []string{"?action=bogus", "?player=abc", "?before=-1", "?limit=0", "?limit=x"} { a.get("/admin/api/audit"+q, 400) } // Things that failed are not actions taken, and are not logged. n := len(a.audit("")) p.do("PUT", "/slots/1", map[string]string{"name": "X", "source": "halt"}, 403) // read-only example p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 6}, 409) // already launching p.do("PUT", "/ships/"+itoa(p.ship)+"/program", string(make([]byte, cfg.ProgramBytes+4)), 413) // too large bad := &player{t: t, base: srv.URL, key: "not-a-key"} bad.do("POST", "/login", nil, 401) // a wrong key is not a login if got := len(a.audit("")); got != n { t.Errorf("%d entries recorded for actions that failed", got-n) } } func TestAuditRecordsProgramUploadAndPlainLaunch(t *testing.T) { srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) a := newAdmin(t, srv.URL) a.login() p := newPlayer(t, srv.URL, "bob") prog := []byte{0x02, 0, 0, 0} // HALT req, _ := http.NewRequest("PUT", srv.URL+"/ships/"+itoa(p.ship)+"/program", bytes.NewReader(prog)) req.Header.Set("Authorization", "Bearer "+p.key) if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 204 { t.Fatalf("upload: %v %v", resp, err) } p.do("POST", "/ships/"+itoa(p.ship)+"/launch", nil, 202) rows := a.audit("") if got := strings.Join(actionsOf(rows), ","); got != "register,program_upload,launch" { t.Fatalf("trail = %s", got) } if up := rows[1]; up.Detail["bytes"] != float64(4) || up.Detail["sha256"] != sha256hex(string(prog)) || up.Ship != p.ship { t.Errorf("program upload: %+v", up) } if l := rows[0]; l.Slot != 0 || l.Ship != p.ship || l.Detail["program"] == nil { t.Errorf("plain launch: %+v", l) } } func TestAdminAccountsAndDetail(t *testing.T) { srv, _ := newSite(t, "", api.WithAdminPassword(adminPW)) a := newAdmin(t, srv.URL) a.login() ann := newPlayer(t, srv.URL, "ann") bob := newPlayer(t, srv.URL, "") // hostile names must come back as plain data req, _ := http.NewRequest("POST", srv.URL+"/login", nil) req.Header.Set("Authorization", "Bearer "+ann.key) if resp, err := http.DefaultClient.Do(req); err != nil || resp.StatusCode != 200 { t.Fatalf("login: %v %v", resp, err) } ann.do("PUT", "/slots/4", map[string]string{"name": "ALPHA", "source": "; alpha\nhalt"}, 200) ann.do("PUT", "/slots/9", map[string]string{"name": "OMEGA", "source": "; omega\nhalt"}, 200) ann.do("POST", "/ships/"+itoa(ann.ship)+"/launch", map[string]int{"slot": 4}, 202) var accts []struct { ID int64 `json:"id"` Name string `json:"name"` Credits int64 `json:"credits"` Ships int `json:"ships"` SlotsUsed int `json:"slots_used"` Actions int `json:"actions"` LastLogin int64 `json:"last_login"` LastAction int64 `json:"last_action"` } if err := json.Unmarshal(a.get("/admin/api/accounts", 200), &accts); err != nil || len(accts) != 2 { t.Fatalf("accounts: %v %v", err, accts) } if x := accts[0]; x.ID != 1 || x.Name != "ann" || x.Ships != 1 || x.SlotsUsed != 2 || x.Actions != 5 || x.LastLogin == 0 || x.LastAction == 0 { t.Errorf("ann's row: %+v", x) } if x := accts[1]; x.Name != "" || x.Actions != 1 || x.LastLogin != 0 || x.SlotsUsed != 0 { t.Errorf("bob's row: %+v", x) } _ = bob var d struct { Name string `json:"name"` Ships []struct { ID int64 `json:"id"` Status string `json:"status"` ProgramBytes int `json:"program_bytes"` } `json:"ships"` Slots []struct { Slot int `json:"slot"` Name string `json:"name"` Size int `json:"size"` } `json:"slots"` Audit []auditRow `json:"audit"` } if err := json.Unmarshal(a.get("/admin/api/accounts/1", 200), &d); err != nil { t.Fatal(err) } if d.Name != "ann" || len(d.Ships) != 1 || d.Ships[0].Status != "launching" || d.Ships[0].ProgramBytes == 0 { t.Errorf("detail ships: %+v", d) } if len(d.Slots) != 2 || d.Slots[0].Slot != 4 || d.Slots[0].Name != "ALPHA" || d.Slots[1].Slot != 9 || d.Slots[0].Size != len("; alpha\nhalt") { t.Errorf("detail slots: %+v", d.Slots) } if len(d.Audit) != 5 || d.Audit[0].Action != "launch" { t.Errorf("detail audit: %d entries, first %v", len(d.Audit), d.Audit) } // Another account's actions do not leak into this one's. for _, r := range d.Audit { if r.PlayerID != 1 { t.Errorf("foreign entry in ann's audit: %+v", r) } } // The code saved in a slot can be read. var src struct { Name string `json:"name"` Source string `json:"source"` } json.Unmarshal(a.get("/admin/api/accounts/1/slots/9", 200), &src) if src.Name != "OMEGA" || src.Source != "; omega\nhalt" { t.Errorf("slot source: %+v", src) } a.get("/admin/api/accounts/1/slots/12", 404) // empty a.get("/admin/api/accounts/1/slots/x", 400) a.get("/admin/api/accounts/99", 404) a.get("/admin/api/accounts/abc", 400) a.get("/admin/api/accounts/99/slots/4", 404) } func sha256hex(s string) string { return sha256HexOf([]byte(s)) } func sha256HexOf(b []byte) string { h := sha256.Sum256(b) return hex.EncodeToString(h[:]) }