253 lines
7.0 KiB
Go
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
|
|
}
|