Files
2026-08-15 07:13:00 +00:00

590 lines
22 KiB
Go

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)
}
}
}