From 8402cb9d61b13350205e9c607253dd10c79c0752 Mon Sep 17 00:00:00 2001 From: medunes Date: Thu, 28 May 2026 00:30:51 +0200 Subject: [PATCH] feat(add verbose flag) --- .github/workflows/test.yml | 24 +++ Makefile | 6 +- README.md | 102 ++++++--- main.go | 160 +++++++++------ main_test.go | 410 +++++++++++++++++++++++++++++++++++++ 5 files changed, 605 insertions(+), 97 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 main_test.go diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a390a28 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,24 @@ +name: Test + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + test: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: stable + + - name: Run tests + run: make test diff --git a/Makefile b/Makefile index b7716c1..254e9bc 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ build: - CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o dtrack + CGO_ENABLED=0 go build -ldflags="-extldflags=-static" -o dtrack-cli format: go fmt ./... format-check: @@ -11,4 +11,6 @@ format-check: echo "--- Diff ---"; \ gofmt -d .; \ exit 1; \ - fi \ No newline at end of file + fi +test: + go test -v -race ./... diff --git a/README.md b/README.md index 09c064a..bfee835 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # Dependency-Track Lifecycle CLI [![Release](https://github.com/MedUnes/dtrack-cli/actions/workflows/release.yml/badge.svg)](https://github.com/MedUnes/dtrack-cli/actions/workflows/release.yml) @@ -6,43 +5,56 @@ [![License](https://img.shields.io/github/license/medunes/dtrack-cli)](LICENSE) [![Go Reference](https://pkg.go.dev/badge/github.com/medunes/dtrack-cli.svg)](https://pkg.go.dev/github.com/medunes/dtrack-cli) -A Go-based CLI tool to automate the upload and lifecycle management of Software Bill of Materials (SBOM) in [OWASP Dependency-Track](https://dependencytrack.org/). +A Go-based CLI tool to automate the upload and lifecycle management of Software Bill of Materials (SBOM) +in [OWASP Dependency-Track](https://dependencytrack.org/). -This tool bridges the gap between simple API uploads and full CI/CD lifecycle management by handling **version sprawl**, **active states**, and **latest version tagging** in a single execution. +This tool bridges the gap between simple API uploads and full CI/CD lifecycle management by handling **version sprawl**, +**active states**, and **latest version tagging** in a single execution. ## ๐Ÿš€ Why this tool? (The Gap) -If you simply use `curl` to upload an SBOM to Dependency-Track, you encounter two major problems over time. These are well-documented pain points in the community: +If you simply use `curl` to upload an SBOM to Dependency-Track, you encounter two major problems over time. These are +well-documented pain points in the community: ### 1. The "Version Sprawl" Problem -Every CI build creates a new version. If you have 100 builds, you have 100 "Active" versions. Dependency-Track monitors *all* active versions for vulnerabilities, meaning you will receive alerts for vulnerabilities in old, undeployed versions. +Every CI build creates a new version. If you have 100 builds, you have 100 "Active" versions. Dependency-Track monitors +*all* active versions for vulnerabilities, meaning you will receive alerts for vulnerabilities in old, undeployed +versions. -* **Community Validation:** Users have explicitly requested an `isActiveExclusively` flag to solve this, noting that "Over time there will be hundreds of 'active' versions, even though they are actually not 'active'". -* **Current Workaround:** Teams currently resort to manual housekeeping or complex scripts to set "dirty tags to inactive" to avoid polluting their risk score. -* **Our Solution:** The `-clean` flag automatically iterates through project versions and sets old ones to `active: false`. +* **Community Validation:** Users have explicitly requested an `isActiveExclusively` flag to solve this, noting that " + Over time there will be hundreds of 'active' versions, even though they are actually not 'active'". +* **Current Workaround:** Teams currently resort to manual housekeeping or complex scripts to set "dirty tags to + inactive" to avoid polluting their risk score. +* **Our Solution:** The `-clean` flag automatically iterates through project versions and sets old ones to + `active: false`. ### 2. The "Latest Version" Ambiguity -Dependency-Track attempts to guess the "latest" version, but it is not always accurate (e.g., when patching older release branches or dealing with pre-releases). +Dependency-Track attempts to guess the "latest" version, but it is not always accurate (e.g., when patching older +release branches or dealing with pre-releases). -* **Community Validation:** Users have reported issues where Dependency-Track incorrectly identifies pre-release versions as "latest," skewing "Outdated Component" analysis metrics. -* **Our Solution:** The `-latest` flag explicitly forces the version you are currently uploading to be marked `isLatest=true`, ensuring your "Outdated Component" metrics are calculated against the correct baseline. +* **Community Validation:** Users have reported issues where Dependency-Track incorrectly identifies pre-release + versions as "latest," skewing "Outdated Component" analysis metrics. +* **Our Solution:** The `-latest` flag explicitly forces the version you are currently uploading to be marked + `isLatest=true`, ensuring your "Outdated Component" metrics are calculated against the correct baseline. ### 3. Missing Auto-Purge/Cleanup There is no built-in native feature to "keep only the last X versions" or "purge old versions" during upload. -* **Community Validation:** Feature requests for "automatic purging of projects" have been raised by users who find it "hard to do this manually" for projects with many releases. -* **Our Solution:** While we don't delete data (auditors hate that!), our tool effectively "archives" old versions by deactivating them, solving the noise issue without destroying history. +* **Community Validation:** Feature requests for "automatic purging of projects" have been raised by users who find it " + hard to do this manually" for projects with many releases. +* **Our Solution:** While we don't delete data (auditors hate that!), our tool effectively "archives" old versions by + deactivating them, solving the noise issue without destroying history. ## ๐Ÿง  Concepts: Active vs. Latest Understanding these flags is critical for a clean dashboard: -| Flag | Dependency-Track Meaning | Impact on Pipeline | -| --- | --- | --- | -| **Active** | Indicates this version is currently deployed/supported. | **Critical.** Only "Active" versions contribute to Portfolio Metrics and trigger Vulnerability Alerts. | +| Flag | Dependency-Track Meaning | Impact on Pipeline | +|------------|------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------| +| **Active** | Indicates this version is currently deployed/supported. | **Critical.** Only "Active" versions contribute to Portfolio Metrics and trigger Vulnerability Alerts. | | **Latest** | Indicates this is the most current version of the project. | **Visual & Analytical.** Used as the baseline for "Outdated Component" analysis and marked with a badge in the UI. | ## ๐Ÿ“ฆ Installation @@ -50,6 +62,7 @@ Understanding these flags is critical for a clean dashboard: Since this is a single-file Go program, you can run it directly or build a binary. ### Option 1: Download the lastest release binary (Recommended for CI) + **Run the following command to get the latest release binary**: ```bash @@ -83,7 +96,7 @@ Use the `-ci` shortcut flag, which enables `-upload`, `-latest`, and `-clean` si ```bash # Syntax: go run main.go [flags] -dtrack -ci -file ./build/bom.json $DT_API_KEY "My-Web-App" "v1.2.0" +dtrack-cli -ci -file ./build/bom.json $DT_API_KEY "My-Web-App" "v1.2.0" ``` @@ -98,7 +111,7 @@ dtrack -ci -file ./build/bom.json $DT_API_KEY "My-Web-App" "v1.2.0" **Goal:** Check what versions exist and when they were uploaded without making changes. ```bash -dtrack -list $DT_API_KEY "My-Web-App" +dtrack-cli -list $DT_API_KEY "My-Web-App" ``` @@ -115,37 +128,59 @@ v1.1.0 false false 2023-10-20 10:00 def-456... ### Scenario 3: Maintenance / Hotfix -**Goal:** You uploaded a hotfix for an older version (`v1.0.1`) and want to mark it as Active, but you **do not** want it to become the "Latest" version (because `v2.0` already exists). +**Goal:** You uploaded a hotfix for an older version (`v1.0.1`) and want to mark it as Active, but you **do not** want +it to become the "Latest" version (because `v2.0` already exists). Use specific flags instead of `-ci`. ```bash # Upload and Clean (deactivate others), but do NOT touch the 'Latest' flag -dtrack -upload -clean $DT_API_KEY "My-Web-App" "v1.0.1" +dtrack-cli -upload -clean $DT_API_KEY "My-Web-App" "v1.0.1" ``` ### Scenario 4: Manual Cleanup (No Upload) -**Goal:** Your dashboard is cluttered with 50 old versions. You want to keep `v2.0` as the only active one, but you don't have the SBOM file handy to re-upload. +**Goal:** Your dashboard is cluttered with 50 old versions. You want to keep `v2.0` as the only active one, but you +don't have the SBOM file handy to re-upload. ```bash # Just clean up the metadata -dtrack -clean -latest $DT_API_KEY "My-Web-App" "v2.0" +dtrack-cli -clean -latest $DT_API_KEY "My-Web-App" "v2.0" + +``` + +### Scenario 5: Debugging HTTP issues + +**Goal:** An upload or lookup is failing in a way that isn't obvious โ€” perhaps the base URL is wrong, an upstream proxy +is rewriting requests, or the server is responding with HTML instead of JSON. Reach for `-verbose` to surface the actual +request URL, response status, and a body preview (first 512 bytes). + +```bash +dtrack-cli -verbose -list $DT_API_KEY "My-Web-App" ``` +**Example output:** + +```text +[verbose] โ†’ GET https://dtrack.example.com/api/v1/project?excludeInactive=false&name=My-Web-App&pageNumber=1&pageSize=50 +[verbose] โ† 200 OK +[verbose] Body (preview): [{"uuid":"abc-123","name":"My-Web-App","version":"v1.2.0",...}] +``` + ## ๐Ÿšฉ Flag Reference -| Flag | Description | Requirement | -| --- | --- |---------------------------------------| -| `-upload` | Uploads the file specified by `-file`. | Requires `VERSION` argument. | -| `-list` | Displays a table of all versions for this project. | | -| `-latest` | Marks the target version as `isLatest=true` and unsets it for others. | Requires `VERSION` argument. | -| `-clean` | Marks the target version as `active=true` and **all others** as `active=false`. | Requires `VERSION` argument. | -| `-ci` | **Recommended.** Shortcut for `-upload -latest -clean`. | Requires `VERSION` argument. | -| `-url` | Base URL of your Dependency-Track instance. | Default: `https://dtrack.example.com` | -| `-file` | Path to the SBOM file. | Default: `sbom.json` | +| Flag | Description | Requirement | +|-----------|---------------------------------------------------------------------------------|---------------------------------------| +| `-upload` | Uploads the file specified by `-file`. | Requires `VERSION` argument. | +| `-list` | Displays a table of all versions for this project. | | +| `-latest` | Marks the target version as `isLatest=true` and unsets it for others. | Requires `VERSION` argument. | +| `-clean` | Marks the target version as `active=true` and **all others** as `active=false`. | Requires `VERSION` argument. | +| `-ci` | **Recommended.** Shortcut for `-upload -latest -clean`. | Requires `VERSION` argument. | +| `-url` | Base URL of your Dependency-Track instance. Trailing slashes are stripped. | Default: `https://dtrack.example.com` | +| `-file` | Path to the SBOM file. | Default: `sbom.json` | +| `-verbose` | Prints HTTP request/response details (method, URL, status, body preview) for debugging. | Optional, additive to any other flag. | ## โš ๏ธ Requirements @@ -153,6 +188,11 @@ dtrack -clean -latest $DT_API_KEY "My-Web-App" "v2.0" * **Network:** Access to the Dependency-Track API. * **Permissions:** The API Key used must have `BOM_UPLOAD` and `PROJECT_CREATION_UPLOAD` permissions. +## ๐Ÿงช Development + +* `make build` โ€” produce the static binary. +* `make test` โ€” run `go test -v -race ./...`. + ## ๐Ÿ“š References * Dependency Track REST API documentation https://docs.dependencytrack.org/integrations/rest-api/ diff --git a/main.go b/main.go index 74a9c19..df5924f 100644 --- a/main.go +++ b/main.go @@ -12,6 +12,7 @@ import ( "os" "path/filepath" "slices" + "strings" "text/tabwriter" "time" ) @@ -22,6 +23,7 @@ type Config struct { ProjectName string Version string SbomFile string + Verbose bool } type Project struct { @@ -30,13 +32,20 @@ type Project struct { Version string `json:"version"` Active bool `json:"active"` IsLatest bool `json:"isLatest"` - LastBomImport int64 `json:"lastBomImport"` // Epoch millis + LastBomImport int64 `json:"lastBomImport"` } -var config Config +type HTTPDoer interface { + Do(req *http.Request) (*http.Response, error) +} -func main() { +type App struct { + Config Config + Client HTTPDoer + Stdout io.Writer +} +func main() { uploadPtr := flag.Bool("upload", false, "Upload the SBOM file") listPtr := flag.Bool("list", false, "Display a table of existing versions") latestPtr := flag.Bool("latest", false, "Mark the target version as Latest") @@ -45,6 +54,7 @@ func main() { urlPtr := flag.String("url", "", "Base URL of Dependency-Track") filePtr := flag.String("file", "sbom.json", "Path to the SBOM file") + verbosePtr := flag.Bool("verbose", false, "Print HTTP request/response details for debugging") flag.Parse() @@ -54,15 +64,16 @@ func main() { os.Exit(1) } - config = Config{ - BaseURL: *urlPtr, + cfg := Config{ + BaseURL: strings.TrimRight(*urlPtr, "/"), SbomFile: *filePtr, APIKey: args[0], ProjectName: args[1], + Verbose: *verbosePtr, } if len(args) >= 3 { - config.Version = args[2] + cfg.Version = args[2] } if *ciPtr { @@ -72,32 +83,38 @@ func main() { } actionsRequiringVersion := *uploadPtr || *latestPtr || *cleanPtr - if actionsRequiringVersion && config.Version == "" { + if actionsRequiringVersion && cfg.Version == "" { fmt.Println("Error: You must provide a [VERSION] argument for -upload, -latest, or -clean.") printUsage() os.Exit(1) } + app := &App{ + Config: cfg, + Client: &http.Client{Timeout: 30 * time.Second}, + Stdout: os.Stdout, + } + if *uploadPtr { - if err := uploadSBOM(); err != nil { - fmt.Printf("Error uploading SBOM: %v\n", err) + if err := app.uploadSBOM(); err != nil { + fmt.Fprintf(os.Stderr, "Error uploading SBOM: %v\n", err) os.Exit(1) } } if *listPtr || *latestPtr || *cleanPtr { - versions, err := getAllVersions() + versions, err := app.getAllVersions() if err != nil { - fmt.Printf("Error fetching versions: %v\n", err) + fmt.Fprintf(os.Stderr, "Error fetching versions: %v\n", err) os.Exit(1) } if *listPtr { - displayVersions(versions) + app.displayVersions(versions) } if *latestPtr || *cleanPtr { - updateLifecycle(versions, *latestPtr, *cleanPtr) + app.updateLifecycle(versions, *latestPtr, *cleanPtr) } } } @@ -110,82 +127,79 @@ func printUsage() { flag.PrintDefaults() } -func uploadSBOM() error { - fmt.Printf("Uploading SBOM from %s for %s %s...\n", config.SbomFile, config.ProjectName, config.Version) +func (a *App) uploadSBOM() error { + fmt.Fprintf(a.Stdout, "Uploading SBOM from %s for %s %s...\n", a.Config.SbomFile, a.Config.ProjectName, a.Config.Version) body := &bytes.Buffer{} writer := multipart.NewWriter(body) _ = writer.WriteField("autoCreate", "true") - _ = writer.WriteField("projectName", config.ProjectName) - _ = writer.WriteField("projectVersion", config.Version) + _ = writer.WriteField("projectName", a.Config.ProjectName) + _ = writer.WriteField("projectVersion", a.Config.Version) - file, err := os.Open(config.SbomFile) + file, err := os.Open(a.Config.SbomFile) if err != nil { - return fmt.Errorf("could not open file %s: %v", config.SbomFile, err) + return fmt.Errorf("could not open file %s: %v", a.Config.SbomFile, err) } defer file.Close() - part, err := writer.CreateFormFile("bom", filepath.Base(config.SbomFile)) + part, err := writer.CreateFormFile("bom", filepath.Base(a.Config.SbomFile)) if err != nil { return err } - _, err = io.Copy(part, file) - if err != nil { + if _, err = io.Copy(part, file); err != nil { return err } - err = writer.Close() - if err != nil { + if err := writer.Close(); err != nil { return err } - req, err := http.NewRequest("POST", config.BaseURL+"/api/v1/bom", body) + req, err := http.NewRequest("POST", a.Config.BaseURL+"/api/v1/bom", body) if err != nil { return err } - req.Header.Set("X-Api-Key", config.APIKey) + req.Header.Set("X-Api-Key", a.Config.APIKey) req.Header.Set("Content-Type", writer.FormDataContentType()) - return doRequest(req) + _, err = a.doRequest(req) + return err } -func getAllVersions() ([]Project, error) { - fmt.Println("Fetching all project versions...") +func (a *App) getAllVersions() ([]Project, error) { + fmt.Fprintln(a.Stdout, "Fetching all project versions...") var allProjects []Project page := 1 pageSize := 50 - client := &http.Client{Timeout: 20 * time.Second} for { - req, err := http.NewRequest("GET", config.BaseURL+"/api/v1/project", nil) + req, err := http.NewRequest("GET", a.Config.BaseURL+"/api/v1/project", nil) if err != nil { return nil, err } q := req.URL.Query() - q.Add("name", config.ProjectName) + q.Add("name", a.Config.ProjectName) q.Add("excludeInactive", "false") q.Add("pageNumber", fmt.Sprintf("%d", page)) q.Add("pageSize", fmt.Sprintf("%d", pageSize)) req.URL.RawQuery = q.Encode() - req.Header.Set("X-Api-Key", config.APIKey) + req.Header.Set("X-Api-Key", a.Config.APIKey) req.Header.Set("Content-Type", "application/json") - resp, err := client.Do(req) + bodyBytes, err := a.doRequest(req) if err != nil { return nil, err } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("api returned status: %s", resp.Status) - } var pageProjects []Project - if err := json.NewDecoder(resp.Body).Decode(&pageProjects); err != nil { - return nil, err + if err := json.Unmarshal(bodyBytes, &pageProjects); err != nil { + preview := string(bodyBytes) + if len(preview) > 512 { + preview = preview[:512] + "..." + } + return nil, fmt.Errorf("decoding versions response: %w (body preview: %s)", err, preview) } if len(pageProjects) == 0 { @@ -201,13 +215,13 @@ func getAllVersions() ([]Project, error) { return allProjects, nil } -func displayVersions(projects []Project) { - fmt.Println("\n--- Current Versions ---") - w := tabwriter.NewWriter(os.Stdout, 0, 0, 3, ' ', 0) +func (a *App) displayVersions(projects []Project) { + fmt.Fprintln(a.Stdout, "\n--- Current Versions ---") + w := tabwriter.NewWriter(a.Stdout, 0, 0, 3, ' ', 0) fmt.Fprintln(w, "VERSION\tACTIVE\tLATEST\tLAST UPLOAD\tUUID") for _, p := range projects { - if p.Name != config.ProjectName { + if p.Name != a.Config.ProjectName { continue } @@ -220,19 +234,19 @@ func displayVersions(projects []Project) { fmt.Fprintf(w, "%s\t%v\t%v\t%s\t%s\n", p.Version, p.Active, p.IsLatest, ts, p.UUID) } w.Flush() - fmt.Println("------------------------") + fmt.Fprintln(a.Stdout, "------------------------") } -func updateLifecycle(projects []Project, updateLatest bool, cleanInactive bool) { - fmt.Println("Updating Lifecycle...") +func (a *App) updateLifecycle(projects []Project, updateLatest bool, cleanInactive bool) { + fmt.Fprintln(a.Stdout, "Updating Lifecycle...") for _, p := range projects { - if p.Name != config.ProjectName { + if p.Name != a.Config.ProjectName { continue } payload := make(map[string]interface{}) shouldPatch := false - isTargetVersion := p.Version == config.Version + isTargetVersion := p.Version == a.Config.Version if updateLatest { if isTargetVersion { @@ -263,36 +277,54 @@ func updateLifecycle(projects []Project, updateLatest bool, cleanInactive bool) } if shouldPatch { - fmt.Printf(" -> Patching %s: %v\n", p.Version, payload) - if err := sendPatch(p.UUID, payload); err != nil { - fmt.Printf(" Error: %v\n", err) + fmt.Fprintf(a.Stdout, " -> Patching %s: %v\n", p.Version, payload) + if err := a.sendPatch(p.UUID, payload); err != nil { + fmt.Fprintf(a.Stdout, " Error: %v\n", err) } } } } -func sendPatch(uuid string, payload map[string]interface{}) error { +func (a *App) sendPatch(uuid string, payload map[string]interface{}) error { jsonBody, _ := json.Marshal(payload) - req, err := http.NewRequest("PATCH", fmt.Sprintf("%s/api/v1/project/%s", config.BaseURL, uuid), bytes.NewBuffer(jsonBody)) + req, err := http.NewRequest("PATCH", fmt.Sprintf("%s/api/v1/project/%s", a.Config.BaseURL, uuid), bytes.NewBuffer(jsonBody)) if err != nil { return err } - req.Header.Set("X-Api-Key", config.APIKey) + req.Header.Set("X-Api-Key", a.Config.APIKey) req.Header.Set("Content-Type", "application/json") - return doRequest(req) + _, err = a.doRequest(req) + return err } -func doRequest(req *http.Request) error { - client := &http.Client{Timeout: 30 * time.Second} - resp, err := client.Do(req) +func (a *App) doRequest(req *http.Request) ([]byte, error) { + if a.Config.Verbose { + fmt.Fprintf(a.Stdout, "[verbose] โ†’ %s %s\n", req.Method, req.URL.String()) + } + + resp, err := a.Client.Do(req) if err != nil { - return err + return nil, err } defer resp.Body.Close() + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("reading response body: %w", err) + } + + if a.Config.Verbose { + fmt.Fprintf(a.Stdout, "[verbose] โ† %s\n", resp.Status) + preview := string(bodyBytes) + if len(preview) > 512 { + preview = preview[:512] + "..." + } + fmt.Fprintf(a.Stdout, "[verbose] Body (preview): %s\n", preview) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { - bodyBytes, _ := io.ReadAll(resp.Body) - return fmt.Errorf("status %s: %s", resp.Status, string(bodyBytes)) + return nil, fmt.Errorf("status %s: %s", resp.Status, string(bodyBytes)) } - return nil + + return bodyBytes, nil } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..be3668f --- /dev/null +++ b/main_test.go @@ -0,0 +1,410 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "mime" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" +) + +func newTestApp(t *testing.T, handler http.HandlerFunc) (*App, *bytes.Buffer, *httptest.Server) { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + buf := &bytes.Buffer{} + app := &App{ + Config: Config{ + BaseURL: srv.URL, + APIKey: "test-key", + ProjectName: "test-project", + Version: "v1.0.0", + }, + Client: srv.Client(), + Stdout: buf, + } + return app, buf, srv +} + +func TestTrailingSlashStripped(t *testing.T) { + var gotPath string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + })) + t.Cleanup(srv.Close) + + rawURL := srv.URL + "/" + app := &App{ + Config: Config{ + BaseURL: strings.TrimRight(rawURL, "/"), + APIKey: "k", + ProjectName: "p", + }, + Client: srv.Client(), + Stdout: &bytes.Buffer{}, + } + + if _, err := app.getAllVersions(); err != nil { + t.Fatalf("getAllVersions: %v", err) + } + + if gotPath != "/api/v1/project" { + t.Fatalf("expected request path /api/v1/project, got %q", gotPath) + } +} + +func TestGetAllVersions_SinglePage(t *testing.T) { + projects := []Project{ + {UUID: "u1", Name: "test-project", Version: "v1", LastBomImport: 2000}, + {UUID: "u2", Name: "test-project", Version: "v2", LastBomImport: 1000}, + } + app, _, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(projects) + }) + + got, err := app.getAllVersions() + if err != nil { + t.Fatalf("getAllVersions: %v", err) + } + if len(got) != 2 { + t.Fatalf("expected 2 projects, got %d", len(got)) + } + if got[0].UUID != "u2" || got[1].UUID != "u1" { + t.Fatalf("expected sort by LastBomImport ascending; got order %s, %s", got[0].UUID, got[1].UUID) + } +} + +func TestGetAllVersions_Pagination(t *testing.T) { + makePage := func(n int, prefix string) []Project { + out := make([]Project, n) + for i := 0; i < n; i++ { + out[i] = Project{UUID: fmt.Sprintf("%s-%d", prefix, i), Name: "test-project", Version: fmt.Sprintf("v%d", i)} + } + return out + } + + app, _, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + page := r.URL.Query().Get("pageNumber") + w.Header().Set("Content-Type", "application/json") + switch page { + case "1": + _ = json.NewEncoder(w).Encode(makePage(50, "p1")) + case "2": + _ = json.NewEncoder(w).Encode(makePage(10, "p2")) + default: + _ = json.NewEncoder(w).Encode([]Project{}) + } + }) + + got, err := app.getAllVersions() + if err != nil { + t.Fatalf("getAllVersions: %v", err) + } + if len(got) != 60 { + t.Fatalf("expected 60 projects, got %d", len(got)) + } +} + +func TestGetAllVersions_ServerError(t *testing.T) { + app, _, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "boom", http.StatusInternalServerError) + }) + + _, err := app.getAllVersions() + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "500") { + t.Errorf("expected status 500 in error, got %q", msg) + } + if !strings.Contains(msg, "boom") { + t.Errorf("expected body 'boom' in error, got %q", msg) + } +} + +func TestGetAllVersions_HTMLResponse(t *testing.T) { + htmlBody := `302 FoundFound. Redirecting to /login` + app, _, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(htmlBody)) + }) + + _, err := app.getAllVersions() + if err == nil { + t.Fatal("expected error from HTML response") + } + msg := err.Error() + if !strings.Contains(msg, "302 Found") && !strings.Contains(msg, "") { + t.Errorf("expected body preview in error, got: %q", msg) + } + if !strings.Contains(msg, "preview") { + t.Errorf("expected 'preview' label in error, got: %q", msg) + } +} + +type patchRecord struct { + uuid string + payload map[string]interface{} +} + +func newPatchRecorder() (http.HandlerFunc, *[]patchRecord, *sync.Mutex) { + var records []patchRecord + var mu sync.Mutex + handler := func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPatch { + http.Error(w, "unexpected method", http.StatusBadRequest) + return + } + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/v1/project/"), "/") + uuid := parts[0] + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mu.Lock() + records = append(records, patchRecord{uuid: uuid, payload: payload}) + mu.Unlock() + w.WriteHeader(http.StatusOK) + } + return handler, &records, &mu +} + +func cleanTestProjects() []Project { + return []Project{ + {UUID: "u-target", Name: "test-project", Version: "v1.0.0", Active: false, IsLatest: false}, + {UUID: "u-old1", Name: "test-project", Version: "v0.9.0", Active: true, IsLatest: false}, + {UUID: "u-old2", Name: "test-project", Version: "v0.8.0", Active: false, IsLatest: false}, + } +} + +func TestUpdateLifecycle_Clean(t *testing.T) { + handler, records, mu := newPatchRecorder() + app, _, _ := newTestApp(t, handler) + + app.updateLifecycle(cleanTestProjects(), false, true) + + mu.Lock() + defer mu.Unlock() + if len(*records) != 2 { + t.Fatalf("expected 2 patches, got %d: %+v", len(*records), *records) + } + byUUID := map[string]map[string]interface{}{} + for _, r := range *records { + byUUID[r.uuid] = r.payload + } + if v, ok := byUUID["u-target"]["active"]; !ok || v != true { + t.Errorf("expected u-target active=true, got %+v", byUUID["u-target"]) + } + if v, ok := byUUID["u-old1"]["active"]; !ok || v != false { + t.Errorf("expected u-old1 active=false, got %+v", byUUID["u-old1"]) + } + if _, patched := byUUID["u-old2"]; patched { + t.Errorf("u-old2 already had active=false; should not be patched") + } +} + +func TestUpdateLifecycle_Latest(t *testing.T) { + handler, records, mu := newPatchRecorder() + app, _, _ := newTestApp(t, handler) + + projects := []Project{ + {UUID: "u-target", Name: "test-project", Version: "v1.0.0", Active: true, IsLatest: false}, + {UUID: "u-old1", Name: "test-project", Version: "v0.9.0", Active: true, IsLatest: true}, + {UUID: "u-old2", Name: "test-project", Version: "v0.8.0", Active: true, IsLatest: false}, + } + app.updateLifecycle(projects, true, false) + + mu.Lock() + defer mu.Unlock() + if len(*records) != 2 { + t.Fatalf("expected 2 patches, got %d: %+v", len(*records), *records) + } + byUUID := map[string]map[string]interface{}{} + for _, r := range *records { + byUUID[r.uuid] = r.payload + } + if v, ok := byUUID["u-target"]["isLatest"]; !ok || v != true { + t.Errorf("expected u-target isLatest=true, got %+v", byUUID["u-target"]) + } + if v, ok := byUUID["u-old1"]["isLatest"]; !ok || v != false { + t.Errorf("expected u-old1 isLatest=false, got %+v", byUUID["u-old1"]) + } + if _, patched := byUUID["u-old2"]; patched { + t.Errorf("u-old2 already had isLatest=false; should not be patched") + } +} + +func TestUpdateLifecycle_Both(t *testing.T) { + handler, records, mu := newPatchRecorder() + app, _, _ := newTestApp(t, handler) + + projects := []Project{ + {UUID: "u-target", Name: "test-project", Version: "v1.0.0", Active: false, IsLatest: false}, + {UUID: "u-old1", Name: "test-project", Version: "v0.9.0", Active: true, IsLatest: true}, + } + app.updateLifecycle(projects, true, true) + + mu.Lock() + defer mu.Unlock() + if len(*records) != 2 { + t.Fatalf("expected 2 patches, got %d: %+v", len(*records), *records) + } + byUUID := map[string]map[string]interface{}{} + for _, r := range *records { + byUUID[r.uuid] = r.payload + } + target := byUUID["u-target"] + if target["active"] != true || target["isLatest"] != true { + t.Errorf("expected target active=true, isLatest=true; got %+v", target) + } + old := byUUID["u-old1"] + if old["active"] != false || old["isLatest"] != false { + t.Errorf("expected old1 active=false, isLatest=false; got %+v", old) + } +} + +func TestUploadSBOM(t *testing.T) { + dir := t.TempDir() + sbomPath := filepath.Join(dir, "sbom.json") + sbomContent := []byte(`{"bomFormat":"CycloneDX"}`) + if err := os.WriteFile(sbomPath, sbomContent, 0o600); err != nil { + t.Fatal(err) + } + + var ( + gotPath string + gotProjectName string + gotVersion string + gotAutoCreate string + gotFileContent []byte + ) + + app, _, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + ct := r.Header.Get("Content-Type") + _, params, err := mime.ParseMediaType(ct) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mr := multipart.NewReader(r.Body, params["boundary"]) + for { + p, err := mr.NextPart() + if err == io.EOF { + break + } + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + switch p.FormName() { + case "projectName": + b, _ := io.ReadAll(p) + gotProjectName = string(b) + case "projectVersion": + b, _ := io.ReadAll(p) + gotVersion = string(b) + case "autoCreate": + b, _ := io.ReadAll(p) + gotAutoCreate = string(b) + case "bom": + gotFileContent, _ = io.ReadAll(p) + } + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"token":"abc"}`)) + }) + app.Config.SbomFile = sbomPath + + if err := app.uploadSBOM(); err != nil { + t.Fatalf("uploadSBOM: %v", err) + } + if gotPath != "/api/v1/bom" { + t.Errorf("expected POST to /api/v1/bom, got %s", gotPath) + } + if gotProjectName != "test-project" { + t.Errorf("projectName: got %q", gotProjectName) + } + if gotVersion != "v1.0.0" { + t.Errorf("projectVersion: got %q", gotVersion) + } + if gotAutoCreate != "true" { + t.Errorf("autoCreate: got %q", gotAutoCreate) + } + if !bytes.Equal(gotFileContent, sbomContent) { + t.Errorf("bom file content mismatch") + } +} + +func TestVerboseOutput(t *testing.T) { + app, buf, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + }) + app.Config.Verbose = true + + if _, err := app.getAllVersions(); err != nil { + t.Fatalf("getAllVersions: %v", err) + } + out := buf.String() + if !strings.Contains(out, "[verbose] โ†’") { + t.Errorf("expected '[verbose] โ†’' in output, got: %s", out) + } + if !strings.Contains(out, "[verbose] โ†") { + t.Errorf("expected '[verbose] โ†' in output, got: %s", out) + } + if !strings.Contains(out, "GET") { + t.Errorf("expected method GET in output, got: %s", out) + } + if !strings.Contains(out, "200 OK") { + t.Errorf("expected '200 OK' status in output, got: %s", out) + } +} + +func TestVerboseOff(t *testing.T) { + app, buf, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`[]`)) + }) + + if _, err := app.getAllVersions(); err != nil { + t.Fatalf("getAllVersions: %v", err) + } + if strings.Contains(buf.String(), "[verbose]") { + t.Errorf("expected no [verbose] output when Verbose=false; got: %s", buf.String()) + } +} + +func TestDisplayVersions(t *testing.T) { + app, buf, _ := newTestApp(t, func(w http.ResponseWriter, r *http.Request) {}) + projects := []Project{ + {UUID: "u-1", Name: "test-project", Version: "v1.0.0", Active: true, IsLatest: true, LastBomImport: 1700000000000}, + {UUID: "u-2", Name: "other-project", Version: "v9.9.9", Active: true, IsLatest: false}, + } + + app.displayVersions(projects) + + out := buf.String() + for _, want := range []string{"VERSION", "ACTIVE", "LATEST", "LAST UPLOAD", "UUID", "v1.0.0", "u-1"} { + if !strings.Contains(out, want) { + t.Errorf("expected %q in output, got: %s", want, out) + } + } + if strings.Contains(out, "other-project") || strings.Contains(out, "v9.9.9") { + t.Errorf("expected projects with different name to be filtered out; got: %s", out) + } +}