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{ "/": "", "/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) } }