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

215 lines
7.1 KiB
Go

package api_test
import (
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"halcyon/api"
"halcyon/config"
"halcyon/store"
"halcyon/web"
)
// newSite starts the API with the web front end mounted, as cmd/server does.
func newSite(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config) {
srv, cfg, _ := newSiteStore(t, manualPDF, opts...)
return srv, cfg
}
// newSiteStore is newSite, also returning the database so tests can run a simulated day.
func newSiteStore(t *testing.T, manualPDF string, opts ...api.Option) (*httptest.Server, config.Config, *store.Store) {
t.Helper()
cfg := config.Default()
dsn := "sqlite:" + filepath.Join(t.TempDir(), "t.db")
if pg := os.Getenv("HALCYON_TEST_PG"); pg != "" { // run against PostgreSQL, wiping its public schema first
db, err := sql.Open("pgx", pg)
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec("DROP SCHEMA public CASCADE; CREATE SCHEMA public"); err != nil {
t.Fatal(err)
}
db.Close()
dsn = pg
}
st, err := store.Open(dsn)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
mux := api.New(st, cfg, opts...)
mux.Handle("/", web.Handler(manualPDF))
srv := httptest.NewServer(mux)
t.Cleanup(srv.Close)
return srv, cfg, st
}
func get(t *testing.T, url string) (*http.Response, string) {
t.Helper()
resp, err := http.Get(url)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return resp, string(b)
}
func TestInfoAndAssemble(t *testing.T) {
srv, cfg := newSite(t, "")
_, body := get(t, srv.URL+"/info")
var info struct {
ProgramBytes int `json:"program_bytes"`
CommBytes int `json:"comm_bytes"`
Ores []string `json:"ores"`
}
if err := json.Unmarshal([]byte(body), &info); err != nil {
t.Fatal(err)
}
if info.ProgramBytes != cfg.ProgramBytes || info.CommBytes != cfg.CommBytes || len(info.Ores) != 4 {
t.Fatalf("info = %+v", info)
}
post := func(src string) (int, map[string]any) {
resp, err := http.Post(srv.URL+"/assemble", "text/plain", strings.NewReader(src))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var m map[string]any
json.NewDecoder(resp.Body).Decode(&m)
return resp.StatusCode, m
}
code, m := post("ldi r1, 5\nhalt")
if code != 200 || m["size"].(float64) != 8 || m["fits"] != true {
t.Fatalf("assemble ok case: %d %v", code, m)
}
if raw, _ := base64.StdEncoding.DecodeString(m["program"].(string)); len(raw) != 8 {
t.Fatalf("program bytes = %d", len(raw))
}
code, m = post("ldi r1, 5\nbogus r1")
if code != 400 || !strings.Contains(m["error"].(string), "line 2") {
t.Fatalf("assemble error case: %d %v", code, m)
}
// Too big for the belt: assembles, but reports that it does not fit.
code, m = post(strings.Repeat("nop\n", cfg.ProgramBytes/4+1))
if code != 200 || m["fits"] != false {
t.Fatalf("oversize case: %d %v", code, m)
}
}
func TestWebAssets(t *testing.T) {
srv, _ := newSite(t, "")
for path, want := range map[string]string{
"/": "<title>",
"/manual.md": "Programmer's Reference Manual",
"/examples/echo.s": "ECHO",
"/examples/index.json": `"pursue"`,
} {
resp, body := get(t, srv.URL+path)
if resp.StatusCode != 200 || !strings.Contains(body, want) {
t.Errorf("%s: status %d, missing %q", path, resp.StatusCode, want)
}
}
resp, _ := get(t, srv.URL+"/")
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
t.Error("missing CSP header")
}
// The PDF is rendered separately; until it exists /manual.pdf explains how.
presp, pbody := get(t, srv.URL+"/manual.pdf")
if presp.StatusCode != 404 || !strings.Contains(pbody, "render-manual.sh") {
t.Errorf("/manual.pdf before any render: status %d: %s", presp.StatusCode, pbody)
}
// The API still wins over the catch-all.
if resp, _ := get(t, srv.URL+"/market"); !strings.HasPrefix(resp.Header.Get("Content-Type"), "application/json") {
t.Errorf("/market served as %q", resp.Header.Get("Content-Type"))
}
}
func TestManualPDFServedFromFileAndPickedUpLive(t *testing.T) {
path := filepath.Join(t.TempDir(), "manual.pdf")
srv, _ := newSite(t, path)
if resp, _ := get(t, srv.URL+"/manual.pdf"); resp.StatusCode != 404 {
t.Fatalf("before the file exists: %d", resp.StatusCode)
}
write := func(content string) {
// Like the renderer: write beside the target, then rename into place.
if err := os.WriteFile(path+".tmp", []byte(content), 0o644); err != nil {
t.Fatal(err)
}
if err := os.Rename(path+".tmp", path); err != nil {
t.Fatal(err)
}
}
write("%PDF-1.7 first render")
resp, body := get(t, srv.URL+"/manual.pdf")
if resp.StatusCode != 200 || body != "%PDF-1.7 first render" || resp.Header.Get("Content-Type") != "application/pdf" {
t.Fatalf("first render: %d %q %q", resp.StatusCode, body, resp.Header.Get("Content-Type"))
}
if resp.Header.Get("Cache-Control") != "no-cache" || resp.Header.Get("Content-Security-Policy") != "" {
t.Errorf("headers: cache=%q csp=%q", resp.Header.Get("Cache-Control"), resp.Header.Get("Content-Security-Policy"))
}
// A re-render is served immediately, with no restart.
write("%PDF-1.7 second render, longer")
if _, body := get(t, srv.URL+"/manual.pdf"); body != "%PDF-1.7 second render, longer" {
t.Fatalf("new render not picked up: %q", body)
}
// Range requests work (PDF viewers rely on them).
req, _ := http.NewRequest("GET", srv.URL+"/manual.pdf", nil)
req.Header.Set("Range", "bytes=0-4")
rr, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer rr.Body.Close()
if part, _ := io.ReadAll(rr.Body); rr.StatusCode != 206 || string(part) != "%PDF-" {
t.Fatalf("range: %d %q", rr.StatusCode, part)
}
}
func TestAdminPageIsServedWithSafeHeaders(t *testing.T) {
srv, _ := newSite(t, "")
resp, body := get(t, srv.URL+"/admin/")
if resp.StatusCode != 200 || !strings.Contains(body, "Halcyon Admin") {
t.Fatalf("/admin/: %d", resp.StatusCode)
}
if !strings.Contains(resp.Header.Get("Content-Security-Policy"), "default-src 'self'") {
t.Error("the admin page needs the site's Content-Security-Policy")
}
if !strings.Contains(resp.Header.Get("X-Robots-Tag"), "noindex") || resp.Header.Get("Cache-Control") != "no-store" {
t.Errorf("admin page headers: robots=%q cache=%q", resp.Header.Get("X-Robots-Tag"), resp.Header.Get("Cache-Control"))
}
for _, path := range []string{"/admin/admin.js", "/admin/admin.css"} {
if r, _ := get(t, srv.URL+path); r.StatusCode != 200 {
t.Errorf("%s: %d", path, r.StatusCode)
}
}
// /admin (no slash) reaches the same page.
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
r, err := client.Get(srv.URL + "/admin")
if err != nil {
t.Fatal(err)
}
r.Body.Close()
// The Location is relative ("admin/"), which a browser resolves to /admin/.
if loc := r.Header.Get("Location"); r.StatusCode != 301 || (loc != "admin/" && loc != "/admin/") {
t.Errorf("/admin: %d -> %q, want a redirect to /admin/", r.StatusCode, loc)
}
}