init
This commit is contained in:
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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: ¬Found,
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user