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": "

hello

", "assets/app.js": "console.log(1)", "copy.html": "

hello

", // 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) } } }