Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-11-09 15:49:16 -05:00
pkg-proxy/internal/server/projects_api_test.go
Simon Chrzanowski 1c706fffd0 Review fixes for the project inventory
- GetProjectsUsingPackage no longer counts each project's components
  per row; the package page only needs names and versions. The ?purl=
  listing leaves component_count out accordingly.
- The delete button sends to DELETE /ui/api/projects/{project}, the same
  handler as the API and still behind the token, so it sits under /ui
  like the cache deletes.
- The project page answers 500 when its components can't be read,
  instead of showing an empty project.
- DELETE of a path ending in /sbom that names no project answers 400
  with a hint, since it's most likely the upload path.
- The upload endpoint lists JSON, XML and plain text (SPDX tag-value)
  as accepted.
- The docs say that versions are compared as the SBOM spells them.
2026-10-27 20:23:14 +01:00

412 lines
16 KiB
Go

package server
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/git-pkgs/proxy/internal/config"
"github.com/git-pkgs/proxy/internal/database"
"github.com/go-chi/chi/v5"
)
const testProjectsToken = "sbom-token"
// syftSBOM is a CycloneDX document syft produced for a directory with an
// npm, Composer and Go lock file and an Alpine package database. It lists
// ten packages; five entries (the OS and the lock files) have no PURL.
func syftSBOM(t *testing.T) []byte {
t.Helper()
data, err := os.ReadFile("../inventory/testdata/syft-cyclonedx.json")
if err != nil {
t.Fatal(err)
}
return data
}
type projectsTestServer struct {
server *Server
router http.Handler
db *database.DB
}
// newProjectsTestServer routes the project API and the UI pages that show
// the inventory, with projects.enabled set.
func newProjectsTestServer(t *testing.T) projectsTestServer {
t.Helper()
db, err := database.Create(filepath.Join(t.TempDir(), "test.db"))
if err != nil {
t.Fatalf("database.Create: %v", err)
}
t.Cleanup(func() { _ = db.Close() })
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
cfg := &config.Config{Projects: config.ProjectsConfig{Enabled: true, Token: testProjectsToken}}
s := &Server{cfg: cfg, db: db, logger: logger, templates: &Templates{}}
r := chi.NewRouter()
registerProjectRoutes(r, NewProjectsAPIHandler(db, logger), testProjectsToken)
r.Route("/ui", func(ui chi.Router) {
ui.Get("/", s.handleRoot)
ui.Get("/package/{ecosystem}/*", s.handlePackagePath)
s.mountProjectUI(ui)
})
return projectsTestServer{server: s, router: r, db: db}
}
func (ts projectsTestServer) do(t *testing.T, method, target, token string, body []byte) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, target, bytes.NewReader(body))
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
rec := httptest.NewRecorder()
ts.router.ServeHTTP(rec, req)
return rec
}
func (ts projectsTestServer) upload(t *testing.T, target string, body []byte) ProjectUploadResponse {
t.Helper()
rec := ts.do(t, http.MethodPut, target, testProjectsToken, body)
if rec.Code != http.StatusOK {
t.Fatalf("PUT %s = %d: %s", target, rec.Code, rec.Body)
}
var resp ProjectUploadResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatalf("decoding upload response: %v", err)
}
return resp
}
func decodeJSON(t *testing.T, rec *httptest.ResponseRecorder, v any) {
t.Helper()
if rec.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rec.Code, rec.Body)
}
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
t.Fatalf("decoding response: %v", err)
}
}
// componentCount returns a project's component_count, or -1 if not set.
func componentCount(p ProjectSummary) int64 {
if p.ComponentCount == nil {
return -1
}
return *p.ComponentCount
}
func TestProjectUploadAndGet(t *testing.T) {
ts := newProjectsTestServer(t)
resp := ts.upload(t, "/api/projects/acme/shop/web/sbom?ref=0123abcd&branch=main", syftSBOM(t))
if resp != (ProjectUploadResponse{Project: "acme/shop/web", Components: 9, Skipped: 6}) {
t.Errorf("upload response = %+v", resp)
}
// The same project, URL-encoded as one segment.
var project ProjectResponse
decodeJSON(t, ts.do(t, http.MethodGet, "/api/projects/acme%2Fshop%2Fweb", "", nil), &project)
if project.Name != "acme/shop/web" || project.Ref != "0123abcd" || project.Branch != "main" || componentCount(project.ProjectSummary) != 9 {
t.Errorf("project = %+v", project.ProjectSummary)
}
var purls []string
for _, c := range project.Components {
purls = append(purls, c.PURL)
}
for _, want := range []string{"pkg:npm/%40babel/core@7.23.0", "pkg:composer/symfony/console@v6.4.1", "pkg:golang/github.com/spf13/cobra@v1.8.0", "pkg:apk/alpine/busybox@1.36.1-r15"} {
if !strings.Contains(strings.Join(purls, " "), want) {
t.Errorf("components %v do not include %s", purls, want)
}
}
var list ProjectListResponse
decodeJSON(t, ts.do(t, http.MethodGet, "/api/projects", "", nil), &list)
if len(list.Projects) != 1 || list.Projects[0].Name != "acme/shop/web" || componentCount(list.Projects[0]) != 9 {
t.Errorf("list = %+v", list)
}
}
func TestProjectUploadReplacesComponents(t *testing.T) {
ts := newProjectsTestServer(t)
ts.upload(t, "/api/projects/acme%2Fweb/sbom?ref=one", syftSBOM(t))
resp := ts.upload(t, "/api/projects/acme%2Fweb/sbom", []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"}]}`))
if resp.Components != 1 || resp.Skipped != 0 {
t.Errorf("upload response = %+v", resp)
}
var project ProjectResponse
decodeJSON(t, ts.do(t, http.MethodGet, "/api/projects/acme/web", "", nil), &project)
if len(project.Components) != 1 || project.Components[0].PURL != "pkg:npm/lodash@4.17.21" || project.Ref != "" {
t.Errorf("project after second upload = %+v", project)
}
}
func TestProjectLookupByPURL(t *testing.T) {
ts := newProjectsTestServer(t)
ts.upload(t, "/api/projects/acme/web/sbom", syftSBOM(t))
ts.upload(t, "/api/projects/acme/api/sbom", []byte(`{"bomFormat":"CycloneDX","specVersion":"1.5","components":[
{"type":"library","name":"lodash","version":"4.17.21","purl":"pkg:npm/lodash@4.17.21"}]}`))
tests := []struct {
purl string
want []string
}{
{"pkg:npm/lodash", []string{"acme/api@4.17.21", "acme/web@4.17.20"}},
{"pkg:npm/lodash@4.17.20", []string{"acme/web@4.17.20"}},
{"pkg:npm/lodash@1.0.0", nil},
{"pkg:npm/%40babel/core", []string{"acme/web@7.23.0"}},
{"pkg:apk/alpine/musl@1.2.4-r2?arch=x86_64", []string{"acme/web@1.2.4-r2"}},
}
for _, tt := range tests {
t.Run(tt.purl, func(t *testing.T) {
var list ProjectListResponse
decodeJSON(t, ts.do(t, http.MethodGet, "/api/projects?purl="+url.QueryEscape(tt.purl), "", nil), &list)
var got []string
for _, p := range list.Projects {
got = append(got, p.Name+"@"+p.Version)
if p.ComponentCount != nil {
t.Errorf("%s has component_count set when listing by purl", p.Name)
}
}
if strings.Join(got, ",") != strings.Join(tt.want, ",") {
t.Errorf("projects = %v, want %v", got, tt.want)
}
})
}
for _, bad := range []string{"lodash", "pkg:github/actions/checkout@v4"} {
if rec := ts.do(t, http.MethodGet, "/api/projects?purl="+url.QueryEscape(bad), "", nil); rec.Code != http.StatusBadRequest {
t.Errorf("purl %q status = %d, want 400", bad, rec.Code)
}
}
}
func TestProjectWritesRequireToken(t *testing.T) {
ts := newProjectsTestServer(t)
for _, token := range []string{"", "wrong-token"} {
if rec := ts.do(t, http.MethodPut, "/api/projects/acme/web/sbom", token, syftSBOM(t)); rec.Code != http.StatusUnauthorized {
t.Errorf("upload with token %q status = %d, want 401", token, rec.Code)
}
if rec := ts.do(t, http.MethodDelete, "/api/projects/acme/web", token, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("delete with token %q status = %d, want 401", token, rec.Code)
}
}
if rec := ts.do(t, http.MethodGet, "/api/projects/acme/web", "", nil); rec.Code != http.StatusNotFound {
t.Errorf("project exists after unauthorized upload: status %d", rec.Code)
}
}
func TestProjectDelete(t *testing.T) {
ts := newProjectsTestServer(t)
ts.upload(t, "/api/projects/acme/web/sbom", syftSBOM(t))
if rec := ts.do(t, http.MethodDelete, "/api/projects/acme%2Fweb", testProjectsToken, nil); rec.Code != http.StatusNoContent {
t.Fatalf("delete status = %d: %s", rec.Code, rec.Body)
}
if rec := ts.do(t, http.MethodDelete, "/api/projects/acme/web", testProjectsToken, nil); rec.Code != http.StatusNotFound {
t.Errorf("second delete status = %d, want 404", rec.Code)
}
if rec := ts.do(t, http.MethodGet, "/api/projects/acme/web", "", nil); rec.Code != http.StatusNotFound {
t.Errorf("get after delete status = %d, want 404", rec.Code)
}
}
func TestProjectDeleteOfUploadPath(t *testing.T) {
ts := newProjectsTestServer(t)
ts.upload(t, "/api/projects/acme/web/sbom", syftSBOM(t))
rec := ts.do(t, http.MethodDelete, "/api/projects/acme/web/sbom", testProjectsToken, nil)
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "without /sbom") {
t.Errorf("DELETE of the upload path = %d: %s, want 400 with a hint", rec.Code, rec.Body)
}
if rec := ts.do(t, http.MethodGet, "/api/projects/acme/web", "", nil); rec.Code != http.StatusOK {
t.Errorf("project is gone after DELETE of the upload path: %d", rec.Code)
}
// A project whose last segment is "sbom" is deleted as usual.
ts.upload(t, "/api/projects/acme/sbom/sbom", syftSBOM(t))
if rec := ts.do(t, http.MethodDelete, "/api/projects/acme/sbom", testProjectsToken, nil); rec.Code != http.StatusNoContent {
t.Errorf("DELETE of project acme/sbom = %d: %s", rec.Code, rec.Body)
}
}
func TestProjectDeleteFromUI(t *testing.T) {
ts := newProjectsTestServer(t)
ts.upload(t, "/api/projects/acme/web/sbom", syftSBOM(t))
for _, token := range []string{"", "wrong-token"} {
if rec := ts.do(t, http.MethodDelete, "/ui/api/projects/acme%2Fweb", token, nil); rec.Code != http.StatusUnauthorized {
t.Errorf("UI delete with token %q status = %d, want 401", token, rec.Code)
}
}
if rec := ts.do(t, http.MethodDelete, "/ui/api/projects/acme%2Fweb", testProjectsToken, nil); rec.Code != http.StatusNoContent {
t.Fatalf("UI delete status = %d: %s", rec.Code, rec.Body)
}
if rec := ts.do(t, http.MethodGet, "/api/projects/acme/web", "", nil); rec.Code != http.StatusNotFound {
t.Errorf("get after UI delete status = %d, want 404", rec.Code)
}
}
func TestProjectPathsAreValidated(t *testing.T) {
ts := newProjectsTestServer(t)
uploads := []string{
"/api/projects/sbom",
"/api/projects/acme/web",
"/api/projects/acme/../web/sbom",
"/api/projects/acme%2F..%2Fweb/sbom",
"/api/projects/..%2Fweb/sbom",
"/api/projects/acme//web/sbom",
"/api/projects/acme/./web/sbom",
"/api/projects/acme%20web/sbom",
"/api/projects/acme%2Fweb%3F/sbom",
"/api/projects/" + strings.Repeat("a", 256) + "/sbom",
}
for _, target := range uploads {
if rec := ts.do(t, http.MethodPut, target, testProjectsToken, syftSBOM(t)); rec.Code != http.StatusBadRequest {
t.Errorf("PUT %s status = %d, want 400", target, rec.Code)
}
}
for _, target := range []string{"/api/projects/", "/api/projects/acme/../web", "/api/projects/acme%2F..%2Fweb"} {
if rec := ts.do(t, http.MethodGet, target, "", nil); rec.Code != http.StatusBadRequest {
t.Errorf("GET %s status = %d, want 400", target, rec.Code)
}
if rec := ts.do(t, http.MethodDelete, target, testProjectsToken, nil); rec.Code != http.StatusBadRequest {
t.Errorf("DELETE %s status = %d, want 400", target, rec.Code)
}
}
if projects, err := ts.db.ListProjects(); err != nil || len(projects) != 0 {
t.Errorf("projects after invalid uploads = %v, %v", projects, err)
}
}
func TestProjectUploadRejectsBadBodies(t *testing.T) {
ts := newProjectsTestServer(t)
tests := []struct {
name string
target string
body []byte
want int
}{
{"empty", "/api/projects/acme/web/sbom", nil, http.StatusBadRequest},
{"not an SBOM", "/api/projects/acme/web/sbom", []byte(`{"hello":"world"}`), http.StatusBadRequest},
{"too large", "/api/projects/acme/web/sbom", make([]byte, maxSBOMBytes+1), http.StatusRequestEntityTooLarge},
{"long ref", "/api/projects/acme/web/sbom?ref=" + strings.Repeat("a", maxRefLength+1), syftSBOM(t), http.StatusBadRequest},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if rec := ts.do(t, http.MethodPut, tt.target, testProjectsToken, tt.body); rec.Code != tt.want {
t.Errorf("status = %d, want %d: %s", rec.Code, tt.want, rec.Body)
}
})
}
}
// startProjectsServer runs the real server with the given projects config.
func startProjectsServer(t *testing.T, projects config.ProjectsConfig) string {
t.Helper()
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatalf("reserving proxy address: %v", err)
}
t.Cleanup(func() { _ = listener.Close() })
tempDir := t.TempDir()
cfg := config.Default()
cfg.Listen = listener.Addr().String()
cfg.BaseURL = "http://" + cfg.Listen
cfg.Database.Path = filepath.Join(tempDir, "proxy.db")
cfg.Storage.URL = "file://" + filepath.Join(tempDir, "artifacts")
cfg.Projects = projects
if err := cfg.Validate(); err != nil {
t.Fatalf("validating config: %v", err)
}
proxyServer, err := New(cfg, slog.New(slog.NewTextHandler(io.Discard, nil)), BuildInfo{Version: "test", Commit: "test"})
if err != nil {
t.Fatalf("creating server: %v", err)
}
startErr := make(chan error, 1)
go func() { startErr <- proxyServer.Start(listener) }()
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = proxyServer.Shutdown(ctx)
if err := <-startErr; !errors.Is(err, http.ErrServerClosed) {
t.Errorf("server stopped with %v", err)
}
})
return cfg.BaseURL
}
func request(t *testing.T, method, url, token string, body []byte) (int, string) {
t.Helper()
req, err := http.NewRequest(method, url, bytes.NewReader(body))
if err != nil {
t.Fatalf("building request: %v", err)
}
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
defer func() { _ = resp.Body.Close() }()
data, _ := io.ReadAll(resp.Body)
return resp.StatusCode, string(data)
}
func TestProjectRoutesFollowConfig(t *testing.T) {
disabled := startProjectsServer(t, config.ProjectsConfig{})
for _, path := range []string{"/api/projects", "/api/projects/acme/web", "/ui/projects"} {
if status, _ := request(t, http.MethodGet, disabled+path, "", nil); status != http.StatusNotFound {
t.Errorf("GET %s with projects disabled = %d, want 404", path, status)
}
}
if status, _ := request(t, http.MethodPut, disabled+"/api/projects/acme/web/sbom", testProjectsToken, syftSBOM(t)); status != http.StatusNotFound {
t.Errorf("upload with projects disabled = %d, want 404", status)
}
if status, _ := request(t, http.MethodDelete, disabled+"/ui/api/projects/acme%2Fweb", testProjectsToken, nil); status != http.StatusNotFound {
t.Errorf("UI delete with projects disabled = %d, want 404", status)
}
if _, body := request(t, http.MethodGet, disabled+"/ui/", "", nil); strings.Contains(body, `href="/ui/projects"`) {
t.Error("navigation links to projects while they are disabled")
}
enabled := startProjectsServer(t, config.ProjectsConfig{Enabled: true, Token: testProjectsToken, MaxAge: "90d"})
if status, _ := request(t, http.MethodPut, enabled+"/api/projects/acme%2Fweb/sbom", "", syftSBOM(t)); status != http.StatusUnauthorized {
t.Errorf("upload without token = %d, want 401", status)
}
if status, body := request(t, http.MethodPut, enabled+"/api/projects/acme%2Fweb/sbom", testProjectsToken, syftSBOM(t)); status != http.StatusOK {
t.Fatalf("upload = %d: %s", status, body)
}
if status, body := request(t, http.MethodGet, enabled+"/api/projects/acme/web", "", nil); status != http.StatusOK || !strings.Contains(body, `"name":"acme/web"`) {
t.Errorf("GET project = %d: %s", status, body)
}
if status, body := request(t, http.MethodGet, enabled+"/ui/projects", "", nil); status != http.StatusOK || !strings.Contains(body, `href="/ui/projects/acme/web"`) {
t.Errorf("projects page = %d, missing the project link", status)
}
if _, body := request(t, http.MethodGet, enabled+"/ui/", "", nil); !strings.Contains(body, `href="/ui/projects"`) {
t.Error("navigation does not link to projects")
}
if status, _ := request(t, http.MethodDelete, enabled+"/ui/api/projects/acme%2Fweb", "", nil); status != http.StatusUnauthorized {
t.Errorf("UI delete without token = %d, want 401", status)
}
if status, body := request(t, http.MethodDelete, enabled+"/ui/api/projects/acme%2Fweb", testProjectsToken, nil); status != http.StatusNoContent {
t.Errorf("UI delete = %d: %s", status, body)
}
// The delete button sends its request there, too.
if _, body := request(t, http.MethodGet, enabled+"/ui/static/project-delete.js", "", nil); !strings.Contains(body, "'/ui/api/projects/'") {
t.Error("project-delete.js does not send to /ui/api/projects")
}
}