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