248 lines
7.9 KiB
Go
248 lines
7.9 KiB
Go
package api_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
|
|
"halcyon/runner"
|
|
)
|
|
|
|
// player is a registered account talking to the API.
|
|
type player struct {
|
|
t *testing.T
|
|
base string
|
|
key string
|
|
ship int64
|
|
}
|
|
|
|
func newPlayer(t *testing.T, base, name string) *player {
|
|
t.Helper()
|
|
resp, err := http.Post(base+"/register", "application/json", strings.NewReader(`{"name":"`+name+`"}`))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
var r struct {
|
|
APIKey string `json:"api_key"`
|
|
ShipID int64 `json:"ship_id"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&r); err != nil || r.APIKey == "" {
|
|
t.Fatalf("register %s: %v", name, err)
|
|
}
|
|
return &player{t: t, base: base, key: r.APIKey, ship: r.ShipID}
|
|
}
|
|
|
|
// do sends a request and returns the body, failing unless the status is want.
|
|
func (p *player) do(method, path string, body any, want int) []byte {
|
|
p.t.Helper()
|
|
var rdr io.Reader
|
|
if body != nil {
|
|
if s, ok := body.(string); ok {
|
|
rdr = strings.NewReader(s)
|
|
} else {
|
|
b, _ := json.Marshal(body)
|
|
rdr = bytes.NewReader(b)
|
|
}
|
|
}
|
|
req, _ := http.NewRequest(method, p.base+path, rdr)
|
|
if p.key != "" {
|
|
req.Header.Set("Authorization", "Bearer "+p.key)
|
|
}
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
p.t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
b, _ := io.ReadAll(resp.Body)
|
|
if resp.StatusCode != want {
|
|
p.t.Fatalf("%s %s: status %d want %d: %s", method, path, resp.StatusCode, want, b)
|
|
}
|
|
return b
|
|
}
|
|
|
|
type slotRow struct {
|
|
Slot int `json:"slot"`
|
|
Name string `json:"name"`
|
|
Builtin bool `json:"builtin"`
|
|
Empty bool `json:"empty"`
|
|
Source string `json:"source"`
|
|
}
|
|
|
|
func (p *player) slots() []slotRow {
|
|
var rows []slotRow
|
|
json.Unmarshal(p.do("GET", "/slots", nil, 200), &rows)
|
|
return rows
|
|
}
|
|
|
|
func (p *player) slot(n int) slotRow {
|
|
var r slotRow
|
|
json.Unmarshal(p.do("GET", "/slots/"+itoa(int64(n)), nil, 200), &r)
|
|
return r
|
|
}
|
|
|
|
func (p *player) shipStatus() (status string, programBytes int) {
|
|
var ships []struct {
|
|
Status string `json:"status"`
|
|
ProgramBytes int `json:"program_bytes"`
|
|
}
|
|
json.Unmarshal(p.do("GET", "/ships", nil, 200), &ships)
|
|
return ships[0].Status, ships[0].ProgramBytes
|
|
}
|
|
|
|
func TestSlotsListAndExamples(t *testing.T) {
|
|
srv, _ := newSite(t, "")
|
|
a := newPlayer(t, srv.URL, "ann")
|
|
|
|
rows := a.slots()
|
|
if len(rows) != 16 {
|
|
t.Fatalf("%d slots, want 16", len(rows))
|
|
}
|
|
for i, want := range []string{"ECHO", "TELEMETRY", "PURSUE"} {
|
|
if r := rows[i]; r.Slot != i+1 || r.Name != want || !r.Builtin || r.Empty {
|
|
t.Errorf("slot %d = %+v, want the %s example", i+1, r, want)
|
|
}
|
|
}
|
|
for _, r := range rows[3:] {
|
|
if r.Builtin || !r.Empty || r.Name != "" {
|
|
t.Errorf("slot %d should start empty: %+v", r.Slot, r)
|
|
}
|
|
}
|
|
if s := a.slot(2); !s.Builtin || !strings.Contains(s.Source, "TELEMETRY") {
|
|
t.Errorf("slot 2 = %+v", s)
|
|
}
|
|
if s := a.slot(9); !s.Empty || s.Source != "" {
|
|
t.Errorf("empty slot 9 = %+v", s)
|
|
}
|
|
}
|
|
|
|
func TestSlotsSaveLoadClearAndIsolation(t *testing.T) {
|
|
srv, _ := newSite(t, "")
|
|
a, b := newPlayer(t, srv.URL, "ann"), newPlayer(t, srv.URL, "bob")
|
|
|
|
a.do("PUT", "/slots/5", map[string]string{"name": "MINER", "source": "ldi r0, 1\nhalt"}, 200)
|
|
if s := a.slot(5); s.Name != "MINER" || s.Source != "ldi r0, 1\nhalt" || s.Empty || s.Builtin {
|
|
t.Fatalf("saved slot = %+v", s)
|
|
}
|
|
if r := a.slots()[4]; r.Name != "MINER" || r.Empty {
|
|
t.Fatalf("listing = %+v", r)
|
|
}
|
|
// Saving again replaces it, including the name.
|
|
a.do("PUT", "/slots/5", map[string]string{"name": "MINER 2", "source": "halt"}, 200)
|
|
if s := a.slot(5); s.Name != "MINER 2" || s.Source != "halt" {
|
|
t.Fatalf("overwritten slot = %+v", s)
|
|
}
|
|
// Slots are private to their owner.
|
|
if s := b.slot(5); !s.Empty {
|
|
t.Fatalf("bob can see ann's slot: %+v", s)
|
|
}
|
|
|
|
// The examples cannot be changed or cleared.
|
|
for _, n := range []string{"1", "2", "3"} {
|
|
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 403)
|
|
a.do("DELETE", "/slots/"+n, nil, 403)
|
|
}
|
|
// Bad slot numbers and bodies.
|
|
for _, n := range []string{"0", "17", "abc", "-1"} {
|
|
a.do("GET", "/slots/"+n, nil, 400)
|
|
a.do("PUT", "/slots/"+n, map[string]string{"name": "X", "source": "halt"}, 400)
|
|
}
|
|
a.do("PUT", "/slots/6", map[string]string{"name": " ", "source": "halt"}, 400)
|
|
a.do("PUT", "/slots/6", map[string]string{"name": strings.Repeat("x", 41), "source": "halt"}, 400)
|
|
a.do("PUT", "/slots/6", "not json", 400)
|
|
a.do("PUT", "/slots/6", map[string]string{"name": "BIG", "source": strings.Repeat("x", 64<<10+1)}, 413)
|
|
a.do("PUT", "/slots/6", map[string]string{"name": "OK", "source": strings.Repeat("x", 64<<10)}, 200)
|
|
|
|
// Clearing.
|
|
a.do("DELETE", "/slots/5", nil, 204)
|
|
a.do("DELETE", "/slots/5", nil, 204) // clearing an empty slot is fine
|
|
if s := a.slot(5); !s.Empty {
|
|
t.Fatalf("cleared slot = %+v", s)
|
|
}
|
|
|
|
// Everything needs a key.
|
|
anon := &player{t: t, base: srv.URL}
|
|
anon.do("GET", "/slots", nil, 401)
|
|
anon.do("PUT", "/slots/5", map[string]string{"name": "X", "source": "halt"}, 401)
|
|
}
|
|
|
|
func TestLaunchFromSlot(t *testing.T) {
|
|
srv, cfg := newSite(t, "")
|
|
launch := func(p *player, slot any, want int) []byte {
|
|
return p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]any{"slot": slot}, want)
|
|
}
|
|
|
|
// A saved slot: assembled, sealed into the ship, and launched in one step.
|
|
a := newPlayer(t, srv.URL, "ann")
|
|
a.do("PUT", "/slots/6", map[string]string{"name": "TINY", "source": "ldi r0, 1\nhalt"}, 200)
|
|
var res struct {
|
|
Slot int `json:"slot"`
|
|
ProgramBytes int `json:"program_bytes"`
|
|
}
|
|
json.Unmarshal(launch(a, 6, 202), &res)
|
|
if res.Slot != 6 || res.ProgramBytes != 8 {
|
|
t.Fatalf("launch reply = %+v", res)
|
|
}
|
|
if st, n := a.shipStatus(); st != "launching" || n != 8 {
|
|
t.Fatalf("ship is %s with %d program bytes", st, n)
|
|
}
|
|
launch(a, 6, 409) // already launching
|
|
|
|
// Things that must be refused, leaving the ship docked and its program untouched.
|
|
b := newPlayer(t, srv.URL, "bob")
|
|
b.do("PUT", "/slots/7", map[string]string{"name": "BROKEN", "source": "ldi r1, 5\nbogus r1"}, 200)
|
|
b.do("PUT", "/slots/8", map[string]string{"name": "HUGE", "source": strings.Repeat("nop\n", cfg.ProgramBytes/4+1)}, 200)
|
|
b.do("PUT", "/slots/10", map[string]string{"name": "BLANK", "source": " \n"}, 200)
|
|
for name, c := range map[string]struct {
|
|
slot any
|
|
msg string
|
|
}{
|
|
"empty slot": {9, "empty"},
|
|
"blank source": {10, "empty"},
|
|
"does not compile": {7, "line 2"},
|
|
"too large": {8, "limit"},
|
|
"out of range": {99, "1 to 16"},
|
|
"zero": {0, "1 to 16"},
|
|
} {
|
|
if body := launch(b, c.slot, 400); !strings.Contains(string(body), c.msg) {
|
|
t.Errorf("%s: error %q should mention %q", name, body, c.msg)
|
|
}
|
|
}
|
|
if st, n := b.shipStatus(); st != "inventory" || n != 0 {
|
|
t.Fatalf("a refused launch changed the ship: %s, %d bytes", st, n)
|
|
}
|
|
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", "{not json", 400)
|
|
b.do("POST", "/ships/"+itoa(b.ship)+"/launch", nil, 409) // no slot and no sealed program: as before
|
|
// Slots are per player: ann's saved slot 6 is empty for bob.
|
|
launch(b, 6, 400)
|
|
// Ann cannot launch bob's ship with her slot.
|
|
a.do("POST", "/ships/"+itoa(b.ship)+"/launch", map[string]int{"slot": 6}, 404)
|
|
|
|
// An example slot works for anyone.
|
|
launch(b, 2, 202)
|
|
if st, n := b.shipStatus(); st != "launching" || n == 0 {
|
|
t.Fatalf("after launching the telemetry example: %s, %d bytes", st, n)
|
|
}
|
|
}
|
|
|
|
// A ship launched from a slot really flies that slot's program.
|
|
func TestSlotProgramRunsInTheBelt(t *testing.T) {
|
|
srv, cfg, st := newSiteStore(t, "")
|
|
cfg.AsteroidCount = 20
|
|
p := newPlayer(t, srv.URL, "ann")
|
|
p.do("POST", "/ships/"+itoa(p.ship)+"/launch", map[string]int{"slot": 2}, 202) // TELEMETRY
|
|
|
|
if _, err := runner.RunNextDay(context.Background(), st, cfg); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
down := p.do("GET", "/ships/"+itoa(p.ship)+"/downlink", nil, 200)
|
|
if tick := binary.LittleEndian.Uint32(down[0:4]); tick != uint32(cfg.TicksPerDay-1) {
|
|
t.Fatalf("telemetry downlink reports tick %d, want the last tick %d", tick, cfg.TicksPerDay-1)
|
|
}
|
|
}
|