Files
pages/internal/adminapi/adminapi_test.go
T
2026-08-15 07:13:00 +00:00

253 lines
7.0 KiB
Go

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
}