package adminapi import ( "bytes" "context" "encoding/json" "io" "log/slog" "net/http" "net/http/httptest" "path/filepath" "testing" "time" "github.com/iceBear67/simplepages/api" "github.com/iceBear67/simplepages/internal/auth" "github.com/iceBear67/simplepages/internal/cas" "github.com/iceBear67/simplepages/internal/config" "github.com/iceBear67/simplepages/internal/deploy" "github.com/iceBear67/simplepages/internal/site" "github.com/iceBear67/simplepages/internal/store" "github.com/iceBear67/simplepages/internal/webroot" ) // env is a management API running against a real SQLite file in a temp dir. // Nothing here is faked below the HTTP boundary: the tests exercise the same // store, verifier and route table the server assembles. type env struct { db *store.DB cas *cas.Store deployDir string webrootDir string sites *site.Registry verifier *auth.Verifier server *Server ts *httptest.Server adminToken string logBuf *bytes.Buffer } func newEnv(t *testing.T) *env { t.Helper() ctx := t.Context() var buf bytes.Buffer log := slog.New(slog.NewTextHandler(&buf, &slog.HandlerOptions{Level: slog.LevelDebug})) base := t.TempDir() db, err := store.Open(ctx, filepath.Join(base, "pages.db"), log) if err != nil { t.Fatalf("open store: %v", err) } t.Cleanup(func() { db.Close() }) deployDir := filepath.Join(base, "deployments") cs, err := cas.Open(filepath.Join(base, "cas"), cas.Options{ProbeDir: deployDir, Log: log}) if err != nil { t.Fatalf("open cas: %v", err) } t.Cleanup(func() { cs.Close() }) webrootDir := filepath.Join(base, "www") wr, err := webroot.Open(webrootDir, deployDir) if err != nil { t.Fatalf("open webroot: %v", err) } // The serving layer is wired up exactly as cmd/pages-server does it, so the // endpoints that publish into it — activate above all — are exercised against // the same collaborators they have in production. verifier := auth.NewVerifier(db, log, auth.DefaultCacheTTL) sites := site.NewRegistry() e := &env{ db: db, cas: cs, deployDir: deployDir, webrootDir: webrootDir, sites: sites, verifier: verifier, logBuf: &buf, } e.server = &Server{ DB: db, Deploy: &deploy.Service{ DB: db, CAS: cs, Log: log, Dir: deployDir, Sites: sites, Webroot: wr, }, Auth: &auth.Middleware{ V: verifier, // A burst high enough that the limiter never fires by accident; the // throttling behaviour itself is tested in internal/auth. Limiter: auth.NewLimiter(10000, time.Minute, 128), Log: log, }, Log: log, Limits: config.Default().Limits, Sites: sites, Started: time.Now().Add(-time.Minute), Hooks: Hooks{ ProjectChanged: func(_ context.Context, p *store.Project) { sites.Put(p) }, ProjectDeleted: func(_ context.Context, p *store.Project) { sites.Delete(p.Name) if err := wr.Unpoint(p.Name); err != nil { t.Errorf("unpoint %s: %v", p.Name, err) } }, }, } mux := http.NewServeMux() e.server.Register(mux) e.ts = httptest.NewServer(mux) t.Cleanup(e.ts.Close) e.adminToken = e.mintAdmin(t, "test-admin") return e } // mintAdmin creates an admin key directly in the store, the way the bootstrap // path does, and returns its token. func (e *env) mintAdmin(t *testing.T, name string) string { t.Helper() return e.mint(t, store.ScopeAdmin, nil, name) } func (e *env) mintProject(t *testing.T, projectID int64, name string) string { t.Helper() return e.mint(t, store.ScopeProject, &projectID, name) } func (e *env) mint(t *testing.T, scope store.Scope, projectID *int64, name string) string { t.Helper() return e.mintWith(t, scope, projectID, name, nil) } // mintWith goes around the API so a test can create a key the API would refuse // to mint — an already-expired one, for instance. func (e *env) mintWith(t *testing.T, scope store.Scope, projectID *int64, name string, expires *time.Time) string { t.Helper() token, keyID, hash, err := auth.Mint() if err != nil { t.Fatalf("mint: %v", err) } k := &store.APIKey{ ID: keyID, SecretHash: hash[:], Scope: scope, ProjectID: projectID, Name: name, ExpiresAt: expires, } if err := e.db.CreateKey(t.Context(), k); err != nil { t.Fatalf("create key: %v", err) } return token } // do issues a request and returns the status and raw body. The body is returned // undecoded so tests can assert on what is literally on the wire. func (e *env) do(t *testing.T, method, path, token string, body any) (int, []byte) { t.Helper() var r io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { t.Fatalf("marshal request: %v", err) } r = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(t.Context(), method, e.ts.URL+path, r) if err != nil { t.Fatalf("new request: %v", err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } if body != nil { req.Header.Set("Content-Type", "application/json") } resp, err := e.ts.Client().Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } defer resp.Body.Close() raw, err := io.ReadAll(resp.Body) if err != nil { t.Fatalf("read body: %v", err) } return resp.StatusCode, raw } // doResp is do() for the tests that need to inspect response headers. func (e *env) doResp(t *testing.T, method, path, token string, body any) *http.Response { t.Helper() var r io.Reader if body != nil { buf, err := json.Marshal(body) if err != nil { t.Fatalf("marshal request: %v", err) } r = bytes.NewReader(buf) } req, err := http.NewRequestWithContext(t.Context(), method, e.ts.URL+path, r) if err != nil { t.Fatalf("new request: %v", err) } if token != "" { req.Header.Set("Authorization", "Bearer "+token) } resp, err := e.ts.Client().Do(req) if err != nil { t.Fatalf("%s %s: %v", method, path, err) } t.Cleanup(func() { resp.Body.Close() }) return resp } // mustJSON decodes body into v, failing the test if the status is unexpected. func mustJSON(t *testing.T, status, want int, body []byte, v any) { t.Helper() if status != want { t.Fatalf("status = %d, want %d; body: %s", status, want, body) } if v == nil { return } if err := json.Unmarshal(body, v); err != nil { t.Fatalf("decode %T: %v; body: %s", v, err, body) } } // errCode extracts the machine-readable code from an error envelope. func errCode(t *testing.T, body []byte) api.Code { t.Helper() var env api.ErrorEnvelope if err := json.Unmarshal(body, &env); err != nil { t.Fatalf("decode error envelope: %v; body: %s", err, body) } return env.Error.Code } // createProject is the fixture most tests start from. func (e *env) createProject(t *testing.T, name string) api.Project { t.Helper() status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken, api.CreateProjectRequest{Name: name}) var p api.Project mustJSON(t, status, http.StatusCreated, body, &p) return p } // projectID looks up the row id, which the wire format deliberately does not // carry. func (e *env) projectID(t *testing.T, name string) int64 { t.Helper() p, err := e.db.ProjectByName(context.Background(), name) if err != nil { t.Fatalf("project %q: %v", name, err) } return p.ID }