590 lines
22 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|