440 lines
14 KiB
Go
440 lines
14 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|