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