Files
pages/internal/adminapi/projects_test.go
T
2026-08-15 07:13:00 +00:00

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: &notFound,
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)
}
})
}