Files
Halcyon/api/admin_test.go
T
2026-10-01 22:16:48 +02:00

474 lines
17 KiB
Go

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, "<img src=x onerror=alert(1)>") // 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 != "<img src=x onerror=alert(1)>" || 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[:])
}