This commit is contained in:
iceBear67
2026-08-15 07:13:00 +00:00
commit dd50674fdc
114 changed files with 26865 additions and 0 deletions
+307
View File
@@ -0,0 +1,307 @@
package adminapi
import (
"net/http"
"os"
"path/filepath"
"testing"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/deploy"
)
// readyDeployment runs a whole deployment through create, manifest, upload and
// finalize, so an activation test starts from the only state activation accepts.
func (e *env) readyDeployment(t *testing.T, token, project string, contents map[string]string) api.Deployment {
t.Helper()
dep := e.startDeployment(t, token, project)
files := make([]api.FileEntry, 0, len(contents))
for path, content := range contents {
files = append(files, entry(path, content))
}
status, body := e.do(t, http.MethodPost, api.PathManifest(project, dep.ID), token,
api.ManifestRequest{Files: files})
var mr api.ManifestResponse
mustJSON(t, status, http.StatusOK, body, &mr)
for _, content := range contents {
if status, body := e.putBlob(t, token, content); status != http.StatusCreated && status != http.StatusOK {
t.Fatalf("upload: status = %d; body: %s", status, body)
}
}
status, body = e.do(t, http.MethodPost, api.PathFinalize(project, dep.ID), token, nil)
var out api.Deployment
mustJSON(t, status, http.StatusOK, body, &out)
return out
}
// symlink reads $WEBROOT/~project, failing if it is not a link.
func (e *env) symlink(t *testing.T, project string) string {
t.Helper()
target, err := os.Readlink(filepath.Join(e.webrootDir, "~"+project))
if err != nil {
t.Fatalf("readlink ~%s: %v", project, err)
}
return target
}
// TestActivate covers the endpoint the whole product turns on: what it answers,
// what it publishes into the serving registry, and what it leaves on disk.
func TestActivate(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
pid := e.projectID(t, "demo")
// Nothing is served before the first activation.
sp, ok := e.sites.Lookup("demo")
if !ok {
t.Fatal("creating a project did not register it")
}
if sp.Active() != nil {
t.Fatal("a project with no deployment is serving something")
}
first := e.readyDeployment(t, token, "demo", map[string]string{
"index.html": "<h1>v1</h1>",
"assets/app.js": "console.log(1)",
})
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil)
var got api.Deployment
mustJSON(t, status, http.StatusOK, body, &got)
if got.ID != first.ID || !got.Active || got.State != api.StateReady {
t.Fatalf("activated = %+v", got)
}
if got.ActivatedAt == nil {
t.Error("activated_at is missing from the response")
}
if got.URL != "" {
t.Errorf("url = %q, want it omitted when no base URL is configured", got.URL)
}
// The registry is what the site handler reads, so this is the assertion that
// the deployment is actually being served and not merely recorded.
d := sp.Active()
if d == nil {
t.Fatal("activation did not publish into the registry")
}
if d.ID != first.ID || d.FileCount != 2 {
t.Fatalf("published snapshot = %+v", d)
}
if _, ok := d.Lookup("assets/app.js"); !ok {
t.Error("the published snapshot does not know a file the manifest declared")
}
wantDir := deploy.DeploymentDir(e.deployDir, pid, first.ID)
if got := e.symlink(t, "demo"); got != wantDir {
t.Errorf("~demo -> %q, want %q", got, wantDir)
}
// The project listing now reports what is being served, without a query per
// project.
status, body = e.do(t, http.MethodGet, api.PathProject("demo"), token, nil)
var p api.Project
mustJSON(t, status, http.StatusOK, body, &p)
if p.ActiveDeployment == nil || p.ActiveDeployment.ID != first.ID {
t.Fatalf("active_deployment = %+v", p.ActiveDeployment)
}
if !p.ActiveDeployment.Active || p.ActiveDeployment.State != api.StateReady {
t.Errorf("active_deployment = %+v", p.ActiveDeployment)
}
// --- a second deployment takes over
second := e.readyDeployment(t, token, "demo", map[string]string{
"index.html": "<h1>v2</h1>",
"assets/app.js": "console.log(1)", // unchanged, so it shares a blob
})
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", second.ID), token, nil)
mustJSON(t, status, http.StatusOK, body, &got)
if got.ID != second.ID || !got.Active {
t.Fatalf("second activation = %+v", got)
}
if d := sp.Active(); d == nil || d.ID != second.ID {
t.Fatalf("the registry still serves %v", d)
}
if got := e.symlink(t, "demo"); got != deploy.DeploymentDir(e.deployDir, pid, second.ID) {
t.Errorf("~demo -> %q, want the second deployment", got)
}
// --- rollback is the same endpoint on an older deployment
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil)
mustJSON(t, status, http.StatusOK, body, &got)
if got.ID != first.ID || !got.Active {
t.Fatalf("rollback = %+v", got)
}
if d := sp.Active(); d == nil || d.ID != first.ID {
t.Fatalf("the registry did not roll back: %v", d)
}
if got := e.symlink(t, "demo"); got != wantDir {
t.Errorf("~demo -> %q, want the first deployment again", got)
}
// Re-activating what is already active is a no-op that still answers.
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", first.ID), token, nil)
mustJSON(t, status, http.StatusOK, body, &got)
if !got.Active {
t.Errorf("re-activation = %+v", got)
}
}
func TestActivateReportsTheSiteURL(t *testing.T) {
e := newEnv(t)
e.server.BaseURL = "https://pages.example.com"
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
var got api.Deployment
mustJSON(t, status, http.StatusOK, body, &got)
if want := api.SiteURL(e.server.BaseURL, "demo"); got.URL != want {
t.Errorf("url = %q, want %q", got.URL, want)
}
}
// Activation is the one operation that changes what the world sees, so every
// deployment that is not finished must be refused — and refused without
// disturbing whatever is being served.
func TestActivateRequiresAReadyDeployment(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
serving := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>v1</h1>"})
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", serving.ID), token, nil)
mustJSON(t, status, http.StatusOK, body, nil)
t.Run("pending", func(t *testing.T) {
dep := e.startDeployment(t, token, "demo")
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
if status != http.StatusConflict {
t.Fatalf("status = %d, want 409; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeDeploymentNotReady {
t.Errorf("code = %q, want %q", code, api.CodeDeploymentNotReady)
}
})
t.Run("uploading", func(t *testing.T) {
dep := e.startDeployment(t, token, "demo")
status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
api.ManifestRequest{Files: []api.FileEntry{entry("index.html", "never uploaded")}})
mustJSON(t, status, http.StatusOK, body, nil)
status, body = e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
if status != http.StatusConflict {
t.Fatalf("status = %d, want 409; body: %s", status, body)
}
})
t.Run("unknown", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", "dpl_ffffffffffffffff"), token, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeNotFound {
t.Errorf("code = %q, want %q", code, api.CodeNotFound)
}
})
// Every refusal above left the site alone.
sp, _ := e.sites.Lookup("demo")
if d := sp.Active(); d == nil || d.ID != serving.ID {
t.Errorf("a refused activation changed what is served: %v", d)
}
}
// The security property: naming another project's deployment must not activate
// it, whether the caller routes through their own project or the victim's.
func TestActivateIsProjectScoped(t *testing.T) {
e := newEnv(t)
e.createProject(t, "victim")
e.createProject(t, "attacker")
victimToken := e.mintProject(t, e.projectID(t, "victim"), "ci")
attackerToken := e.mintProject(t, e.projectID(t, "attacker"), "ci")
target := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "<h1>v1</h1>"})
newer := e.readyDeployment(t, victimToken, "victim", map[string]string{"index.html": "<h1>v2</h1>"})
status, body := e.do(t, http.MethodPost, api.PathActivate("victim", newer.ID), victimToken, nil)
mustJSON(t, status, http.StatusOK, body, nil)
t.Run("through the victim's project", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathActivate("victim", target.ID), attackerToken, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
t.Run("through the attacker's own project", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathActivate("attacker", target.ID), attackerToken, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
})
sp, _ := e.sites.Lookup("victim")
if d := sp.Active(); d == nil || d.ID != newer.ID {
t.Errorf("the victim is now serving %v", d)
}
if _, ok := e.sites.Lookup("attacker"); !ok {
t.Fatal("the attacker's project vanished")
}
if sp, _ := e.sites.Lookup("attacker"); sp.Active() != nil {
t.Error("the attacker ended up serving the victim's deployment")
}
}
func TestActivateRejectsABodyAndWrongMethods(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token,
map[string]string{"unexpected": "field"})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodDelete} {
resp := e.doResp(t, method, api.PathActivate("demo", dep.ID), token, nil)
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("%s: status = %d, want 405", method, resp.StatusCode)
}
if allow := resp.Header.Get("Allow"); allow != http.MethodPost {
t.Errorf("%s: Allow = %q, want POST", method, allow)
}
}
if status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), "", nil); status != http.StatusUnauthorized {
t.Errorf("unauthenticated: status = %d, want 401; body: %s", status, body)
}
}
// Deleting a project takes its symlink with it: the deployment tree is about to
// be GC'd, and a link left pointing at it would dangle.
func TestDeleteProjectUnpointsTheWebroot(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.readyDeployment(t, token, "demo", map[string]string{"index.html": "<h1>hi</h1>"})
status, body := e.do(t, http.MethodPost, api.PathActivate("demo", dep.ID), token, nil)
mustJSON(t, status, http.StatusOK, body, nil)
e.symlink(t, "demo")
status, body = e.do(t, http.MethodDelete, api.PathProject("demo"), e.adminToken, nil)
if status != http.StatusNoContent {
t.Fatalf("delete: status = %d; body: %s", status, body)
}
if _, err := os.Lstat(filepath.Join(e.webrootDir, "~demo")); err == nil {
t.Error("the symlink outlived the project")
}
if _, ok := e.sites.Lookup("demo"); ok {
t.Error("the deleted project is still in the serving registry")
}
}
+252
View File
@@ -0,0 +1,252 @@
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
}
+39
View File
@@ -0,0 +1,39 @@
package adminapi
import (
"net/http"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/httpx"
)
// putBlob handles PUT /api/v1/blobs/{digest}.
//
// Blobs are global rather than per-project because the store deduplicates
// across projects, so any authenticated key may upload one — but only content
// some manifest already declared, and only bytes that really hash to the digest
// in the URL. Both checks live in deploy.Service and cas.Store; the digest here
// is a claim until then.
//
// The route is deliberately not owner-guarded: there is no project in the path
// to guard against. What a caller can do with it is bounded by the manifest
// requirement, and the resulting cross-project existence oracle is the known,
// documented trade-off of shared deduplication.
func (s *Server) putBlob(w http.ResponseWriter, r *http.Request) error {
digest, err := cas.ParseDigest(r.PathValue("digest"))
if err != nil {
return api.Errorf(api.CodeBadRequest, "%s", err)
}
size, stored, err := s.Deploy.Upload(r.Context(), digest, r.Body)
if err != nil {
return err
}
status := http.StatusOK // already had it
if stored {
status = http.StatusCreated
}
httpx.WriteJSON(w, status, api.BlobResponse{Digest: digest.String(), Size: size})
return nil
}
+199
View File
@@ -0,0 +1,199 @@
package adminapi
import (
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/store"
)
// projectOf renders a stored project for the wire.
func (s *Server) projectOf(p *store.Project) api.Project {
out := api.Project{
Name: p.Name,
DisplayName: p.DisplayName,
IndexFile: p.IndexFile,
NotFoundFile: p.NotFoundFile,
SPAFallback: p.SPAFallback,
CacheControl: p.CacheControl,
RetentionCount: p.RetentionCount,
RetentionGrace: p.RetentionGraceS,
MaxFiles: p.MaxFiles,
MaxFileBytes: p.MaxFileBytes,
MaxTotalBytes: p.MaxTotalBytes,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
}
if s.BaseURL != "" {
out.URL = api.SiteURL(s.BaseURL, p.Name)
}
out.ActiveDeployment = s.activeOf(p)
return out
}
// activeOf summarises what a project is serving right now.
//
// It reads the registry rather than the database so listing a hundred projects
// stays one query. The summary carries what is being served — id, size,
// timestamps — and not the row's metadata or error text; those come from
// fetching the deployment itself.
func (s *Server) activeOf(p *store.Project) *api.Deployment {
if s.Sites == nil {
return nil
}
sp, ok := s.Sites.Lookup(p.Name)
if !ok {
return nil
}
d := sp.Active()
if d == nil {
return nil
}
out := &api.Deployment{
ID: d.ID,
Project: p.Name,
State: api.StateReady, // only a ready deployment can be active
Active: true,
FileCount: d.FileCount,
TotalBytes: d.TotalBytes,
CreatedAt: d.CreatedAt,
}
if !d.ActivatedAt.IsZero() {
t := d.ActivatedAt
out.ActivatedAt = &t
}
return out
}
// deploymentOf renders a stored deployment for the wire.
//
// The row id stays behind: the wire only ever names a deployment by its public
// id, so nothing a client holds can be walked to a neighbouring row.
func deploymentOf(p *store.Project, d *store.Deployment) api.Deployment {
return api.Deployment{
ID: d.PublicID,
Project: p.Name,
State: string(d.State),
Active: d.Active,
FileCount: d.FileCount,
TotalBytes: d.TotalBytes,
Meta: d.Meta,
Error: d.Error,
CreatedAt: d.CreatedAt,
FinalizedAt: copyTime(d.FinalizedAt),
ActivatedAt: copyTime(d.ActivatedAt),
}
}
// keyOf renders a stored key. It deliberately has no access to the secret: the
// store never loads one in a form that could be rendered, only the hash.
func keyOf(k *store.APIKey, projectName string) api.Key {
return api.Key{
ID: k.ID,
Scope: string(k.Scope),
Project: projectName,
Name: k.Name,
CreatedAt: k.CreatedAt,
ExpiresAt: copyTime(k.ExpiresAt),
LastUsed: copyTime(k.LastUsedAt),
RevokedAt: copyTime(k.RevokedAt),
}
}
// copyTime defensively copies an optional timestamp so a response value cannot
// alias a cached store row.
func copyTime(t *time.Time) *time.Time {
if t == nil {
return nil
}
v := *t
return &v
}
// applyPatch folds a partial update into p, validating as it goes.
//
// A patch is all-or-nothing: it is applied to a copy by the caller, so a
// rejected field leaves the stored project untouched rather than half-updated.
func (s *Server) applyPatch(p *store.Project, patch *api.ProjectPatch) error {
if patch == nil {
return nil
}
if v := patch.DisplayName; v != nil {
if err := checkText("display_name", *v, maxDisplayNameLen); err != nil {
return err
}
p.DisplayName = *v
}
if v := patch.IndexFile; v != nil {
if err := checkSitePath("index_file", *v); err != nil {
return err
}
p.IndexFile = *v
}
if v := patch.NotFoundFile; v != nil {
// The empty string is how a patch clears the custom 404 document; every
// other value must name a real relative path.
if *v != "" {
if err := checkSitePath("not_found_file", *v); err != nil {
return err
}
}
p.NotFoundFile = *v
}
if v := patch.SPAFallback; v != nil {
p.SPAFallback = *v
}
if v := patch.CacheControl; v != nil {
if err := checkHeaderValue("cache_control", *v); err != nil {
return err
}
p.CacheControl = *v
}
if v := patch.RetentionCount; v != nil {
// At least one: retaining zero deployments would delete the active one.
if *v < 1 || *v > 1000 {
return api.Errorf(api.CodeBadRequest, "retention_count must be between 1 and 1000")
}
p.RetentionCount = *v
}
if v := patch.RetentionGrace; v != nil {
if *v < 0 || *v > 30*24*3600 {
return api.Errorf(api.CodeBadRequest,
"retention_grace_s must be between 0 and %d", 30*24*3600)
}
p.RetentionGraceS = *v
}
if v := patch.MaxFiles; v != nil {
if err := checkCeiling("max_files", int64(*v), int64(s.Limits.MaxManifestFiles)); err != nil {
return err
}
p.MaxFiles = *v
}
if v := patch.MaxFileBytes; v != nil {
if err := checkCeiling("max_file_bytes", *v, s.Limits.MaxFileBytes); err != nil {
return err
}
p.MaxFileBytes = *v
}
if v := patch.MaxTotalBytes; v != nil {
if *v < 1 {
return api.Errorf(api.CodeBadRequest, "max_total_bytes must be positive")
}
p.MaxTotalBytes = *v
}
return nil
}
// checkCeiling enforces that a per-project limit is positive and does not
// exceed the server-wide one. A project may lower its own ceiling but never
// raise it past what the operator configured.
func checkCeiling(field string, v, ceiling int64) error {
if v < 1 {
return api.Errorf(api.CodeBadRequest, "%s must be positive", field)
}
if ceiling > 0 && v > ceiling {
return api.Errorf(api.CodeBadRequest,
"%s must be at most the server limit of %d", field, ceiling)
}
return nil
}
+377
View File
@@ -0,0 +1,377 @@
package adminapi
import (
"encoding/json"
"errors"
"net/http"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/pathutil"
"github.com/iceBear67/simplepages/internal/store"
)
// Caps on the deployment metadata a CI job may attach. Generous enough for a
// commit sha, a branch, a run URL and an actor; small enough that the column
// cannot become a place to store things.
const (
maxMetaEntries = 32
maxMetaKeyLen = 64
maxMetaValueLen = 512
)
// createDeployment handles POST /api/v1/projects/{name}/deployments.
func (s *Server) createDeployment(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
// The body is optional: a deployment that carries no metadata is a bare POST,
// which is what `curl -X POST` and any shell-driven CI job send.
var req api.CreateDeploymentRequest
if r.ContentLength != 0 {
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
}
if err := checkMeta(req.Meta); err != nil {
return err
}
id, err := s.identity(r)
if err != nil {
return err
}
dep, err := s.Deploy.Create(r.Context(), p, id.KeyID, req.Meta)
if err != nil {
return err
}
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
w.Header().Set("Location", api.PathDeployment(p.Name, dep.PublicID))
httpx.WriteJSON(w, http.StatusCreated, deploymentOf(p, dep))
return nil
}
// setManifest handles POST .../deployments/{id}/manifest.
//
// The body is walked one entry at a time rather than unmarshalled whole: a
// 50,000-file manifest would otherwise be resident twice over, once as raw JSON
// and once as structs, for no benefit — nothing here needs to see the entries
// together.
func (s *Server) setManifest(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
r.Body = http.MaxBytesReader(w, r.Body, s.maxManifest())
var (
files []store.FileRow
paths = pathutil.NewSet(0)
unique = make(map[cas.Digest]struct{})
totalBytes int64
)
err = decodeManifest(json.NewDecoder(r.Body), func(f api.FileEntry) error {
if err := paths.Add(f.Path); err != nil {
return api.Errorf(api.CodeInvalidPath, "%s", err)
}
digest, err := cas.ParseDigest(f.Digest)
if err != nil {
return api.Errorf(api.CodeBadRequest, "%s: %s", f.Path, err)
}
if f.Size < 0 {
return api.Errorf(api.CodeBadRequest, "%s: size must not be negative", f.Path)
}
if f.Size > p.MaxFileBytes {
return api.Errorf(api.CodeLimitExceeded,
"%s is %d bytes; this project allows at most %d per file", f.Path, f.Size, p.MaxFileBytes)
}
if len(files) >= p.MaxFiles {
return api.Errorf(api.CodeLimitExceeded,
"a deployment of this project may contain at most %d files", p.MaxFiles)
}
totalBytes += f.Size
if totalBytes > p.MaxTotalBytes {
return api.Errorf(api.CodeLimitExceeded,
"a deployment of this project may total at most %d bytes", p.MaxTotalBytes)
}
unique[digest] = struct{}{}
files = append(files, store.FileRow{Path: f.Path, Digest: digest, Size: f.Size})
return nil
})
if err != nil {
return err
}
if len(files) == 0 {
return api.Errorf(api.CodeBadRequest, "a manifest must list at least one file")
}
missing, missingBytes, err := s.Deploy.SetManifest(r.Context(), dep, files)
if err != nil {
return err
}
out := api.ManifestResponse{
Missing: make([]string, 0, len(missing)),
MissingBytes: missingBytes,
Have: len(unique) - len(missing),
FileCount: len(files),
TotalBytes: totalBytes,
}
for _, d := range missing {
out.Missing = append(out.Missing, d.String())
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// finalize handles POST .../deployments/{id}/finalize.
func (s *Server) finalize(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
if err := httpx.NoBody(r); err != nil {
return err
}
dep, err = s.Deploy.Finalize(r.Context(), p, dep)
if err != nil {
return err
}
httpx.WriteJSON(w, http.StatusOK, deploymentOf(p, dep))
return nil
}
// activate handles POST .../deployments/{id}/activate.
//
// This is also the rollback endpoint: activating an older ready deployment is
// the same operation, and costs the same single pointer store.
func (s *Server) activate(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
if err := httpx.NoBody(r); err != nil {
return err
}
dep, err = s.Deploy.Activate(r.Context(), p, dep)
if err != nil {
return err
}
out := deploymentOf(p, dep)
if s.BaseURL != "" {
out.URL = api.SiteURL(s.BaseURL, p.Name)
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// listDeployments handles GET .../deployments.
func (s *Server) listDeployments(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
limit, err := intQuery(r, "limit", 100, 1, 500)
if err != nil {
return err
}
state, err := parseState(r.URL.Query().Get("state"))
if err != nil {
return err
}
deps, next, err := s.DB.ListDeployments(r.Context(), p.ID, state, limit, r.URL.Query().Get("cursor"))
if err != nil {
return err
}
out := api.DeploymentList{Deployments: make([]api.Deployment, 0, len(deps)), NextCursor: next}
for _, dep := range deps {
out.Deployments = append(out.Deployments, deploymentOf(p, dep))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// getDeployment handles GET .../deployments/{id}, with ?files=true adding the
// manifest.
//
// The manifest is opt-in because it can be fifty thousand entries: a listing
// that carried it by default would make `pages deployment list` unusable on a
// large site for information nobody asked for.
func (s *Server) getDeployment(w http.ResponseWriter, r *http.Request) error {
p, dep, err := s.deployment(r)
if err != nil {
return err
}
out := deploymentOf(p, dep)
if r.URL.Query().Get("files") == "true" {
files, err := s.DB.DeploymentFiles(r.Context(), dep.ID)
if err != nil {
return err
}
out.Files = make([]api.FileEntry, 0, len(files))
for _, f := range files {
out.Files = append(out.Files, api.FileEntry{
Path: f.Path, Digest: f.Digest.String(), Size: f.Size,
})
}
}
if dep.Active && s.BaseURL != "" {
out.URL = api.SiteURL(s.BaseURL, p.Name)
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// deleteDeployment handles DELETE .../deployments/{id}. Deleting the active one
// is a 409: the client is expected to activate something else first, so that
// the project is never left with nothing to serve by accident.
func (s *Server) deleteDeployment(w http.ResponseWriter, r *http.Request) error {
if err := httpx.NoBody(r); err != nil {
return err
}
p, dep, err := s.deployment(r)
if err != nil {
return err
}
if err := s.Deploy.Delete(r.Context(), p, dep); err != nil {
return err
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// parseState validates the ?state= filter. The empty string means no filter;
// anything else must be a state that exists, so a typo is a 400 rather than a
// silently empty list.
func parseState(raw string) (store.State, error) {
switch raw {
case "":
return "", nil
case api.StatePending, api.StateUploading, api.StateReady, api.StateFailed, api.StateDeleting:
return store.State(raw), nil
}
return "", api.Errorf(api.CodeBadRequest, "unknown deployment state %q", raw)
}
// deployment resolves both the {name} and {id} wildcards.
//
// The deployment is looked up within the project, never on its own: a
// project-scoped caller that guessed another project's deployment id gets the
// same "no such deployment" as one that guessed a nonexistent one.
func (s *Server) deployment(r *http.Request) (*store.Project, *store.Deployment, error) {
p, err := s.project(r)
if err != nil {
return nil, nil, err
}
dep, err := s.DB.DeploymentByPublicID(r.Context(), p.ID, r.PathValue("id"))
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, nil, api.Errorf(api.CodeNotFound, "no such deployment")
}
return nil, nil, err
}
httpx.LogAttr(r.Context(), "deployment", dep.PublicID)
return p, dep, nil
}
// decodeManifest streams {"files":[...]} and calls onFile for each entry.
func decodeManifest(dec *json.Decoder, onFile func(api.FileEntry) error) error {
if err := expectDelim(dec, '{', "manifest must be a JSON object"); err != nil {
return err
}
seen := false
for dec.More() {
tok, err := dec.Token()
if err != nil {
return badJSON(err)
}
key, _ := tok.(string)
if key != "files" {
// Unknown keys are skipped rather than rejected: a newer CLI may send
// a field this server predates, and the body is bounded anyway.
var skip json.RawMessage
if err := dec.Decode(&skip); err != nil {
return badJSON(err)
}
continue
}
seen = true
if err := expectDelim(dec, '[', "files must be an array"); err != nil {
return err
}
for dec.More() {
var f api.FileEntry
if err := dec.Decode(&f); err != nil {
return badJSON(err)
}
if err := onFile(f); err != nil {
return err
}
}
if _, err := dec.Token(); err != nil { // closing ]
return badJSON(err)
}
}
if _, err := dec.Token(); err != nil { // closing }
return badJSON(err)
}
if !seen {
return api.Errorf(api.CodeBadRequest, "manifest is missing the files array")
}
return nil
}
func expectDelim(dec *json.Decoder, want json.Delim, msg string) error {
tok, err := dec.Token()
if err != nil {
return badJSON(err)
}
if d, ok := tok.(json.Delim); !ok || d != want {
return api.Errorf(api.CodeBadRequest, "%s", msg)
}
return nil
}
// badJSON turns a decoder failure into a client error, keeping the body-size
// rejection distinguishable from a syntax one.
func badJSON(err error) error {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
return api.Errorf(api.CodePayloadTooLarge, "manifest exceeds %d bytes", maxErr.Limit)
}
var syn *json.SyntaxError
if errors.As(err, &syn) {
return api.Errorf(api.CodeBadRequest, "malformed JSON at byte %d", syn.Offset)
}
var typeErr *json.UnmarshalTypeError
if errors.As(err, &typeErr) {
return api.Errorf(api.CodeBadRequest, "field %q: want %s", typeErr.Field, typeErr.Type)
}
return api.Errorf(api.CodeBadRequest, "malformed manifest")
}
func checkMeta(meta map[string]string) error {
if len(meta) > maxMetaEntries {
return api.Errorf(api.CodeBadRequest, "meta may hold at most %d entries", maxMetaEntries)
}
for k, v := range meta {
if k == "" {
return api.Errorf(api.CodeBadRequest, "meta keys must not be empty")
}
if err := checkText("meta key", k, maxMetaKeyLen); err != nil {
return err
}
if err := checkText("meta value of "+k, v, maxMetaValueLen); err != nil {
return err
}
}
return nil
}
func (s *Server) maxManifest() int64 {
if s.Limits.MaxManifestBytes > 0 {
return s.Limits.MaxManifestBytes
}
return 64 << 20
}
+589
View File
@@ -0,0 +1,589 @@
package adminapi
import (
"bytes"
"encoding/json"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/deploy"
)
// entry is a manifest line for content the test holds.
func entry(path, content string) api.FileEntry {
return api.FileEntry{Path: path, Digest: cas.Sum([]byte(content)).String(), Size: int64(len(content))}
}
// putBlob uploads raw bytes, which is the one endpoint that is not JSON in and
// so cannot go through env.do.
func (e *env) putBlob(t *testing.T, token, content string) (int, []byte) {
t.Helper()
return e.putBlobAs(t, token, cas.Sum([]byte(content)).String(), content)
}
func (e *env) putBlobAs(t *testing.T, token, hexDigest, content string) (int, []byte) {
t.Helper()
req, err := http.NewRequestWithContext(t.Context(), http.MethodPut,
e.ts.URL+api.PathBlob(hexDigest), strings.NewReader(content))
if err != nil {
t.Fatal(err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := e.ts.Client().Do(req)
if err != nil {
t.Fatalf("PUT blob: %v", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
return resp.StatusCode, raw
}
// startDeployment is the fixture the manifest and finalize tests build on.
func (e *env) startDeployment(t *testing.T, token, project string) api.Deployment {
t.Helper()
status, body := e.do(t, http.MethodPost, api.PathDeployments(project), token,
api.CreateDeploymentRequest{})
var dep api.Deployment
mustJSON(t, status, http.StatusCreated, body, &dep)
return dep
}
// TestDeploymentFlow walks the three endpoints in the order a CI job does and
// asserts on what a client can actually see at each step.
func TestDeploymentFlow(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
// --- create
status, body := e.do(t, http.MethodPost, api.PathDeployments(p.Name), token,
api.CreateDeploymentRequest{Meta: map[string]string{"git_sha": "abc123", "branch": "main"}})
var dep api.Deployment
mustJSON(t, status, http.StatusCreated, body, &dep)
if !strings.HasPrefix(dep.ID, "dpl_") {
t.Errorf("id = %q, want a dpl_ prefix", dep.ID)
}
if dep.State != api.StatePending || dep.Project != "demo" {
t.Errorf("deployment = %+v", dep)
}
if dep.Meta["git_sha"] != "abc123" {
t.Errorf("meta = %v, want the submitted values back", dep.Meta)
}
// A bare POST with no body is a deployment with no metadata, and the response
// points at where the new deployment lives.
resp := e.doResp(t, http.MethodPost, api.PathDeployments(p.Name), token, nil)
if resp.StatusCode != http.StatusCreated {
t.Fatalf("bodyless create: status = %d, want 201", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); !strings.Contains(loc, api.PathDeployments(p.Name)+"/dpl_") {
t.Errorf("Location = %q, want the new deployment's path", loc)
}
// --- manifest
contents := map[string]string{
"index.html": "<h1>hello</h1>",
"assets/app.js": "console.log(1)",
"copy.html": "<h1>hello</h1>", // shares a blob with index.html
}
status, body = e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
api.ManifestRequest{Files: []api.FileEntry{
entry("index.html", contents["index.html"]),
entry("assets/app.js", contents["assets/app.js"]),
entry("copy.html", contents["copy.html"]),
}})
var mr api.ManifestResponse
mustJSON(t, status, http.StatusOK, body, &mr)
if len(mr.Missing) != 2 {
t.Fatalf("missing = %v, want the 2 distinct blobs", mr.Missing)
}
if mr.Have != 0 || mr.FileCount != 3 {
t.Errorf("have = %d, file_count = %d, want 0 and 3", mr.Have, mr.FileCount)
}
if want := int64(len(contents["index.html"])*2 + len(contents["assets/app.js"])); mr.TotalBytes != want {
t.Errorf("total_bytes = %d, want %d", mr.TotalBytes, want)
}
if mr.MissingBytes != int64(len(contents["index.html"])+len(contents["assets/app.js"])) {
t.Errorf("missing_bytes = %d counts the shared blob twice", mr.MissingBytes)
}
// --- finalize before the content arrives names what is outstanding
status, body = e.do(t, http.MethodPost, api.PathFinalize(p.Name, dep.ID), token, nil)
if status != http.StatusConflict {
t.Fatalf("premature finalize: status = %d, body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeBlobsMissing {
t.Errorf("code = %q, want %q", code, api.CodeBlobsMissing)
}
var env api.ErrorEnvelope
if err := json.Unmarshal(body, &env); err != nil {
t.Fatal(err)
}
if missing, _ := env.Error.Details["missing"].([]any); len(missing) != 2 {
t.Errorf("details.missing = %v, want the 2 digests to retry", env.Error.Details["missing"])
}
// --- upload
for _, name := range []string{"index.html", "assets/app.js"} {
status, body := e.putBlob(t, token, contents[name])
var br api.BlobResponse
mustJSON(t, status, http.StatusCreated, body, &br)
if br.Digest != cas.Sum([]byte(contents[name])).String() || br.Size != int64(len(contents[name])) {
t.Errorf("%s: response = %+v", name, br)
}
}
// A re-upload is a cheap 200 rather than a 201: this is what makes a retried
// deploy fast, so the distinction is part of the contract.
status, body = e.putBlob(t, token, contents["index.html"])
mustJSON(t, status, http.StatusOK, body, nil)
// --- a second manifest now reports what the server already has
dep2 := e.startDeployment(t, token, p.Name)
status, body = e.do(t, http.MethodPost, api.PathManifest(p.Name, dep2.ID), token,
api.ManifestRequest{Files: []api.FileEntry{
entry("index.html", contents["index.html"]),
entry("assets/app.js", contents["assets/app.js"]),
entry("new.txt", "brand new"),
}})
mr = api.ManifestResponse{}
mustJSON(t, status, http.StatusOK, body, &mr)
if len(mr.Missing) != 1 || mr.Have != 2 {
t.Errorf("missing = %v, have = %d; want only the new file to be asked for", mr.Missing, mr.Have)
}
// --- finalize
status, body = e.do(t, http.MethodPost, api.PathFinalize(p.Name, dep.ID), token, nil)
var done api.Deployment
mustJSON(t, status, http.StatusOK, body, &done)
if done.State != api.StateReady || done.FileCount != 3 {
t.Fatalf("finalized = %+v", done)
}
if done.Active {
t.Error("finalize activated the deployment")
}
if done.FinalizedAt == nil {
t.Error("finalized_at is missing from the response")
}
// The tree really is on disk, and byte-identical to what was uploaded.
dir := deploy.DeploymentDir(e.deployDir, e.projectID(t, "demo"), dep.ID)
for name, want := range contents {
got, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(name)))
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
if string(got) != want {
t.Errorf("%s = %q, want %q", name, got, want)
}
}
// Finalizing again is a no-op, not a second assembly.
status, body = e.do(t, http.MethodPost, api.PathFinalize(p.Name, dep.ID), token, nil)
mustJSON(t, status, http.StatusOK, body, nil)
}
func TestManifestRejectsBadEntries(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
good := entry("index.html", "hello")
cases := []struct {
name string
files []api.FileEntry
want api.Code
}{
{"escaping path", []api.FileEntry{entry("../etc/passwd", "x")}, api.CodeInvalidPath},
{"absolute path", []api.FileEntry{entry("/etc/passwd", "x")}, api.CodeInvalidPath},
{"empty path", []api.FileEntry{entry("", "x")}, api.CodeInvalidPath},
{"trailing slash", []api.FileEntry{entry("dir/", "x")}, api.CodeInvalidPath},
{"backslash", []api.FileEntry{entry(`a\b`, "x")}, api.CodeInvalidPath},
{"NUL byte", []api.FileEntry{entry("a\x00b", "x")}, api.CodeInvalidPath},
{"duplicate path", []api.FileEntry{good, good}, api.CodeInvalidPath},
// A case-insensitive filesystem would silently collapse these two into
// one file, so the manifest is refused rather than assembled wrongly.
{"case collision", []api.FileEntry{entry("Index.html", "a"), entry("index.html", "b")}, api.CodeInvalidPath},
{"file used as a directory", []api.FileEntry{entry("a", "x"), entry("a/b", "y")}, api.CodeInvalidPath},
{"uppercase hex digest", []api.FileEntry{{Path: "a", Digest: strings.ToUpper(good.Digest), Size: 1}}, api.CodeBadRequest},
{"short digest", []api.FileEntry{{Path: "a", Digest: "abc", Size: 1}}, api.CodeBadRequest},
{"negative size", []api.FileEntry{{Path: "a", Digest: good.Digest, Size: -1}}, api.CodeBadRequest},
{"no files", []api.FileEntry{}, api.CodeBadRequest},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
dep := e.startDeployment(t, token, p.Name)
status, body := e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
api.ManifestRequest{Files: tc.files})
if status < 400 || status >= 500 {
t.Fatalf("status = %d, want a 4xx; body: %s", status, body)
}
if code := errCode(t, body); code != tc.want {
t.Errorf("code = %q, want %q; body: %s", code, tc.want, body)
}
})
}
}
func TestManifestEnforcesProjectLimits(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
id := e.projectID(t, "demo")
token := e.mintProject(t, id, "ci")
// Lower the project's own ceilings; a project may tighten but never raise
// them, and these are the values the manifest is checked against.
two := 2
small := int64(4)
status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken,
api.ProjectPatch{MaxFiles: &two, MaxFileBytes: &small})
mustJSON(t, status, http.StatusOK, body, nil)
for _, tc := range []struct {
name string
files []api.FileEntry
}{
{"too many files", []api.FileEntry{entry("a", "1"), entry("b", "2"), entry("c", "3")}},
{"file too large", []api.FileEntry{entry("a", "much too long")}},
} {
t.Run(tc.name, func(t *testing.T) {
dep := e.startDeployment(t, token, p.Name)
status, body := e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
api.ManifestRequest{Files: tc.files})
if code := errCode(t, body); code != api.CodeLimitExceeded {
t.Errorf("status %d, code = %q, want %q; body: %s", status, code, api.CodeLimitExceeded, body)
}
})
}
t.Run("total too large", func(t *testing.T) {
big := int64(3)
status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken,
api.ProjectPatch{MaxTotalBytes: &big})
mustJSON(t, status, http.StatusOK, body, nil)
dep := e.startDeployment(t, token, p.Name)
status, body = e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
api.ManifestRequest{Files: []api.FileEntry{entry("a", "12"), entry("b", "34")}})
if code := errCode(t, body); code != api.CodeLimitExceeded {
t.Errorf("status %d, code = %q, want %q; body: %s", status, code, api.CodeLimitExceeded, body)
}
})
}
// The manifest body is bounded before it is parsed, so a 50,000-entry manifest
// cannot be turned into an unbounded allocation.
func TestManifestBodyIsBounded(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
e.server.Limits.MaxManifestBytes = 256
dep := e.startDeployment(t, token, p.Name)
files := make([]api.FileEntry, 32)
for i := range files {
files[i] = entry(string(rune('a'+i%26))+strings.Repeat("x", i), "content")
}
status, body := e.do(t, http.MethodPost, api.PathManifest(p.Name, dep.ID), token,
api.ManifestRequest{Files: files})
if code := errCode(t, body); code != api.CodePayloadTooLarge {
t.Errorf("status %d, code = %q, want %q; body: %s", status, code, api.CodePayloadTooLarge, body)
}
}
func TestManifestRejectsMalformedBodies(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
for _, tc := range []struct{ name, body string }{
{"not an object", `[]`},
{"files is not an array", `{"files":{}}`},
{"entry is not an object", `{"files":["index.html"]}`},
{"truncated", `{"files":[{"path":"a"`},
{"no files key", `{"meta":{}}`},
{"empty body", ``},
} {
t.Run(tc.name, func(t *testing.T) {
dep := e.startDeployment(t, token, p.Name)
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
e.ts.URL+api.PathManifest(p.Name, dep.ID), strings.NewReader(tc.body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
resp, err := e.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", resp.StatusCode, raw)
}
if code := errCode(t, raw); code != api.CodeBadRequest {
t.Errorf("code = %q, want %q", code, api.CodeBadRequest)
}
})
}
}
// Unknown top-level keys are skipped so a newer CLI can add a field without
// every older server rejecting its deployments.
func TestManifestIgnoresUnknownTopLevelKeys(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.startDeployment(t, token, p.Name)
body := `{"future_field":{"a":[1,2,3]},"files":[` +
`{"path":"index.html","digest":"` + cas.Sum([]byte("hi")).String() + `","size":2}` +
`],"another":"ignored"}`
req, err := http.NewRequestWithContext(t.Context(), http.MethodPost,
e.ts.URL+api.PathManifest(p.Name, dep.ID), strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+token)
resp, err := e.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
raw, _ := io.ReadAll(resp.Body)
var mr api.ManifestResponse
mustJSON(t, resp.StatusCode, http.StatusOK, raw, &mr)
if mr.FileCount != 1 {
t.Errorf("file_count = %d, want 1", mr.FileCount)
}
}
func TestCreateDeploymentRejectsOversizedMeta(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
cases := map[string]map[string]string{
"too many entries": func() map[string]string {
m := make(map[string]string, maxMetaEntries+1)
for i := range maxMetaEntries + 1 {
m[string(rune('a'+i%26))+strings.Repeat("k", i)] = "v"
}
return m
}(),
"key too long": {strings.Repeat("k", maxMetaKeyLen+1): "v"},
"value too long": {"k": strings.Repeat("v", maxMetaValueLen+1)},
"empty key": {"": "v"},
}
for name, meta := range cases {
t.Run(name, func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathDeployments(p.Name), token,
api.CreateDeploymentRequest{Meta: meta})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
}
}
// A project-scoped key reaches its own project's deployments and nothing else.
// The check compares resolved project ids, so it holds for the deployment id in
// the path as well as for the project name.
func TestDeploymentEndpointsAreProjectScoped(t *testing.T) {
e := newEnv(t)
e.createProject(t, "mine")
e.createProject(t, "theirs")
mine := e.mintProject(t, e.projectID(t, "mine"), "ci")
theirs := e.mintProject(t, e.projectID(t, "theirs"), "ci")
// Their deployment, created with their own key.
dep := e.startDeployment(t, theirs, "theirs")
t.Run("another project's endpoint is forbidden", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathDeployments("theirs"), mine, api.CreateDeploymentRequest{})
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
t.Run("another project's deployment id is not found", func(t *testing.T) {
// Named under the caller's own project, so the ownership guard passes and
// the scoping that matters is the one in the lookup itself.
status, body := e.do(t, http.MethodPost, api.PathFinalize("mine", dep.ID), mine, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeNotFound {
t.Errorf("code = %q, want %q", code, api.CodeNotFound)
}
})
t.Run("an admin reaches every project", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathDeployments("theirs"), e.adminToken,
api.CreateDeploymentRequest{})
mustJSON(t, status, http.StatusCreated, body, nil)
})
}
func TestBlobUpload(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.startDeployment(t, token, "demo")
content := "hello"
digest := cas.Sum([]byte(content)).String()
t.Run("content no manifest declared is refused", func(t *testing.T) {
status, body := e.putBlob(t, token, "nobody asked for this")
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeNotFound {
t.Errorf("code = %q, want %q", code, api.CodeNotFound)
}
})
status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
api.ManifestRequest{Files: []api.FileEntry{entry("index.html", content)}})
mustJSON(t, status, http.StatusOK, body, nil)
t.Run("a digest that is not one is a 400", func(t *testing.T) {
for _, bad := range []string{"nothex", strings.ToUpper(digest), digest + "00", "../../etc/passwd"} {
status, body := e.putBlobAs(t, token, bad, content)
if status != http.StatusBadRequest {
t.Errorf("%q: status = %d, want 400; body: %s", bad, status, body)
}
}
})
t.Run("content that does not hash to its digest is rejected", func(t *testing.T) {
// The same length the manifest declared, so this is a digest rejection
// and not the length check catching it first.
status, body := e.putBlobAs(t, token, digest, "forge")
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeDigestMismatch {
t.Errorf("code = %q, want %q", code, api.CodeDigestMismatch)
}
if has, _ := e.cas.Has(cas.Sum([]byte(content))); has {
t.Fatal("the forged content was stored under the honest digest")
}
})
t.Run("more bytes than the manifest declared are rejected", func(t *testing.T) {
dep := e.startDeployment(t, token, "demo")
long := strings.Repeat("x", 1024)
status, body := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
api.ManifestRequest{Files: []api.FileEntry{{Path: "a", Digest: cas.Sum([]byte(long)).String(), Size: 4}}})
mustJSON(t, status, http.StatusOK, body, nil)
status, body = e.putBlobAs(t, token, cas.Sum([]byte(long)).String(), long)
if status < 400 || status >= 500 {
t.Fatalf("status = %d, want a 4xx; body: %s", status, body)
}
})
t.Run("the honest content is accepted", func(t *testing.T) {
status, body := e.putBlob(t, token, content)
var br api.BlobResponse
mustJSON(t, status, http.StatusCreated, body, &br)
if br.Digest != digest || br.Size != int64(len(content)) {
t.Errorf("response = %+v", br)
}
})
}
// Every deployment route is authenticated, including the blob one that has no
// project in its path to guard against.
func TestDeploymentRoutesRequireAuthentication(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.startDeployment(t, token, "demo")
for _, path := range []string{
api.PathDeployments("demo"),
api.PathManifest("demo", dep.ID),
api.PathFinalize("demo", dep.ID),
} {
if status, body := e.do(t, http.MethodPost, path, "", nil); status != http.StatusUnauthorized {
t.Errorf("POST %s unauthenticated: status = %d, want 401; body: %s", path, status, body)
}
}
if status, body := e.putBlob(t, "", "anything"); status != http.StatusUnauthorized {
t.Errorf("PUT blob unauthenticated: status = %d, want 401; body: %s", status, body)
}
}
func TestDeploymentRoutesRejectWrongMethods(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.startDeployment(t, token, "demo")
for _, tc := range []struct{ method, path, allow string }{
{http.MethodDelete, api.PathDeployments("demo"), "GET, POST"},
{http.MethodPatch, api.PathDeployment("demo", dep.ID), "GET, DELETE"},
{http.MethodGet, api.PathManifest("demo", dep.ID), "POST"},
{http.MethodGet, api.PathFinalize("demo", dep.ID), "POST"},
{http.MethodGet, api.PathBlob(cas.Sum([]byte("x")).String()), "PUT"},
} {
resp := e.doResp(t, tc.method, tc.path, token, nil)
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("%s %s: status = %d, want 405", tc.method, tc.path, resp.StatusCode)
}
if got := resp.Header.Get("Allow"); got != tc.allow {
t.Errorf("%s %s: Allow = %q, want %q", tc.method, tc.path, got, tc.allow)
}
}
}
func TestFinalizeRejectsARequestBody(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
dep := e.startDeployment(t, token, "demo")
status, body := e.do(t, http.MethodPost, api.PathFinalize("demo", dep.ID), token,
map[string]string{"activate": "true"})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
}
// Nothing on these routes may put a credential on the wire or in the log, the
// same guarantee the project and key endpoints carry.
func TestDeploymentEndpointsNeverEchoTheToken(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
secret := token[strings.LastIndex(token, "_")+1:]
dep := e.startDeployment(t, token, "demo")
_, manifestBody := e.do(t, http.MethodPost, api.PathManifest("demo", dep.ID), token,
api.ManifestRequest{Files: []api.FileEntry{entry("index.html", "hi")}})
_, blobBody := e.putBlob(t, token, "hi")
_, finalizeBody := e.do(t, http.MethodPost, api.PathFinalize("demo", dep.ID), token, nil)
for name, body := range map[string][]byte{
"manifest": manifestBody, "blob": blobBody, "finalize": finalizeBody,
"log": e.logBuf.Bytes(),
} {
if bytes.Contains(body, []byte(secret)) || bytes.Contains(body, []byte(token)) {
t.Errorf("the %s output contains the token", name)
}
}
}
+172
View File
@@ -0,0 +1,172 @@
package adminapi
import (
"errors"
"net/http"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/auth"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
// createAdminKey handles POST /api/v1/keys.
func (s *Server) createAdminKey(w http.ResponseWriter, r *http.Request) error {
return s.createKey(w, r, nil)
}
// createProjectKey handles POST /api/v1/projects/{name}/keys.
func (s *Server) createProjectKey(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
return s.createKey(w, r, p)
}
// createKey mints a key and returns it with its token.
//
// This is the only response in the whole API that carries a credential. It is
// marked no-store, and the token is never written anywhere else: not to the
// log, not to the database (only its SHA-256 is), and not to any later
// response. Losing it means minting a new key.
func (s *Server) createKey(w http.ResponseWriter, r *http.Request, project *store.Project) error {
var req api.CreateKeyRequest
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
if err := checkText("name", req.Name, maxKeyNameLen); err != nil {
return err
}
if req.ExpiresAt != nil && !req.ExpiresAt.After(time.Now()) {
return api.Errorf(api.CodeBadRequest, "expires_at is in the past")
}
token, keyID, hash, err := auth.Mint()
if err != nil {
return err
}
k := &store.APIKey{
ID: keyID,
SecretHash: hash[:],
Scope: store.ScopeAdmin,
Name: req.Name,
ExpiresAt: copyTime(req.ExpiresAt),
}
var projectName string
if project != nil {
k.Scope = store.ScopeProject
k.ProjectID = &project.ID
projectName = project.Name
}
if err := s.DB.CreateKey(r.Context(), k); err != nil {
return err
}
httpx.LogAttr(r.Context(), "created_key_id", k.ID)
noStore(w)
httpx.WriteJSON(w, http.StatusCreated, api.CreateKeyResponse{
Key: keyOf(k, projectName),
Token: token,
})
return nil
}
// listKeys handles GET /api/v1/keys, admin only.
func (s *Server) listKeys(w http.ResponseWriter, r *http.Request) error {
ks, err := s.DB.ListKeys(r.Context(), nil)
if err != nil {
return err
}
names, err := s.projectNames(r.Context())
if err != nil {
return err
}
out := api.KeyList{Keys: make([]api.Key, 0, len(ks))}
for _, k := range ks {
var name string
if k.ProjectID != nil {
name = names[*k.ProjectID]
}
out.Keys = append(out.Keys, keyOf(k, name))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// listProjectKeys handles GET /api/v1/projects/{name}/keys.
func (s *Server) listProjectKeys(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
ks, err := s.DB.ListKeys(r.Context(), &p.ID)
if err != nil {
return err
}
out := api.KeyList{Keys: make([]api.Key, 0, len(ks))}
for _, k := range ks {
out.Keys = append(out.Keys, keyOf(k, p.Name))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// revokeKey handles DELETE /api/v1/keys/{key_id}.
//
// Admins may revoke anything; a project key may revoke keys belonging to its
// own project, which includes itself. That last case is deliberate: a CI runner
// that believes its token leaked should be able to burn it without waiting for
// an operator.
func (s *Server) revokeKey(w http.ResponseWriter, r *http.Request) error {
if err := httpx.NoBody(r); err != nil {
return err
}
ident, err := s.identity(r)
if err != nil {
return err
}
id := r.PathValue("key_id")
if !auth.ValidKeyID(id) {
// Malformed ids are rejected before the query so the endpoint cannot be
// used to probe the table with arbitrary strings.
return api.Errorf(api.CodeBadRequest, "malformed key id")
}
k, err := s.DB.KeyByID(r.Context(), id)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
// A non-admin must not be able to tell "no such key" from "someone
// else's key": that would turn this endpoint into an oracle for
// which key ids exist.
if !ident.IsAdmin() {
return errNotYourKey()
}
return api.Errorf(api.CodeNotFound, "no such key")
}
return err
}
if !ident.IsAdmin() {
if k.ProjectID == nil || !ident.Owns(*k.ProjectID) {
return errNotYourKey()
}
}
if err := s.DB.RevokeKey(r.Context(), id); err != nil {
if errors.Is(err, store.ErrNotFound) {
return api.Errorf(api.CodeNotFound, "no such key")
}
return err
}
// Revocation must take effect now, not when the auth cache entry expires.
s.Auth.V.Invalidate()
httpx.LogAttr(r.Context(), "revoked_key_id", id)
w.WriteHeader(http.StatusNoContent)
return nil
}
func errNotYourKey() error {
return api.Errorf(api.CodeForbidden, "this key does not have access to that key")
}
+361
View File
@@ -0,0 +1,361 @@
package adminapi
import (
"bytes"
"net/http"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/store"
)
// createKey posts to path and returns the minted credential.
func (e *env) createKey(t *testing.T, path, token string, req api.CreateKeyRequest) api.CreateKeyResponse {
t.Helper()
status, body := e.do(t, http.MethodPost, path, token, req)
var out api.CreateKeyResponse
mustJSON(t, status, http.StatusCreated, body, &out)
return out
}
func TestCreateAdminKeyReturnsWorkingToken(t *testing.T) {
e := newEnv(t)
out := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "ci"})
if out.Key.Scope != api.ScopeAdmin {
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeAdmin)
}
if out.Key.Project != "" {
t.Errorf("admin key reports project %q", out.Key.Project)
}
if !strings.HasPrefix(out.Token, "pgs_"+out.Key.ID+"_") {
t.Errorf("token does not carry its own key id %q", out.Key.ID)
}
// The new key authenticates and reports itself.
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
var who api.WhoAmI
mustJSON(t, status, http.StatusOK, body, &who)
if who.KeyID != out.Key.ID || who.Scope != api.ScopeAdmin || who.Name != "ci" {
t.Errorf("whoami = %+v, want key %q scope admin name ci", who, out.Key.ID)
}
}
// The token exists on the wire exactly once. If it were cacheable, a shared
// proxy in front of the management API would keep a live credential on disk.
func TestCreateKeyResponseIsNoStore(t *testing.T) {
e := newEnv(t)
resp := e.doResp(t, http.MethodPost, api.PathKeys(), e.adminToken, api.CreateKeyRequest{})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q, want it to contain no-store", cc)
}
}
// The one invariant that matters most in this package: no endpoint other than
// creation may ever put a secret on the wire. Asserted against the raw bytes,
// not a decoded struct, so a field added to api.Key later cannot leak one past
// this test.
func TestNoEndpointEverReturnsASecret(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
admin := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
secrets := []string{
admin.Token, secretOf(t, admin.Token),
proj.Token, secretOf(t, proj.Token),
e.adminToken, secretOf(t, e.adminToken),
}
for _, tc := range []struct{ method, path, token string }{
{http.MethodGet, api.PathKeys(), e.adminToken},
{http.MethodGet, api.PathProjectKeys("demo"), e.adminToken},
{http.MethodGet, api.PathProjectKeys("demo"), proj.Token},
{http.MethodGet, api.PathWhoAmI(), proj.Token},
{http.MethodGet, api.PathProject("demo"), e.adminToken},
{http.MethodGet, api.PathProjects(), e.adminToken},
} {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
status, body := e.do(t, tc.method, tc.path, tc.token, nil)
if status != http.StatusOK {
t.Fatalf("status = %d, want 200; body: %s", status, body)
}
for _, s := range secrets {
if bytes.Contains(body, []byte(s)) {
t.Fatalf("response contains a credential")
}
}
// The public half is fine to return, and the listings would be
// useless without it — check the test is actually looking at keys.
if strings.HasSuffix(tc.path, "/keys") && !bytes.Contains(body, []byte(proj.Key.ID)) &&
!bytes.Contains(body, []byte(admin.Key.ID)) {
t.Errorf("key listing mentions no key id at all: %s", body)
}
})
}
// And the log, which sees every request, never saw one either.
if logged := e.logBuf.String(); logged != "" {
for _, s := range secrets {
if strings.Contains(logged, s) {
t.Fatal("a credential reached the log")
}
}
}
}
// secretOf returns the half of a token that must never be seen again. The split
// is bounded at three because the base64url secret may itself contain "_".
func secretOf(t *testing.T, token string) string {
t.Helper()
parts := strings.SplitN(token, "_", 3)
if len(parts) != 3 {
t.Fatalf("token has %d parts, want 3", len(parts))
}
return parts[2]
}
func TestCreateProjectKey(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
out := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
if out.Key.Scope != api.ScopeProject {
t.Errorf("scope = %q, want %q", out.Key.Scope, api.ScopeProject)
}
if out.Key.Project != "demo" {
t.Errorf("project = %q, want demo", out.Key.Project)
}
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), out.Token, nil)
var who api.WhoAmI
mustJSON(t, status, http.StatusOK, body, &who)
if who.Scope != api.ScopeProject || who.Project != "demo" {
t.Errorf("whoami = %+v, want scope project on demo", who)
}
}
func TestCreateProjectKeyForUnknownProject(t *testing.T) {
e := newEnv(t)
status, body := e.do(t, http.MethodPost, api.PathProjectKeys("ghost"), e.adminToken,
api.CreateKeyRequest{})
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
}
func TestCreateKeyRejectsBadInput(t *testing.T) {
e := newEnv(t)
past := time.Now().Add(-time.Minute)
t.Run("expired on arrival", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
api.CreateKeyRequest{ExpiresAt: &past})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
t.Run("control characters in name", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
api.CreateKeyRequest{Name: "ci\x1b[2Jrunner"})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
t.Run("oversized name", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathKeys(), e.adminToken,
api.CreateKeyRequest{Name: strings.Repeat("a", maxKeyNameLen+1)})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
}
func TestExpiredKeyDoesNotAuthenticate(t *testing.T) {
e := newEnv(t)
// The API refuses to mint one already expired, so this goes in through the
// store to exercise the verifier's expiry check rather than the validator's.
future := time.Now().Add(time.Hour)
live := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{ExpiresAt: &future})
if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), live.Token, nil); status != http.StatusOK {
t.Fatalf("key expiring in an hour: status = %d, body: %s", status, body)
}
past := time.Now().Add(-time.Hour)
token := e.mintWith(t, store.ScopeAdmin, nil, "stale", &past)
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized {
t.Errorf("expired key authenticated: status = %d", status)
}
}
// Revocation must be visible on the next request, not when a cache entry ages
// out — an operator revoking a leaked token is racing an attacker who has it.
func TestRevokeTakesEffectImmediately(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
victim := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
// Warm the auth cache: without Invalidate() the revocation would not be
// observed until the entry expired.
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusOK {
t.Fatalf("key did not work before revocation")
}
status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil)
if status != http.StatusNoContent {
t.Fatalf("revoke: status = %d, want 204; body: %s", status, body)
}
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), victim.Token, nil); status != http.StatusUnauthorized {
t.Errorf("revoked key still authenticates: status = %d", status)
}
// Revoking again is a no-op rather than an error: a retrying CI step must
// not fail on the second attempt.
if status, body := e.do(t, http.MethodDelete, api.PathKey(victim.Key.ID), e.adminToken, nil); status != http.StatusNoContent {
t.Errorf("second revoke: status = %d, want 204; body: %s", status, body)
}
// The listing keeps it, with a revocation timestamp, so an operator can see
// what happened.
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
var list api.KeyList
mustJSON(t, status, http.StatusOK, body, &list)
var found bool
for _, k := range list.Keys {
if k.ID == victim.Key.ID {
found = true
if !k.Revoked() {
t.Errorf("key %s is listed without revoked_at", k.ID)
}
}
}
if !found {
t.Errorf("revoked key vanished from the listing")
}
}
// A CI runner that believes its token leaked should be able to burn it without
// waiting for an operator.
func TestProjectKeyMayRevokeItself(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
k := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
status, body := e.do(t, http.MethodDelete, api.PathKey(k.Key.ID), k.Token, nil)
if status != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", status, body)
}
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), k.Token, nil); status != http.StatusUnauthorized {
t.Errorf("key survived revoking itself: status = %d", status)
}
}
func TestRevokeAuthorisation(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
e.createProject(t, "other")
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
sibling := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci-2"})
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
adminKey := e.createKey(t, api.PathKeys(), e.adminToken, api.CreateKeyRequest{Name: "admin-2"})
t.Run("sibling in the same project", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(sibling.Key.ID), mine.Token, nil)
if status != http.StatusNoContent {
t.Fatalf("status = %d, want 204; body: %s", status, body)
}
})
t.Run("another project's key", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(foreign.Key.ID), mine.Token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
t.Run("an admin key", func(t *testing.T) {
// An admin key has no project, so a project-scoped caller can never own
// it. Escalating by revoking the operator's credentials is the attack
// this closes.
status, body := e.do(t, http.MethodDelete, api.PathKey(adminKey.Key.ID), mine.Token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), adminKey.Token, nil); status != http.StatusOK {
t.Errorf("the admin key stopped working: status = %d", status)
}
})
}
// 404 versus 403 is an oracle: a project key must not be able to walk the key
// id space and learn which ones exist.
func TestRevokeUnknownKey(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
const unknown = "abcdefghijklmnop" // well-formed, never minted
t.Run("admin", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), e.adminToken, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
})
t.Run("project", func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(unknown), proj.Token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
}
func TestRevokeMalformedKeyID(t *testing.T) {
e := newEnv(t)
for _, id := range []string{"nope", "ABCDEFGHIJKLMNOP", "abcdefghijklmno1", strings.Repeat("a", 17)} {
t.Run(id, func(t *testing.T) {
status, body := e.do(t, http.MethodDelete, api.PathKey(id), e.adminToken, nil)
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
}
}
func TestListProjectKeysIsScoped(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
e.createProject(t, "other")
mine := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
foreign := e.createKey(t, api.PathProjectKeys("other"), e.adminToken, api.CreateKeyRequest{Name: "ci"})
status, body := e.do(t, http.MethodGet, api.PathProjectKeys("demo"), mine.Token, nil)
var list api.KeyList
mustJSON(t, status, http.StatusOK, body, &list)
if len(list.Keys) != 1 || list.Keys[0].ID != mine.Key.ID {
t.Fatalf("listing = %+v, want just %s", list.Keys, mine.Key.ID)
}
if list.Keys[0].Project != "demo" {
t.Errorf("project = %q, want demo", list.Keys[0].Project)
}
if status, _ := e.do(t, http.MethodGet, api.PathProjectKeys("other"), mine.Token, nil); status != http.StatusForbidden {
t.Errorf("read another project's keys: status = %d, want 403", status)
}
// The admin listing spans projects and names each key's project.
status, body = e.do(t, http.MethodGet, api.PathKeys(), e.adminToken, nil)
var all api.KeyList
mustJSON(t, status, http.StatusOK, body, &all)
byID := make(map[string]api.Key, len(all.Keys))
for _, k := range all.Keys {
byID[k.ID] = k
}
if got := byID[foreign.Key.ID].Project; got != "other" {
t.Errorf("foreign key's project = %q, want other", got)
}
if _, ok := byID[mine.Key.ID]; !ok {
t.Errorf("admin listing is missing %s", mine.Key.ID)
}
}
+405
View File
@@ -0,0 +1,405 @@
package adminapi
import (
"database/sql"
"net/http"
"os"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/cas"
"github.com/iceBear67/simplepages/internal/deploy"
)
// exec runs a statement against the store. Tests use it to put the database
// into a state the API cannot produce — drifted reference counts, above all,
// which is the only thing fsck exists to find.
func (e *env) exec(t *testing.T, query string, args ...any) {
t.Helper()
err := e.db.Tx(t.Context(), func(tx *sql.Tx) error {
_, err := tx.ExecContext(t.Context(), query, args...)
return err
})
if err != nil {
t.Fatalf("%s: %v", query, err)
}
}
// oneFile is a single-page deployment, enough to give each version content no
// other version shares — which is what makes the collector's effect visible.
func oneFile(version string) map[string]string {
return map[string]string{"index.html": "<h1>" + version + "</h1>"}
}
// hasContent reports whether the content store still holds these bytes.
func (e *env) hasContent(t *testing.T, content string) bool {
t.Helper()
ok, err := e.cas.Has(cas.Sum([]byte(content)))
if err != nil {
t.Fatalf("cas has: %v", err)
}
return ok
}
func (e *env) activate(t *testing.T, token, project, id string) {
t.Helper()
status, body := e.do(t, http.MethodPost, api.PathActivate(project, id), token, nil)
mustJSON(t, status, http.StatusOK, body, nil)
}
func (e *env) listDeployments(t *testing.T, token, project, query string) api.DeploymentList {
t.Helper()
status, body := e.do(t, http.MethodGet, api.PathDeployments(project)+query, token, nil)
var out api.DeploymentList
mustJSON(t, status, http.StatusOK, body, &out)
return out
}
func ids(list api.DeploymentList) []string {
out := make([]string, 0, len(list.Deployments))
for _, d := range list.Deployments {
out = append(out, d.ID)
}
return out
}
func TestListDeployments(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
d1 := e.readyDeployment(t, token, "demo", oneFile("v1"))
d2 := e.readyDeployment(t, token, "demo", oneFile("v2"))
// Left pending: a listing exists partly so an operator can see the
// deployments that never finished.
d3 := e.startDeployment(t, token, "demo")
e.activate(t, token, "demo", d1.ID)
all := e.listDeployments(t, token, "demo", "")
if got, want := ids(all), []string{d3.ID, d2.ID, d1.ID}; !equalStrings(got, want) {
t.Errorf("ids = %v, want newest first %v", got, want)
}
if all.NextCursor != "" {
t.Errorf("next_cursor = %q, want empty when a page is the whole list", all.NextCursor)
}
for _, d := range all.Deployments {
if d.Project != "demo" {
t.Errorf("deployment %s: project = %q", d.ID, d.Project)
}
if (d.ID == d1.ID) != d.Active {
t.Errorf("deployment %s: active = %v, want it only for the activated one", d.ID, d.Active)
}
if len(d.Files) != 0 {
t.Errorf("deployment %s: a listing carried %d manifest entries", d.ID, len(d.Files))
}
}
ready := e.listDeployments(t, token, "demo", "?state=ready")
if got, want := ids(ready), []string{d2.ID, d1.ID}; !equalStrings(got, want) {
t.Errorf("state=ready = %v, want %v", got, want)
}
if got := ids(e.listDeployments(t, token, "demo", "?state=failed")); len(got) != 0 {
t.Errorf("state=failed = %v, want none", got)
}
// Paging: one at a time, following the cursor, visits the same list.
var paged []string
cursor := ""
for range 5 {
q := "?limit=1"
if cursor != "" {
q += "&cursor=" + cursor
}
page := e.listDeployments(t, token, "demo", q)
paged = append(paged, ids(page)...)
cursor = page.NextCursor
if cursor == "" {
break
}
}
if want := []string{d3.ID, d2.ID, d1.ID}; !equalStrings(paged, want) {
t.Errorf("paged = %v, want %v", paged, want)
}
// A typo in the filter is an error, not an empty list that reads as "this
// project has no deployments".
status, body := e.do(t, http.MethodGet, api.PathDeployments("demo")+"?state=redy", token, nil)
if status != http.StatusBadRequest {
t.Fatalf("state=redy: status = %d; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeBadRequest {
t.Errorf("code = %q, want %q", code, api.CodeBadRequest)
}
status, body = e.do(t, http.MethodGet, api.PathDeployments("demo")+"?limit=9000", token, nil)
if status != http.StatusBadRequest {
t.Errorf("limit=9000: status = %d; body: %s", status, body)
}
// Another project's key cannot read this project's deployments.
e.createProject(t, "other")
otherToken := e.mintProject(t, e.projectID(t, "other"), "other-ci")
status, body = e.do(t, http.MethodGet, api.PathDeployments("demo"), otherToken, nil)
if status != http.StatusForbidden {
t.Errorf("cross-project list: status = %d; body: %s", status, body)
}
}
func TestGetDeployment(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
contents := map[string]string{
"index.html": "<h1>hello</h1>",
"assets/app.js": "console.log(1)",
}
dep := e.readyDeployment(t, token, "demo", contents)
status, body := e.do(t, http.MethodGet, api.PathDeployment("demo", dep.ID), token, nil)
var got api.Deployment
mustJSON(t, status, http.StatusOK, body, &got)
if got.ID != dep.ID || got.State != api.StateReady || got.FileCount != 2 {
t.Errorf("deployment = %+v", got)
}
if got.Files != nil {
t.Errorf("files = %v, want them withheld unless asked for", got.Files)
}
if got.URL != "" {
t.Errorf("url = %q, want none: this deployment is not the one being served", got.URL)
}
status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", dep.ID)+"?files=true", token, nil)
got = api.Deployment{}
mustJSON(t, status, http.StatusOK, body, &got)
if len(got.Files) != len(contents) {
t.Fatalf("files = %+v, want %d entries", got.Files, len(contents))
}
for _, f := range got.Files {
content, ok := contents[f.Path]
if !ok {
t.Errorf("unexpected manifest path %q", f.Path)
continue
}
if f.Digest != cas.Sum([]byte(content)).String() || f.Size != int64(len(content)) {
t.Errorf("%s = %+v, want the digest and size of its content", f.Path, f)
}
}
if got.Files[0].Path != "assets/app.js" {
t.Errorf("files start at %q, want them ordered by path", got.Files[0].Path)
}
status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", "dpl_0000000000000000"), token, nil)
if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound {
t.Errorf("unknown id: status = %d; body: %s", status, body)
}
// A deployment is looked up within its project, so naming it under another
// project is "no such deployment" — not a way to read across the boundary,
// and not a confirmation that the id exists somewhere.
e.createProject(t, "other")
status, body = e.do(t, http.MethodGet, api.PathDeployment("other", dep.ID), e.adminToken, nil)
if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound {
t.Errorf("cross-project read: status = %d; body: %s", status, body)
}
}
func TestDeleteDeploymentEndpoint(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
pid := e.projectID(t, "demo")
token := e.mintProject(t, pid, "ci")
keep := e.readyDeployment(t, token, "demo", oneFile("v1"))
spare := e.readyDeployment(t, token, "demo", oneFile("v2"))
e.activate(t, token, "demo", keep.ID)
// The one being served is a conflict, and specifically not a 403: the
// caller is allowed to do this, just not yet.
status, body := e.do(t, http.MethodDelete, api.PathDeployment("demo", keep.ID), token, nil)
if status != http.StatusConflict {
t.Fatalf("delete active: status = %d; body: %s", status, body)
}
if code := errCode(t, body); code != api.CodeDeploymentActive {
t.Errorf("code = %q, want %q", code, api.CodeDeploymentActive)
}
dir := deploy.DeploymentDir(e.deployDir, pid, spare.ID)
if _, err := os.Stat(dir); err != nil {
t.Fatalf("stat %s: %v", dir, err)
}
status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", spare.ID), token, nil)
if status != http.StatusNoContent || len(body) != 0 {
t.Fatalf("delete: status = %d; body: %s", status, body)
}
if _, err := os.Stat(dir); !os.IsNotExist(err) {
t.Errorf("stat %s after delete: err = %v, want it gone", dir, err)
}
// Gone means gone, and deleting it again says so rather than reporting a
// success that did nothing.
status, body = e.do(t, http.MethodGet, api.PathDeployment("demo", spare.ID), token, nil)
if status != http.StatusNotFound {
t.Errorf("get deleted: status = %d; body: %s", status, body)
}
status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", spare.ID), token, nil)
if status != http.StatusNotFound || errCode(t, body) != api.CodeNotFound {
t.Errorf("second delete: status = %d; body: %s", status, body)
}
// The site is still being served by the deployment that was left alone.
if sp, ok := e.sites.Lookup("demo"); !ok || sp.Active() == nil || sp.Active().ID != keep.ID {
t.Errorf("serving %+v, want %s untouched", sp, keep.ID)
}
status, body = e.do(t, http.MethodDelete, api.PathDeployment("demo", keep.ID), token,
map[string]string{"unexpected": "body"})
if status != http.StatusBadRequest {
t.Errorf("delete with a body: status = %d; body: %s", status, body)
}
}
func TestGCEndpoint(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
pid := e.projectID(t, "demo")
token := e.mintProject(t, pid, "ci")
// Keep one spare deployment and no grace, so retention has something to do
// within the lifetime of a test. The blob grace is what protects content a
// request may be about to open; an hour of it would outlast any test, so
// this pass is told to collect immediately.
zero, one := 0, 1
status, body := e.do(t, http.MethodPatch, api.PathProject(p.Name), e.adminToken,
api.ProjectPatch{RetentionCount: &one, RetentionGrace: &zero})
mustJSON(t, status, http.StatusOK, body, nil)
e.server.Deploy.BlobGrace = -time.Minute
d1 := e.readyDeployment(t, token, "demo", oneFile("v1"))
d2 := e.readyDeployment(t, token, "demo", oneFile("v2"))
d3 := e.readyDeployment(t, token, "demo", oneFile("v3"))
e.activate(t, token, "demo", d1.ID)
// Active is excluded outright, then the newest inactive one is the single
// deployment retention keeps — so d2 is what a pass would delete.
status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, api.GCRequest{DryRun: true})
var dry api.GCStats
mustJSON(t, status, http.StatusOK, body, &dry)
if !dry.DryRun || dry.DeploymentsDeleted != 1 {
t.Errorf("dry run = %+v, want 1 deployment reported", dry)
}
status, _ = e.do(t, http.MethodGet, api.PathDeployment("demo", d2.ID), token, nil)
if status != http.StatusOK {
t.Errorf("a dry run deleted %s", d2.ID)
}
status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, api.GCRequest{})
var stats api.GCStats
mustJSON(t, status, http.StatusOK, body, &stats)
if stats.DryRun || stats.DeploymentsDeleted != 1 {
t.Fatalf("collect = %+v, want 1 deployment deleted", stats)
}
if stats.BlobsDeleted != 1 || stats.BytesFreed == 0 {
t.Errorf("collect = %+v, want the content only that deployment held", stats)
}
if e.hasContent(t, oneFile("v2")["index.html"]) {
t.Error("v2's content survived the deployment that referenced it")
}
for _, keep := range []string{"v1", "v3"} {
if !e.hasContent(t, oneFile(keep)["index.html"]) {
t.Errorf("%s's content was collected while a deployment still referenced it", keep)
}
}
status, _ = e.do(t, http.MethodGet, api.PathDeployment("demo", d2.ID), token, nil)
if status != http.StatusNotFound {
t.Errorf("get collected: status = %d, want 404", status)
}
for _, d := range []api.Deployment{d1, d3} {
if status, _ := e.do(t, http.MethodGet, api.PathDeployment("demo", d.ID), token, nil); status != http.StatusOK {
t.Errorf("%s: status = %d, want it kept", d.ID, status)
}
}
// A body is optional, and a second pass finds nothing left to do.
status, body = e.do(t, http.MethodPost, api.PathGC(), e.adminToken, nil)
stats = api.GCStats{}
mustJSON(t, status, http.StatusOK, body, &stats)
if stats.DeploymentsDeleted != 0 || stats.BlobsDeleted != 0 {
t.Errorf("second pass = %+v, want nothing", stats)
}
// Collection is server-wide, so it is admin-only however trusted the
// project key is.
status, body = e.do(t, http.MethodPost, api.PathGC(), token, api.GCRequest{})
if status != http.StatusForbidden || errCode(t, body) != api.CodeForbidden {
t.Errorf("project key: status = %d; body: %s", status, body)
}
}
func TestFsckEndpoint(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
e.readyDeployment(t, token, "demo", map[string]string{
"index.html": "<h1>hello</h1>",
"app.js": "console.log(1)",
})
// Triggers maintain the counts, so a server that has only been used through
// the API is clean by construction. Asserting that is the baseline the
// injected drift below is measured against.
status, body := e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{})
var rep api.FsckReport
mustJSON(t, status, http.StatusOK, body, &rep)
if rep.Blobs != 2 || rep.DriftCount != 0 || len(rep.Drift) != 0 || rep.Repaired != 0 {
t.Fatalf("clean report = %+v", rep)
}
digest := cas.Sum([]byte("console.log(1)"))
e.exec(t, `UPDATE blobs SET refcount = 7 WHERE digest = ?`, digest[:])
status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{})
rep = api.FsckReport{}
mustJSON(t, status, http.StatusOK, body, &rep)
if rep.DriftCount != 1 || len(rep.Drift) != 1 {
t.Fatalf("report = %+v, want the one drifted blob", rep)
}
if d := rep.Drift[0]; d.Digest != digest.String() || d.Stored != 7 || d.Actual != 1 {
t.Errorf("drift = %+v, want stored 7 and actual 1 for %s", d, digest)
}
if rep.Repaired != 0 {
t.Errorf("repaired = %d without being asked to", rep.Repaired)
}
status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, api.FsckRequest{Repair: true})
rep = api.FsckReport{}
mustJSON(t, status, http.StatusOK, body, &rep)
if rep.DriftCount != 1 || rep.Repaired != 1 {
t.Fatalf("repair = %+v", rep)
}
status, body = e.do(t, http.MethodPost, api.PathFsck(), e.adminToken, nil)
rep = api.FsckReport{}
mustJSON(t, status, http.StatusOK, body, &rep)
if rep.DriftCount != 0 {
t.Errorf("after repair = %+v, want no drift", rep)
}
status, body = e.do(t, http.MethodPost, api.PathFsck(), token, api.FsckRequest{})
if status != http.StatusForbidden || errCode(t, body) != api.CodeForbidden {
t.Errorf("project key: status = %d; body: %s", status, body)
}
}
func equalStrings(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+169
View File
@@ -0,0 +1,169 @@
package adminapi
import (
"errors"
"net/http"
"strings"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/store"
)
// createProject handles POST /api/v1/projects.
func (s *Server) createProject(w http.ResponseWriter, r *http.Request) error {
var req api.CreateProjectRequest
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
name := strings.TrimSpace(req.Name)
if err := checkProjectName(name); err != nil {
return err
}
p := store.DefaultProject(name)
s.clampDefaults(p)
if err := s.applyPatch(p, req.Patch); err != nil {
return err
}
if err := s.DB.CreateProject(r.Context(), p); err != nil {
if errors.Is(err, store.ErrExists) {
return api.Errorf(api.CodeProjectExists, "project %q already exists", name)
}
return err
}
httpx.LogAttr(r.Context(), "project", name)
if s.Hooks.ProjectChanged != nil {
s.Hooks.ProjectChanged(r.Context(), p)
}
w.Header().Set("Location", api.PathProject(name))
httpx.WriteJSON(w, http.StatusCreated, s.projectOf(p))
return nil
}
// listProjects handles GET /api/v1/projects.
func (s *Server) listProjects(w http.ResponseWriter, r *http.Request) error {
limit, err := intQuery(r, "limit", 100, 1, 500)
if err != nil {
return err
}
ps, next, err := s.DB.ListProjects(r.Context(), limit, r.URL.Query().Get("cursor"))
if err != nil {
return err
}
out := api.ProjectList{Projects: make([]api.Project, 0, len(ps)), NextCursor: next}
for _, p := range ps {
out.Projects = append(out.Projects, s.projectOf(p))
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// getProject handles GET /api/v1/projects/{name}.
func (s *Server) getProject(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
httpx.WriteJSON(w, http.StatusOK, s.projectOf(p))
return nil
}
// patchProject handles PATCH /api/v1/projects/{name}.
func (s *Server) patchProject(w http.ResponseWriter, r *http.Request) error {
p, err := s.project(r)
if err != nil {
return err
}
var patch api.ProjectPatch
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &patch); err != nil {
return err
}
// Apply to a copy so a rejected field cannot leave the caller looking at a
// partly-updated project in the error response.
updated := *p
if err := s.applyPatch(&updated, &patch); err != nil {
return err
}
if err := s.DB.UpdateProject(r.Context(), &updated); err != nil {
if errors.Is(err, store.ErrNotFound) {
return api.Errorf(api.CodeNotFound, "no such project")
}
return err
}
if s.Hooks.ProjectChanged != nil {
s.Hooks.ProjectChanged(r.Context(), &updated)
}
httpx.WriteJSON(w, http.StatusOK, s.projectOf(&updated))
return nil
}
// deleteProject handles DELETE /api/v1/projects/{name}.
//
// The cascade takes the project's keys, deployments and manifest rows with it;
// blob refcounts fall as the manifest rows go, so the content is reclaimed by
// the next GC pass rather than synchronously here.
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) error {
if err := httpx.NoBody(r); err != nil {
return err
}
p, err := s.project(r)
if err != nil {
return err
}
if err := s.DB.DeleteProject(r.Context(), p.ID); err != nil {
if errors.Is(err, store.ErrNotFound) {
return api.Errorf(api.CodeNotFound, "no such project")
}
return err
}
// The project's keys went with it. Nothing else invalidates the auth cache,
// so without this a deleted project's key would keep working for up to the
// cache TTL.
s.Auth.V.Invalidate()
if s.Hooks.ProjectDeleted != nil {
s.Hooks.ProjectDeleted(r.Context(), p)
}
w.WriteHeader(http.StatusNoContent)
return nil
}
// project loads the project named by the {name} wildcard.
//
// The ownership guard already resolved the same name; looking it up again costs
// one indexed read on a cold path and keeps the guard free of any obligation to
// hand state to the handler.
func (s *Server) project(r *http.Request) (*store.Project, error) {
name := r.PathValue("name")
if err := checkProjectName(name); err != nil {
return nil, err
}
p, err := s.DB.ProjectByName(r.Context(), name)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
return nil, api.Errorf(api.CodeNotFound, "no such project")
}
return nil, err
}
httpx.LogAttr(r.Context(), "project", p.Name)
return p, nil
}
// clampDefaults lowers the schema's per-project defaults to the server-wide
// ceilings, so a new project on a server with tightened limits does not start
// out already over them.
func (s *Server) clampDefaults(p *store.Project) {
if n := s.Limits.MaxManifestFiles; n > 0 && p.MaxFiles > n {
p.MaxFiles = n
}
if n := s.Limits.MaxFileBytes; n > 0 && p.MaxFileBytes > n {
p.MaxFileBytes = n
}
if p.MaxTotalBytes < p.MaxFileBytes {
p.MaxTotalBytes = p.MaxFileBytes
}
}
+439
View File
@@ -0,0 +1,439 @@
package adminapi
import (
"net/http"
"strconv"
"strings"
"testing"
"github.com/iceBear67/simplepages/api"
)
func TestCreateProjectAppliesDefaults(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
if p.Name != "demo" {
t.Errorf("name = %q", p.Name)
}
if p.IndexFile != "index.html" {
t.Errorf("index_file = %q, want index.html", p.IndexFile)
}
if p.CacheControl == "" {
t.Error("cache_control is empty; a project must always have one")
}
if p.RetentionCount != 10 {
t.Errorf("retention_count = %d, want 10", p.RetentionCount)
}
if p.CreatedAt.IsZero() || p.UpdatedAt.IsZero() {
t.Errorf("timestamps not set: %+v", p)
}
}
func TestCreateProjectSetsLocation(t *testing.T) {
e := newEnv(t)
resp := e.doResp(t, http.MethodPost, api.PathProjects(), e.adminToken,
api.CreateProjectRequest{Name: "demo"})
if resp.StatusCode != http.StatusCreated {
t.Fatalf("status = %d, want 201", resp.StatusCode)
}
if loc := resp.Header.Get("Location"); loc != api.PathProject("demo") {
t.Errorf("Location = %q, want %q", loc, api.PathProject("demo"))
}
}
func TestCreateProjectRejectsBadNames(t *testing.T) {
e := newEnv(t)
// The name becomes a "~name" entry in $WEBROOT, so anything that could carry
// a separator or a traversal has to be impossible by construction.
names := []string{
"", " ", "Demo", "demo/evil", "demo\\evil", "..", ".hidden", "-lead",
"_lead", "demo\x00", "demo project", "デモ", "demo\x1b", "~demo",
strings.Repeat("a", 64),
}
for i, name := range names {
t.Run(strconv.Itoa(i), func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
api.CreateProjectRequest{Name: name})
if status != http.StatusBadRequest {
t.Fatalf("%q: status = %d, want 400; body: %s", name, status, body)
}
if got := errCode(t, body); got != api.CodeInvalidProjectName {
t.Errorf("%q: code = %q, want %q", name, got, api.CodeInvalidProjectName)
}
})
}
// The boundary the pattern actually allows.
e.createProject(t, strings.Repeat("a", 63))
}
// A name arriving from a shell pipeline often has a trailing newline. Trimming
// it is a convenience, and it is the only normalisation the name gets — the
// pattern decides everything else.
func TestCreateProjectTrimsSurroundingSpace(t *testing.T) {
e := newEnv(t)
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
api.CreateProjectRequest{Name: " demo\n"})
var p api.Project
mustJSON(t, status, http.StatusCreated, body, &p)
if p.Name != "demo" {
t.Fatalf("name = %q, want demo", p.Name)
}
}
func TestCreateProjectDuplicate(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
api.CreateProjectRequest{Name: "demo"})
if status != http.StatusConflict {
t.Fatalf("status = %d, want 409; body: %s", status, body)
}
if got := errCode(t, body); got != api.CodeProjectExists {
t.Errorf("code = %q, want %q", got, api.CodeProjectExists)
}
}
func TestCreateProjectWithConfig(t *testing.T) {
e := newEnv(t)
spa := true
index := "app.html"
notFound := "404.html"
retention := 3
status, body := e.do(t, http.MethodPost, api.PathProjects(), e.adminToken,
api.CreateProjectRequest{
Name: "demo",
Patch: &api.ProjectPatch{
IndexFile: &index,
NotFoundFile: &notFound,
SPAFallback: &spa,
RetentionCount: &retention,
},
})
var p api.Project
mustJSON(t, status, http.StatusCreated, body, &p)
if p.IndexFile != index || p.NotFoundFile != notFound || !p.SPAFallback || p.RetentionCount != 3 {
t.Errorf("config not applied: %+v", p)
}
}
func TestCreateProjectRejectsUnknownFields(t *testing.T) {
e := newEnv(t)
req, _ := http.NewRequestWithContext(t.Context(), http.MethodPost,
e.ts.URL+api.PathProjects(), strings.NewReader(`{"name":"demo","retention_count":5}`))
req.Header.Set("Authorization", "Bearer "+e.adminToken)
resp, err := e.ts.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
// retention_count belongs under "config"; silently ignoring a misplaced key
// would let a deploy script think it had configured something it had not.
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", resp.StatusCode)
}
}
// cache_control is written verbatim into a response header on every request the
// project serves. A CR or LF there is response splitting.
func TestPatchRejectsHeaderInjection(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
for _, bad := range []string{
"public\r\nX-Evil: 1",
"public\nX-Evil: 1",
"public\rX-Evil: 1",
"public\x00",
"public, max-age=0\x7f",
strings.Repeat("a", maxCacheControlLen+1),
} {
t.Run(strings.NewReplacer("\r", "CR", "\n", "LF", "\x00", "NUL").Replace(bad), func(t *testing.T) {
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{CacheControl: &bad})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
})
}
// And the project is untouched.
status, body := e.do(t, http.MethodGet, api.PathProject("demo"), e.adminToken, nil)
var p api.Project
mustJSON(t, status, http.StatusOK, body, &p)
if strings.ContainsAny(p.CacheControl, "\r\n") {
t.Fatalf("cache_control was stored with a newline: %q", p.CacheControl)
}
}
// Display names are printed to operator terminals and written into logs.
func TestPatchRejectsControlCharacters(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
bad := "demo\x1b[31m site"
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{DisplayName: &bad})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
}
func TestPatchRejectsBadSitePaths(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
for i, bad := range []string{
"", "/etc/passwd", "../secret", "a/../../b", ".", "a//b", "a/", "a\\b", "a\x00b",
strings.Repeat("a", maxSitePathLen+1),
} {
t.Run(strconv.Itoa(i), func(t *testing.T) {
v := bad
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{IndexFile: &v})
if status != http.StatusBadRequest {
t.Fatalf("%q: status = %d, want 400; body: %s", bad, status, body)
}
if got := errCode(t, body); got != api.CodeInvalidPath {
t.Errorf("%q: code = %q, want %q", bad, got, api.CodeInvalidPath)
}
})
}
}
// The empty string is the only way a client can clear a custom 404 document,
// which is why not_found_file gets a different rule from index_file.
func TestPatchClearsNotFoundFile(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
set := "404.html"
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{NotFoundFile: &set})
var p api.Project
mustJSON(t, status, http.StatusOK, body, &p)
if p.NotFoundFile != set {
t.Fatalf("not_found_file = %q, want %q", p.NotFoundFile, set)
}
// A fresh destination: not_found_file is omitempty, so decoding a cleared
// project over the previous value would silently keep it.
clear := ""
status, body = e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{NotFoundFile: &clear})
var cleared api.Project
mustJSON(t, status, http.StatusOK, body, &cleared)
if cleared.NotFoundFile != "" {
t.Fatalf("not_found_file = %q, want it cleared", cleared.NotFoundFile)
}
// It must be SQL NULL, not the empty string, so the schema's "NULL means no
// custom document" contract holds.
row, err := e.db.ProjectByName(t.Context(), "demo")
if err != nil {
t.Fatal(err)
}
if row.NotFoundFile != "" {
t.Errorf("store kept %q", row.NotFoundFile)
}
}
// An omitted field means "leave alone"; only a present one changes anything.
func TestPatchLeavesOmittedFieldsAlone(t *testing.T) {
e := newEnv(t)
spa := true
index := "app.html"
e.do(t, http.MethodPost, api.PathProjects(), e.adminToken, api.CreateProjectRequest{
Name: "demo",
Patch: &api.ProjectPatch{IndexFile: &index, SPAFallback: &spa},
})
display := "Demo Site"
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{DisplayName: &display})
var p api.Project
mustJSON(t, status, http.StatusOK, body, &p)
if p.IndexFile != index || !p.SPAFallback {
t.Errorf("patch clobbered untouched fields: %+v", p)
}
if p.DisplayName != display {
t.Errorf("display_name = %q, want %q", p.DisplayName, display)
}
}
func TestPatchEnforcesServerCeilings(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
ceiling := e.server.Limits.MaxFileBytes
over := ceiling + 1
status, body := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{MaxFileBytes: &over})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400; body: %s", status, body)
}
// Lowering below the server limit is always allowed.
under := int64(1024)
status, body = e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{MaxFileBytes: &under})
var p api.Project
mustJSON(t, status, http.StatusOK, body, &p)
if p.MaxFileBytes != under {
t.Errorf("max_file_bytes = %d, want %d", p.MaxFileBytes, under)
}
zero := 0
status, _ = e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{RetentionCount: &zero})
if status != http.StatusBadRequest {
t.Errorf("retention_count = 0 accepted (status %d); it would delete the active deployment", status)
}
}
// A patch is applied to a copy, so a field rejected halfway through must leave
// nothing behind.
func TestRejectedPatchChangesNothing(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
display := "Fine"
bad := "x\r\ny"
status, _ := e.do(t, http.MethodPatch, api.PathProject("demo"), e.adminToken,
api.ProjectPatch{DisplayName: &display, CacheControl: &bad})
if status != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", status)
}
row, err := e.db.ProjectByName(t.Context(), "demo")
if err != nil {
t.Fatal(err)
}
if row.DisplayName != "" {
t.Errorf("display_name = %q; the valid half of a rejected patch was applied", row.DisplayName)
}
}
func TestListProjectsPaging(t *testing.T) {
e := newEnv(t)
for _, n := range []string{"a", "b", "c", "d", "e"} {
e.createProject(t, n)
}
var seen []string
cursor := ""
for range 10 {
path := api.PathProjects() + "?limit=2"
if cursor != "" {
path += "&cursor=" + cursor
}
status, body := e.do(t, http.MethodGet, path, e.adminToken, nil)
var list api.ProjectList
mustJSON(t, status, http.StatusOK, body, &list)
for _, p := range list.Projects {
seen = append(seen, p.Name)
}
if list.NextCursor == "" {
break
}
cursor = list.NextCursor
}
if got := strings.Join(seen, ","); got != "a,b,c,d,e" {
t.Errorf("paged through %q, want a,b,c,d,e", got)
}
}
func TestListProjectsRejectsBadLimit(t *testing.T) {
e := newEnv(t)
for _, q := range []string{"?limit=0", "?limit=501", "?limit=abc", "?limit=-1"} {
status, body := e.do(t, http.MethodGet, api.PathProjects()+q, e.adminToken, nil)
if status != http.StatusBadRequest {
t.Errorf("%s: status = %d, want 400; body: %s", q, status, body)
}
}
}
func TestGetUnknownProject(t *testing.T) {
e := newEnv(t)
status, body := e.do(t, http.MethodGet, api.PathProject("nope"), e.adminToken, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
}
func TestDeleteProjectRevokesItsKeysImmediately(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
// Warm the auth cache so the test proves invalidation, not a cold lookup.
if status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusOK {
t.Fatalf("whoami before delete: %d %s", status, body)
}
status, body := e.do(t, http.MethodDelete, api.PathProject("demo"), e.adminToken, nil)
if status != http.StatusNoContent {
t.Fatalf("delete: status = %d, want 204; body: %s", status, body)
}
if len(body) != 0 {
t.Errorf("204 carried a body: %s", body)
}
// The key cascaded away with the project. Without the cache invalidation in
// the delete handler it would keep authenticating for the cache TTL.
if status, _ := e.do(t, http.MethodGet, api.PathWhoAmI(), token, nil); status != http.StatusUnauthorized {
t.Errorf("deleted project's key still works: status = %d", status)
}
}
func TestProjectKeyCannotManageProjects(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
e.createProject(t, "other")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
t.Run("create", func(t *testing.T) {
status, body := e.do(t, http.MethodPost, api.PathProjects(), token,
api.CreateProjectRequest{Name: "sneaky"})
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
})
t.Run("list", func(t *testing.T) {
if status, _ := e.do(t, http.MethodGet, api.PathProjects(), token, nil); status != http.StatusForbidden {
t.Errorf("status = %d, want 403", status)
}
})
t.Run("patch own", func(t *testing.T) {
// Reconfiguring a project is an admin operation even for its own key:
// a deploy credential should not be able to raise its own limits.
display := "x"
status, _ := e.do(t, http.MethodPatch, api.PathProject("demo"), token,
api.ProjectPatch{DisplayName: &display})
if status != http.StatusForbidden {
t.Errorf("status = %d, want 403", status)
}
})
t.Run("delete own", func(t *testing.T) {
if status, _ := e.do(t, http.MethodDelete, api.PathProject("demo"), token, nil); status != http.StatusForbidden {
t.Errorf("status = %d, want 403", status)
}
})
t.Run("read own", func(t *testing.T) {
status, body := e.do(t, http.MethodGet, api.PathProject("demo"), token, nil)
var p api.Project
mustJSON(t, status, http.StatusOK, body, &p)
if p.Name != "demo" {
t.Errorf("name = %q", p.Name)
}
})
t.Run("read other", func(t *testing.T) {
if status, _ := e.do(t, http.MethodGet, api.PathProject("other"), token, nil); status != http.StatusForbidden {
t.Errorf("status = %d, want 403", status)
}
})
// A project key must not be able to use 404-vs-403 to enumerate which
// project names exist.
t.Run("read nonexistent", func(t *testing.T) {
status, _ := e.do(t, http.MethodGet, api.PathProject("ghost"), token, nil)
if status != http.StatusForbidden {
t.Errorf("status = %d, want 403; an unknown name must look like someone else's", status)
}
})
}
+141
View File
@@ -0,0 +1,141 @@
package adminapi
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/auth"
)
// The client builds URLs with api.Path* and the server registers wildcard
// patterns. Nothing in the type system connects the two, so assert that every
// path the client can build actually lands on the route it is meant to.
func TestPathBuildersMatchRoutes(t *testing.T) {
mux := http.NewServeMux()
(&Server{Auth: &auth.Middleware{}}).Register(mux)
cases := []struct {
method, path, want string
}{
{http.MethodGet, api.PathWhoAmI(), "GET " + patWhoAmI},
{http.MethodGet, api.PathSystemInfo(), "GET " + patSystemInfo},
{http.MethodPost, api.PathProjects(), "POST " + patProjects},
{http.MethodGet, api.PathProjects(), "GET " + patProjects},
{http.MethodGet, api.PathProject("demo"), "GET " + patProject},
{http.MethodPatch, api.PathProject("demo"), "PATCH " + patProject},
{http.MethodDelete, api.PathProject("demo"), "DELETE " + patProject},
{http.MethodPost, api.PathProjectKeys("demo"), "POST " + patProjectKeys},
{http.MethodGet, api.PathProjectKeys("demo"), "GET " + patProjectKeys},
{http.MethodPost, api.PathKeys(), "POST " + patKeys},
{http.MethodGet, api.PathKeys(), "GET " + patKeys},
{http.MethodDelete, api.PathKey("abcdefghijklmnop"), "DELETE " + patKey},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
r := httptest.NewRequest(tc.method, tc.path, nil)
_, pattern := mux.Handler(r)
if pattern != tc.want {
t.Errorf("routed to %q, want %q", pattern, tc.want)
}
})
}
}
// A project name that needs escaping must not be able to reach a different
// route by smuggling a slash through the path builder.
func TestPathEscapingCannotCrossRoutes(t *testing.T) {
mux := http.NewServeMux()
(&Server{Auth: &auth.Middleware{}}).Register(mux)
built := api.PathProject("demo/keys")
if strings.Contains(built, "demo/keys") {
t.Fatalf("PathProject did not escape the slash: %q", built)
}
r := httptest.NewRequest(http.MethodGet, built, nil)
_, pattern := mux.Handler(r)
if pattern != "GET "+patProject {
t.Errorf("routed to %q, want %q", pattern, "GET "+patProject)
}
}
func TestEveryRouteRequiresAuthentication(t *testing.T) {
e := newEnv(t)
p := e.createProject(t, "demo")
cases := []struct{ method, path string }{
{http.MethodGet, api.PathWhoAmI()},
{http.MethodGet, api.PathSystemInfo()},
{http.MethodPost, api.PathProjects()},
{http.MethodGet, api.PathProjects()},
{http.MethodGet, api.PathProject(p.Name)},
{http.MethodPatch, api.PathProject(p.Name)},
{http.MethodDelete, api.PathProject(p.Name)},
{http.MethodPost, api.PathProjectKeys(p.Name)},
{http.MethodGet, api.PathProjectKeys(p.Name)},
{http.MethodPost, api.PathKeys()},
{http.MethodGet, api.PathKeys()},
{http.MethodDelete, api.PathKey("abcdefghijklmnop")},
{http.MethodGet, api.Version + "/does-not-exist"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
status, body := e.do(t, tc.method, tc.path, "", nil)
if status != http.StatusUnauthorized {
t.Fatalf("status = %d, want 401; body: %s", status, body)
}
if got := errCode(t, body); got != api.CodeUnauthorized {
t.Errorf("code = %q, want %q", got, api.CodeUnauthorized)
}
})
}
}
// An unknown endpoint must answer with the same envelope as everything else, so
// a client has exactly one error shape to parse.
func TestUnknownEndpointReturnsEnvelope(t *testing.T) {
e := newEnv(t)
status, body := e.do(t, http.MethodGet, api.Version+"/nope", e.adminToken, nil)
if status != http.StatusNotFound {
t.Fatalf("status = %d, want 404; body: %s", status, body)
}
if got := errCode(t, body); got != api.CodeNotFound {
t.Errorf("code = %q, want %q", got, api.CodeNotFound)
}
// The path is not echoed back: there is no reason to reflect caller-supplied
// bytes into a response body.
if bytes := string(body); strings.Contains(bytes, "nope") {
t.Errorf("response echoes the request path: %s", bytes)
}
}
// The catch-all matches every path under the API prefix, so without the
// method-agnostic fallbacks a wrong verb on a real endpoint would report 404
// "no such endpoint" — which sends a client looking for a typo that is not
// there.
func TestWrongMethodIsRejectedWithAllow(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
cases := []struct {
method, path, wantAllow string
}{
{http.MethodPut, api.PathProjects(), "POST"},
{http.MethodPost, api.PathProject("demo"), "PATCH"},
{http.MethodPut, api.PathKey("abcdefghijklmnop"), "DELETE"},
{http.MethodDelete, api.PathWhoAmI(), "GET"},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
resp := e.doResp(t, tc.method, tc.path, e.adminToken, nil)
if resp.StatusCode != http.StatusMethodNotAllowed {
t.Fatalf("status = %d, want 405", resp.StatusCode)
}
if allow := resp.Header.Get("Allow"); !strings.Contains(allow, tc.wantAllow) {
t.Errorf("Allow = %q, want it to mention %s", allow, tc.wantAllow)
}
})
}
}
+265
View File
@@ -0,0 +1,265 @@
// Package adminapi implements the management API: projects, API keys and the
// system endpoints. It owns the translation between storage and the wire
// format, so the store never constructs api.Error values and the api package
// never learns that SQLite exists.
//
// Every route in here is authenticated. Authorisation is expressed as
// per-route middleware rather than as checks inside the handlers, so a new
// endpoint that forgets its guard is visible in the route table instead of
// being hidden three screens down in a handler body.
package adminapi
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/auth"
"github.com/iceBear67/simplepages/internal/config"
"github.com/iceBear67/simplepages/internal/deploy"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/site"
"github.com/iceBear67/simplepages/internal/store"
)
// Route patterns. They are spelled out here rather than reusing the builders in
// api/paths.go because those escape their arguments to produce a concrete URL,
// which is the opposite of what a wildcard pattern needs. TestPathsMatchRoutes
// asserts the two stay in agreement.
const (
patProjects = api.Version + "/projects"
patProject = api.Version + "/projects/{name}"
patProjectKeys = api.Version + "/projects/{name}/keys"
patKeys = api.Version + "/keys"
patKey = api.Version + "/keys/{key_id}"
patWhoAmI = api.Version + "/whoami"
patSystemInfo = api.Version + "/system/info"
patDeployments = patProject + "/deployments"
patDeployment = patDeployments + "/{id}"
patManifest = patDeployment + "/manifest"
patFinalize = patDeployment + "/finalize"
patActivate = patDeployment + "/activate"
patBlob = api.Version + "/blobs/{digest}"
patGC = api.Version + "/gc"
patFsck = api.Version + "/fsck"
patCatchAll = api.Version + "/"
)
// Hooks let later milestones react to changes without adminapi taking a
// dependency on the site registry, the webroot or the deployment service.
// Every hook is optional and runs after the database transaction has committed,
// so a hook failure can never leave the store and the caller disagreeing about
// whether the change happened.
type Hooks struct {
// ProjectChanged fires after a project is created or reconfigured.
ProjectChanged func(ctx context.Context, p *store.Project)
// ProjectDeleted fires after a project and its cascade are gone.
ProjectDeleted func(ctx context.Context, p *store.Project)
}
// Server holds the collaborators the management handlers need.
type Server struct {
DB *store.DB
Auth *auth.Middleware
Deploy *deploy.Service
Log *slog.Logger
// Limits are the server-wide ceilings a per-project setting may not exceed.
Limits config.Limits
// Resolver maps a project name to its row id for the ownership guard. When
// nil the database is consulted directly; the server substitutes the site
// registry so the hot deployment endpoints do not pay a query for it.
Resolver auth.ProjectResolver
// Sites is the serving registry, consulted to report what a project is
// currently serving without a query per project. Nil in tests that do not
// wire up a serving layer, in which case active_deployment is omitted.
Sites *site.Registry
// BaseURL is the public origin serving site content, without a trailing
// slash. Empty until the operator configures one, in which case responses
// simply omit the url field.
BaseURL string
// LinkMode reports how deployment trees are assembled. Filled in from the
// CAS in M2.
LinkMode string
Started time.Time
Hooks Hooks
}
// Register mounts the management routes on mux.
//
// It takes a mux rather than returning a handler so the caller can put the
// health probes on the same listener without them inheriting authentication.
func (s *Server) Register(mux *http.ServeMux) {
admin := auth.RequireAdmin(s.Log)
resolver := s.Resolver
if resolver == nil {
resolver = auth.ResolverFunc(s.resolveProject)
}
// owner admits admins and the project's own key. Note it reads the {name}
// wildcard, so it is only valid on patterns that declare one.
owner := auth.RequireProject("name", resolver, s.Log)
s.route(mux, "GET "+patWhoAmI, s.whoami)
s.route(mux, "GET "+patSystemInfo, s.systemInfo, admin)
s.route(mux, "POST "+patProjects, s.createProject, admin)
s.route(mux, "GET "+patProjects, s.listProjects, admin)
s.route(mux, "GET "+patProject, s.getProject, owner)
s.route(mux, "PATCH "+patProject, s.patchProject, admin)
s.route(mux, "DELETE "+patProject, s.deleteProject, admin)
s.route(mux, "POST "+patKeys, s.createAdminKey, admin)
s.route(mux, "GET "+patKeys, s.listKeys, admin)
s.route(mux, "DELETE "+patKey, s.revokeKey)
s.route(mux, "POST "+patProjectKeys, s.createProjectKey, admin)
s.route(mux, "GET "+patProjectKeys, s.listProjectKeys, owner)
s.route(mux, "POST "+patDeployments, s.createDeployment, owner)
s.route(mux, "GET "+patDeployments, s.listDeployments, owner)
s.route(mux, "GET "+patDeployment, s.getDeployment, owner)
s.route(mux, "DELETE "+patDeployment, s.deleteDeployment, owner)
s.route(mux, "POST "+patManifest, s.setManifest, owner)
s.route(mux, "POST "+patFinalize, s.finalize, owner)
s.route(mux, "POST "+patActivate, s.activate, owner)
s.route(mux, "POST "+patGC, s.gc, admin)
s.route(mux, "POST "+patFsck, s.fsck, admin)
// Blob uploads carry no project in the path and so have no owner to check
// against. See putBlob for why authentication alone is the right guard.
s.route(mux, "PUT "+patBlob, s.putBlob)
// A known path reached with an unregistered method must answer 405 and say
// what it does accept. ServeMux would do that by itself, but only when no
// pattern matches at all — and the catch-all below matches everything under
// the prefix, which would turn every method mismatch into a 404. These
// method-agnostic patterns are less specific than the ones above, so they
// only see requests the real routes rejected.
s.methods(mux, patProjects, http.MethodGet, http.MethodPost)
s.methods(mux, patProject, http.MethodGet, http.MethodPatch, http.MethodDelete)
s.methods(mux, patProjectKeys, http.MethodGet, http.MethodPost)
s.methods(mux, patKeys, http.MethodGet, http.MethodPost)
s.methods(mux, patKey, http.MethodDelete)
s.methods(mux, patWhoAmI, http.MethodGet)
s.methods(mux, patSystemInfo, http.MethodGet)
s.methods(mux, patDeployments, http.MethodGet, http.MethodPost)
s.methods(mux, patDeployment, http.MethodGet, http.MethodDelete)
s.methods(mux, patManifest, http.MethodPost)
s.methods(mux, patFinalize, http.MethodPost)
s.methods(mux, patActivate, http.MethodPost)
s.methods(mux, patBlob, http.MethodPut)
s.methods(mux, patGC, http.MethodPost)
s.methods(mux, patFsck, http.MethodPost)
// Unknown paths under the API prefix answer with the same envelope as every
// other failure instead of net/http's plain-text 404, so a client has one
// shape of error to parse. It sits behind authentication too: an
// unauthenticated caller learns nothing about which endpoints exist.
s.route(mux, patCatchAll, func(w http.ResponseWriter, r *http.Request) error {
return api.Errorf(api.CodeNotFound, "no such endpoint")
})
}
// methods registers the 405 fallback for a path that exists under other verbs.
func (s *Server) methods(mux *http.ServeMux, pattern string, allow ...string) {
list := strings.Join(allow, ", ")
s.route(mux, pattern, func(w http.ResponseWriter, r *http.Request) error {
w.Header().Set("Allow", list)
return api.Errorf(api.CodeMethodNotAllowed, "method not allowed; this path accepts %s", list)
})
}
// handlerFunc is an http.HandlerFunc that may fail. Returning the error instead
// of writing it means a handler cannot accidentally answer twice, and the
// envelope is rendered in exactly one place.
type handlerFunc func(w http.ResponseWriter, r *http.Request) error
func (s *Server) wrap(h handlerFunc) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if err := h(w, r); err != nil {
httpx.WriteError(w, r, s.Log, err)
}
})
}
// route registers pattern behind authentication plus the given guards, which
// run outermost first.
func (s *Server) route(mux *http.ServeMux, pattern string, h handlerFunc, guards ...func(http.Handler) http.Handler) {
var wrapped http.Handler = s.wrap(h)
for i := len(guards) - 1; i >= 0; i-- {
wrapped = guards[i](wrapped)
}
mux.Handle(pattern, s.Auth.Authenticate(wrapped))
}
// resolveProject is the fallback ProjectResolver used until the site registry
// exists.
func (s *Server) resolveProject(ctx context.Context, name string) (int64, error) {
p, err := s.DB.ProjectByName(ctx, name)
if err != nil {
return 0, err
}
return p.ID, nil
}
// identity returns the caller. Authenticate guarantees one is present, so its
// absence is a routing bug rather than a client error.
func (s *Server) identity(r *http.Request) (*auth.Identity, error) {
id, ok := auth.IdentityFrom(r.Context())
if !ok {
return nil, api.Errorf(api.CodeInternal, "handler reached without authentication")
}
return id, nil
}
func (s *Server) maxJSON() int64 {
if s.Limits.MaxJSONBytes > 0 {
return s.Limits.MaxJSONBytes
}
return 1 << 20
}
// projectNames maps row ids to names for the key listings, which store an id
// but report a name. One query beats one lookup per key.
func (s *Server) projectNames(ctx context.Context) (map[int64]string, error) {
ps, err := s.DB.AllProjects(ctx)
if err != nil {
return nil, err
}
m := make(map[int64]string, len(ps))
for _, p := range ps {
m[p.ID] = p.Name
}
return m, nil
}
// intQuery reads a bounded integer query parameter.
func intQuery(r *http.Request, name string, def, min, max int) (int, error) {
raw := r.URL.Query().Get(name)
if raw == "" {
return def, nil
}
v, err := strconv.Atoi(raw)
if err != nil {
return 0, api.Errorf(api.CodeBadRequest, "%s must be an integer", name)
}
if v < min || v > max {
return 0, api.Errorf(api.CodeBadRequest, "%s must be between %d and %d", name, min, max)
}
return v, nil
}
// noStore marks a response that must not be written to any cache. Used for the
// one response in the API that carries a credential.
func noStore(w http.ResponseWriter) {
w.Header().Set("Cache-Control", "no-store")
}
+125
View File
@@ -0,0 +1,125 @@
package adminapi
import (
"net/http"
"time"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/httpx"
"github.com/iceBear67/simplepages/internal/version"
)
// whoami handles GET /api/v1/whoami. Any valid key may call it; a CI job uses
// it to check that the token it was handed is the one it expected.
func (s *Server) whoami(w http.ResponseWriter, r *http.Request) error {
ident, err := s.identity(r)
if err != nil {
return err
}
out := api.WhoAmI{
KeyID: ident.KeyID,
Scope: string(ident.Scope),
Name: ident.Name,
ExpiresAt: copyTime(ident.ExpiresAt),
}
if ident.ProjectID != nil {
// The identity carries the row id, not the name; the name is what a
// human wants to see. A missing row here would mean the project was
// deleted between authentication and now, in which case reporting an
// empty name is more honest than failing the request.
if p, err := s.DB.ProjectByID(r.Context(), *ident.ProjectID); err == nil {
out.Project = p.Name
}
}
noStore(w)
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
// systemInfo handles GET /api/v1/system/info, admin only.
func (s *Server) systemInfo(w http.ResponseWriter, r *http.Request) error {
counts, err := s.DB.Counts(r.Context())
if err != nil {
return err
}
schema, err := s.DB.SchemaVersion(r.Context())
if err != nil {
return err
}
linkMode := s.LinkMode
if linkMode == "" {
linkMode = "unknown"
}
uptime := int64(0)
if !s.Started.IsZero() {
uptime = int64(time.Since(s.Started).Seconds())
}
httpx.WriteJSON(w, http.StatusOK, api.SystemInfo{
Version: version.Short(),
UptimeS: uptime,
Projects: counts.Projects,
Deployments: counts.Deployments,
Blobs: counts.Blobs,
CASBytes: counts.CASBytes,
LinkMode: linkMode,
SchemaVer: schema,
})
return nil
}
// gc handles POST /api/v1/gc, admin only.
//
// A collection pass also runs on a timer; this exists so an operator who needs
// the disk back does not have to wait for it, and so that a dry run can answer
// "what would you delete" before anything is deleted.
func (s *Server) gc(w http.ResponseWriter, r *http.Request) error {
var req api.GCRequest
if r.ContentLength != 0 {
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
}
stats, err := s.Deploy.Collect(r.Context(), req.DryRun)
if err != nil {
return err
}
httpx.WriteJSON(w, http.StatusOK, stats)
return nil
}
// fsck handles POST /api/v1/fsck, admin only.
//
// Refcounts are maintained by triggers, so on a healthy server this always
// reports zero drift. It exists for the cases outside normal operation — a
// database restored from a backup, a schema touched by hand — because the
// collector trusts those counters, and a count that reads low is the one way
// this design can lose data.
func (s *Server) fsck(w http.ResponseWriter, r *http.Request) error {
var req api.FsckRequest
if r.ContentLength != 0 {
if err := httpx.DecodeJSON(w, r, s.maxJSON(), &req); err != nil {
return err
}
}
rep, err := s.DB.Fsck(r.Context(), req.Repair)
if err != nil {
return err
}
out := api.FsckReport{
Blobs: rep.Blobs,
DriftCount: rep.DriftCount,
Repaired: rep.Repaired,
Drift: make([]api.BlobDrift, 0, len(rep.Drift)),
}
for _, d := range rep.Drift {
out.Drift = append(out.Drift, api.BlobDrift{
Digest: d.Digest.String(), Stored: d.Stored, Actual: d.Actual,
})
}
if rep.DriftCount > 0 {
s.Log.WarnContext(r.Context(), "blob reference counts disagree with the manifests",
"blobs", rep.Blobs, "drift", rep.DriftCount, "repaired", rep.Repaired)
}
httpx.WriteJSON(w, http.StatusOK, out)
return nil
}
+101
View File
@@ -0,0 +1,101 @@
package adminapi
import (
"net/http"
"strings"
"testing"
"time"
"github.com/iceBear67/simplepages/api"
)
func TestWhoAmIReportsTheCaller(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
future := time.Now().Add(time.Hour).Truncate(time.Second)
proj := e.createKey(t, api.PathProjectKeys("demo"), e.adminToken,
api.CreateKeyRequest{Name: "ci", ExpiresAt: &future})
t.Run("project scope", func(t *testing.T) {
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), proj.Token, nil)
var who api.WhoAmI
mustJSON(t, status, http.StatusOK, body, &who)
if who.KeyID != proj.Key.ID {
t.Errorf("key_id = %q, want %q", who.KeyID, proj.Key.ID)
}
if who.Scope != api.ScopeProject || who.Project != "demo" || who.Name != "ci" {
t.Errorf("whoami = %+v, want scope project on demo named ci", who)
}
if who.ExpiresAt == nil || !who.ExpiresAt.Equal(future) {
t.Errorf("expires_at = %v, want %v", who.ExpiresAt, future)
}
})
t.Run("admin scope", func(t *testing.T) {
status, body := e.do(t, http.MethodGet, api.PathWhoAmI(), e.adminToken, nil)
var who api.WhoAmI
mustJSON(t, status, http.StatusOK, body, &who)
if who.Scope != api.ScopeAdmin {
t.Errorf("scope = %q, want admin", who.Scope)
}
// An admin key belongs to no project, and saying otherwise would suggest
// the caller is confined to one.
if who.Project != "" {
t.Errorf("project = %q, want empty for an admin key", who.Project)
}
})
// whoami names a credential, so it must not be cached by anything between
// the CLI and the server.
t.Run("no-store", func(t *testing.T) {
resp := e.doResp(t, http.MethodGet, api.PathWhoAmI(), proj.Token, nil)
if cc := resp.Header.Get("Cache-Control"); !strings.Contains(cc, "no-store") {
t.Errorf("Cache-Control = %q, want it to contain no-store", cc)
}
})
}
func TestSystemInfo(t *testing.T) {
e := newEnv(t)
e.createProject(t, "a")
e.createProject(t, "b")
status, body := e.do(t, http.MethodGet, api.PathSystemInfo(), e.adminToken, nil)
var info api.SystemInfo
mustJSON(t, status, http.StatusOK, body, &info)
if info.Projects != 2 {
t.Errorf("projects = %d, want 2", info.Projects)
}
if info.Deployments != 0 || info.Blobs != 0 || info.CASBytes != 0 {
t.Errorf("expected an empty CAS, got %+v", info)
}
if info.SchemaVer < 1 {
t.Errorf("schema_version = %d, want at least 1", info.SchemaVer)
}
if info.Version == "" {
t.Error("version is empty")
}
// The link mode is not known until the CAS is opened in M2; reporting an
// empty string would read as "no linking" rather than "not determined".
if info.LinkMode != "unknown" {
t.Errorf("link_mode = %q, want unknown", info.LinkMode)
}
if info.UptimeS < 0 {
t.Errorf("uptime_s = %d", info.UptimeS)
}
}
func TestSystemInfoIsAdminOnly(t *testing.T) {
e := newEnv(t)
e.createProject(t, "demo")
token := e.mintProject(t, e.projectID(t, "demo"), "ci")
status, body := e.do(t, http.MethodGet, api.PathSystemInfo(), token, nil)
if status != http.StatusForbidden {
t.Fatalf("status = %d, want 403; body: %s", status, body)
}
if got := errCode(t, body); got != api.CodeForbidden {
t.Errorf("code = %q, want %q", got, api.CodeForbidden)
}
}
+107
View File
@@ -0,0 +1,107 @@
package adminapi
import (
"io/fs"
"strings"
"github.com/iceBear67/simplepages/api"
"github.com/iceBear67/simplepages/internal/config"
)
// Field length caps. They exist to stop a client filling the database with a
// megabyte of display name, not to express any semantic limit.
const (
maxDisplayNameLen = 200
maxKeyNameLen = 200
maxSitePathLen = 1024
maxCacheControlLen = 256
)
// checkProjectName validates a name before it becomes a row and, later, a
// "~name" entry inside $WEBROOT. The pattern is the only thing standing between
// a project name and the filesystem, so this is a hard reject rather than a
// normalisation.
func checkProjectName(name string) error {
if name == "" {
return api.Errorf(api.CodeInvalidProjectName, "project name is required")
}
if !config.ProjectNamePattern.MatchString(name) {
return api.Errorf(api.CodeInvalidProjectName,
"project name must match %s", config.ProjectNamePattern.String())
}
return nil
}
// checkText rejects control characters in free-text fields.
//
// These strings are printed to operator terminals by "pages project list" and
// written into structured logs. An embedded ESC would let whoever set the field
// move the cursor, recolour the output or rewrite the line the operator is
// reading; a newline would forge a second log record.
func checkText(field, v string, max int) error {
if len(v) > max {
return api.Errorf(api.CodeBadRequest, "%s must be at most %d bytes", field, max)
}
for i := 0; i < len(v); i++ {
if c := v[i]; c < 0x20 || c == 0x7f {
return api.Errorf(api.CodeBadRequest, "%s must not contain control characters", field)
}
}
return nil
}
// checkSitePath validates a path that names a document inside a deployment,
// such as index_file or not_found_file.
//
// It is checked here as well as at serve time because a value that cannot
// possibly resolve is a configuration mistake worth reporting at the moment it
// is made, rather than as a silent 404 on every request afterwards.
func checkSitePath(field, v string) error {
if v == "" {
return api.Errorf(api.CodeInvalidPath, "%s must not be empty", field)
}
if len(v) > maxSitePathLen {
return api.Errorf(api.CodeInvalidPath, "%s must be at most %d bytes", field, maxSitePathLen)
}
// fs.ValidPath rejects "..", absolute paths, empty segments and a trailing
// slash. It accepts ".", which is a directory rather than a document.
if !fs.ValidPath(v) || v == "." {
return api.Errorf(api.CodeInvalidPath,
"%s must be a relative slash-separated path with no . or .. segments", field)
}
if strings.ContainsRune(v, '\\') {
return api.Errorf(api.CodeInvalidPath, "%s must use forward slashes", field)
}
for i := 0; i < len(v); i++ {
if c := v[i]; c < 0x20 || c == 0x7f {
return api.Errorf(api.CodeInvalidPath, "%s must not contain control characters", field)
}
}
return nil
}
// checkHeaderValue validates a string that is emitted verbatim as a response
// header value.
//
// A CR or LF here would be response splitting: the project could append headers
// of its own choosing to every response it serves, and under path routing those
// responses share an origin with every other project. net/http replaces newlines
// with spaces on write, so this is defence in depth — but it is the layer that
// keeps the bad value out of the database in the first place, and it is the one
// that tells the operator they typed something wrong.
func checkHeaderValue(field, v string) error {
if len(v) > maxCacheControlLen {
return api.Errorf(api.CodeBadRequest, "%s must be at most %d bytes", field, maxCacheControlLen)
}
for i := 0; i < len(v); i++ {
c := v[i]
if c == '\t' {
continue
}
if c < 0x20 || c > 0x7e {
return api.Errorf(api.CodeBadRequest,
"%s must contain only printable ASCII (no newlines)", field)
}
}
return nil
}
+114
View File
@@ -0,0 +1,114 @@
package adminapi
import (
"strings"
"testing"
"github.com/iceBear67/simplepages/api"
)
func TestCheckProjectName(t *testing.T) {
valid := []string{
"a", "0", "demo", "demo-site", "demo_site", "demo.site", "a1.b-c_d",
strings.Repeat("z", 63),
}
for _, name := range valid {
if err := checkProjectName(name); err != nil {
t.Errorf("checkProjectName(%q) = %v, want nil", name, err)
}
}
// Everything that could become a path separator, a traversal or a hidden
// file once the name is turned into a "~name" entry in $WEBROOT.
invalid := []string{
"", ".", "..", "./x", "../x", "/demo", "demo/", "a/b", `a\b`,
".hidden", "-lead", "_lead", "Demo", "DEMO", "démo", "demo ",
" demo", "de mo", "demo\t", "demo\n", "demo\x00", "~demo", "demo%2f",
strings.Repeat("z", 64),
}
for _, name := range invalid {
err := checkProjectName(name)
if err == nil {
t.Errorf("checkProjectName(%q) = nil, want an error", name)
continue
}
if got := api.CodeOf(err); got != api.CodeInvalidProjectName {
t.Errorf("checkProjectName(%q) code = %q, want %q", name, got, api.CodeInvalidProjectName)
}
}
}
func TestCheckSitePath(t *testing.T) {
valid := []string{
"index.html", "404.html", "a/b/c.html", "a.b/c", "_next/index.html",
"страница.html", strings.Repeat("a", maxSitePathLen),
}
for _, p := range valid {
if err := checkSitePath("index_file", p); err != nil {
t.Errorf("checkSitePath(%q) = %v, want nil", p, err)
}
}
invalid := []string{
"", ".", "..", "/index.html", "index.html/", "a//b", "a/./b", "a/../b",
"../../etc/passwd", `a\b`, "a\x00b", "a\nb", "a\x7fb",
strings.Repeat("a", maxSitePathLen+1),
}
for _, p := range invalid {
err := checkSitePath("index_file", p)
if err == nil {
t.Errorf("checkSitePath(%q) = nil, want an error", p)
continue
}
if got := api.CodeOf(err); got != api.CodeInvalidPath {
t.Errorf("checkSitePath(%q) code = %q, want %q", p, got, api.CodeInvalidPath)
}
}
}
func TestCheckHeaderValue(t *testing.T) {
valid := []string{
"", "public, max-age=0, must-revalidate", "no-store",
"public,\tmax-age=31536000, immutable", strings.Repeat("a", maxCacheControlLen),
}
for _, v := range valid {
if err := checkHeaderValue("cache_control", v); err != nil {
t.Errorf("checkHeaderValue(%q) = %v, want nil", v, err)
}
}
// A CR or LF here would let a project append headers of its own to every
// response it serves, on an origin it shares with every other project.
invalid := []string{
"public\r\nX-Evil: 1", "public\nX-Evil: 1", "public\r", "public\x00",
"public\x7f", "public é", strings.Repeat("a", maxCacheControlLen+1),
}
for _, v := range invalid {
if err := checkHeaderValue("cache_control", v); err == nil {
t.Errorf("checkHeaderValue(%q) = nil, want an error", v)
}
}
}
func TestCheckText(t *testing.T) {
// Free text is allowed to be anything printable, including non-ASCII: it is
// a display name, not a header value.
valid := []string{"", "Demo Site", "デモ", "a — b", strings.Repeat("x", maxDisplayNameLen)}
for _, v := range valid {
if err := checkText("display_name", v, maxDisplayNameLen); err != nil {
t.Errorf("checkText(%q) = %v, want nil", v, err)
}
}
// Control characters are not: these strings are printed to operator
// terminals and written into structured logs.
invalid := []string{
"a\x1b[31mred", "line\nbreak", "car\rriage", "nul\x00", "del\x7f", "tab\there",
strings.Repeat("x", maxDisplayNameLen+1),
}
for _, v := range invalid {
if err := checkText("display_name", v, maxDisplayNameLen); err == nil {
t.Errorf("checkText(%q) = nil, want an error", v)
}
}
}