vibes
This commit is contained in:
@@ -0,0 +1,473 @@
|
||||
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[:])
|
||||
}
|
||||
Reference in New Issue
Block a user