From caf6e0f14bb2c4bcbb728fa4d1ee5c33172d73aa Mon Sep 17 00:00:00 2001 From: Sara Date: Thu, 30 Jul 2026 10:15:37 -0400 Subject: [PATCH] Fix category plumbing on the CLI issue commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both `fossapi get issue` and `fossapi list issues` were unusable: the FOSSA API requires a `category` query param on the issue endpoints, and neither command sent one. $ fossapi get issue 16252514 Error: Validation error: Invalid option: expected one of "licensing"|"vulnerability"|"quality" at "category" `Get for Issue` sent no query string at all, and `list` passed `IssueListQuery::default()`, whose `category` is skipped when None. A working `get_with_category` already existed but was wired only to MCP. `Issue::get` now discovers the category, probing vulnerability → licensing → quality and returning the first hit. Only a 404 advances to the next category; auth, rate-limit and server errors propagate rather than being reported as a missing issue. `get issue` takes an optional `--category` to skip the search; `list issues` requires one, matching the policy the MCP layer already enforces. The mock server validated that `category` was present but ignored its value, so the fall-through path was untestable; it is now category-aware and 404s on mismatch. `list_issues` gained the same missing-category 400 that `get_issue` already had. Also adds the remediation assertions this work was chasing. The field was in the test fixture but unasserted, and being Option + serde(default) a renamed key would have silently deserialized to None with the suite still green. Verified against the live API: auto-detect resolves issues in all three categories, an unknown ID reports not-found, and `--category` rejects bad values. Fixes tests/e2e_mock_server.rs::test_list_and_get_issues, which was failing before this change. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01YFjEuwWZkV6739mxVJjsEd --- CLAUDE.md | 2 +- README.md | 7 +- src/bin/fossapi.rs | 21 +++-- src/cli/mod.rs | 10 +++ src/mcp/server.rs | 3 +- src/mock_server/handlers/issues.rs | 41 ++++++---- src/mock_server/state.rs | 9 +++ src/models/issue.rs | 85 ++++++++++++++++++-- tests/cli_args.rs | 51 +++++++++--- tests/cli_get.rs | 122 ++++++++++++++++++++++++++++- tests/cli_list.rs | 11 ++- tests/e2e_mock_server.rs | 39 +++++---- 12 files changed, 340 insertions(+), 61 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index a1e8496..79505cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -117,7 +117,7 @@ Project (top-level container) | Project | `GET /projects/{locator}` | Single project | | Revisions | `GET /projects/{locator}/revisions` | Grouped by branch | | Dependencies | `GET /v2/revisions/{locator}/dependencies` | For a revision | -| Issues | `GET /v2/issues` | Paginated, filterable by category/project | +| Issues | `GET /v2/issues` | `category` **required**; `count` clamps to a minimum of 5 | | Issue | `GET /v2/issues/{id}` | Single issue with full details | | Snippets | `GET /revisions/{locator}/snippets` | Paginated; `pageSize` capped at 50 (`list_all` overrides) | | Snippet paths | `GET /revisions/{locator}/snippets/paths` | File/dir tree, drill in via `path` | diff --git a/README.md b/README.md index ddc6134..4034db1 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ fossapi list dependencies "custom+1/my-project\$abc123" ### Issues Issues come in three categories: `vulnerability`, `licensing`, and `quality`. +The API scopes every issue lookup to one category, so `--category` is required +when listing. ```bash # List vulnerabilities @@ -73,8 +75,11 @@ fossapi list issues --category vulnerability # List licensing issues fossapi list issues --category licensing -# Get a specific issue +# Get a specific issue; searches each category in turn fossapi get issue 12345 + +# Skip the search when you know the category +fossapi get issue 12345 --category licensing ``` ### Snippets diff --git a/src/bin/fossapi.rs b/src/bin/fossapi.rs index dd52ff5..4c41afc 100644 --- a/src/bin/fossapi.rs +++ b/src/bin/fossapi.rs @@ -5,7 +5,7 @@ use clap::Parser; use fossapi::cli::{Cli, Command, Entity, GetCommand, ListCommand}; use fossapi::{ - get_dependencies, FossaClient, Get, Issue, List, Page, PrettyPrint, Project, + get_dependencies, FossaClient, Get, Issue, IssueListQuery, List, Page, PrettyPrint, Project, ProjectUpdateParams, Revision, Snippet, SnippetListQuery, SnippetLocation, SnippetPath, Update, }; use serde::Serialize; @@ -63,8 +63,11 @@ async fn handle_get( let revision = Revision::get(client, locator).await?; output_single(&revision, json)?; } - GetCommand::Issue { id } => { - let issue = Issue::get(client, id).await?; + GetCommand::Issue { id, category } => { + let issue = match category { + Some(category) => Issue::get_with_category(client, id, category).await?, + None => Issue::get(client, id).await?, + }; output_single(&issue, json)?; } GetCommand::Snippet { revision, snippet } => { @@ -95,10 +98,18 @@ async fn handle_list( let projects = Project::list_page(client, &Default::default(), page, count).await?; output_page(&projects, json, |p| ProjectRow::from(p))?; } - ListCommand::Issues { page, count } => { + ListCommand::Issues { + page, + count, + category, + } => { let page = page.unwrap_or(1); let count = count.unwrap_or(20); - let issues = Issue::list_page(client, &Default::default(), page, count).await?; + let query = IssueListQuery { + category: Some(category), + ..Default::default() + }; + let issues = Issue::list_page(client, &query, page, count).await?; output_page(&issues, json, |i| IssueRow::from(i))?; } ListCommand::Dependencies { revision, revision_positional } => { diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 1fe19a8..6a1c881 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -4,6 +4,8 @@ use clap::{Parser, Subcommand, ValueEnum}; +use crate::IssueCategory; + /// FOSSA API command-line interface. #[derive(Parser, Debug)] #[command(name = "fossapi", about = "FOSSA API CLI", version)] @@ -80,6 +82,10 @@ pub enum GetCommand { Issue { /// The issue ID. id: u64, + + /// Issue category. Omit to search every category (up to 3 requests). + #[arg(long, value_enum)] + category: Option, }, #[command( about = "Get a snippet's details, including its matched first-party files", @@ -126,6 +132,10 @@ pub enum ListCommand { /// Number of items per page. #[arg(long)] count: Option, + + /// Issue category to list. + #[arg(long, value_enum)] + category: IssueCategory, }, /// List dependencies for a revision. #[command(alias = "dependency")] diff --git a/src/mcp/server.rs b/src/mcp/server.rs index 7e6be55..5211b69 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use crate::{ mcp::{EntityType, GetParams, ListParams, SnippetMatchParams, UpdateParams}, - DependencyListQuery, FossaClient, FossaError, Get, Issue, IssueCategory, IssueListQuery, List, + DependencyListQuery, FossaClient, FossaError, Get, Issue, IssueListQuery, List, Project, ProjectListQuery, ProjectUpdateParams, Revision, RevisionListQuery, SnippetListQuery, Update, }; @@ -411,6 +411,7 @@ impl ServerHandler for FossaServer { #[cfg(test)] mod tests { use super::*; + use crate::IssueCategory; use wiremock::matchers::{method, path, path_regex, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; diff --git a/src/mock_server/handlers/issues.rs b/src/mock_server/handlers/issues.rs index 618b0e7..8027b66 100644 --- a/src/mock_server/handlers/issues.rs +++ b/src/mock_server/handlers/issues.rs @@ -45,20 +45,11 @@ pub async fn get_issue( Path(id): Path, Query(query): Query, ) -> impl IntoResponse { - // Validate category is provided (required by FOSSA API) - if query.category.is_none() { - return ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "Validation error", - "message": "Invalid option: expected one of \"licensing\"|\"vulnerability\"|\"quality\" at \"category\"" - })), - ) - .into_response(); - } + let Some(category) = query.category.as_deref() else { + return missing_category(); + }; - // Parse the ID as u64 - let id: u64 = match id.parse() { + let id = match id.parse::() { Ok(id) => id, Err(_) => { return ( @@ -74,30 +65,46 @@ pub async fn get_issue( let state = state.read().await; - match state.get_issue(id) { + match state.get_issue_in_category(id, category) { Some(issue) => (StatusCode::OK, Json(issue.clone())).into_response(), None => ( StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Issue not found", - "message": format!("No issue found with ID: {}", id) + "message": format!("No {} issue found with ID: {}", category, id) })), ) .into_response(), } } +/// The 400 the FOSSA API returns when the required `category` param is absent. +fn missing_category() -> axum::response::Response { + ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "Validation error", + "message": "Invalid option: expected one of \"licensing\"|\"vulnerability\"|\"quality\" at \"category\"" + })), + ) + .into_response() +} + /// GET /v2/issues pub async fn list_issues( State(state): State>>, Query(query): Query, ) -> impl IntoResponse { + let Some(category) = query.category.as_deref() else { + return missing_category(); + }; + let state = state.read().await; let page = query.page.unwrap_or(1); let count = query.count.unwrap_or(20); - let all_issues = state.list_issues(query.category.as_deref()); + let all_issues = state.list_issues(Some(category)); // Apply pagination let start = ((page - 1) * count) as usize; @@ -109,5 +116,5 @@ pub async fn list_issues( vec![] }; - (StatusCode::OK, Json(ListIssuesResponse { issues })) + (StatusCode::OK, Json(ListIssuesResponse { issues })).into_response() } diff --git a/src/mock_server/state.rs b/src/mock_server/state.rs index cfb6979..9a18705 100644 --- a/src/mock_server/state.rs +++ b/src/mock_server/state.rs @@ -120,6 +120,15 @@ impl MockState { .collect() } + /// Get an issue by ID, but only if it belongs to `category`. + /// + /// The real API answers 404 when the ID exists under a different category, + /// which is what drives the category search in `Issue::get`. + pub fn get_issue_in_category(&self, id: u64, category: &str) -> Option<&Issue> { + self.get_issue(id) + .filter(|i| i.issue_type.eq_ignore_ascii_case(category)) + } + /// List all issues, optionally filtered by category. pub fn list_issues(&self, category: Option<&str>) -> Vec<&Issue> { self.issues diff --git a/src/models/issue.rs b/src/models/issue.rs index b5ab26c..6ceb950 100644 --- a/src/models/issue.rs +++ b/src/models/issue.rs @@ -5,6 +5,7 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; +use clap::ValueEnum; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; @@ -80,6 +81,51 @@ mod tests { assert_eq!(issue.exploitability.as_deref(), Some("MATURE")); assert!(issue.epss.is_some()); assert_eq!(issue.cwes, vec!["CWE-254"]); + + let remediation = issue + .remediation + .expect("Vulnerability should have remediation"); + assert_eq!(remediation.partial_fix.as_deref(), Some("1.15.4")); + assert_eq!(remediation.complete_fix.as_deref(), Some("1.16.0")); + assert_eq!(remediation.partial_fix_distance.as_deref(), Some("PATCH")); + assert_eq!(remediation.complete_fix_distance.as_deref(), Some("MAJOR")); + } + + /// Guards the camelCase mapping: every key here is one the API actually + /// sends, and each is `Option` + `#[serde(default)]`, so a rename would + /// silently deserialize to `None` rather than fail. + #[test] + fn test_issue_remediation_deserialize_all_fields() { + let json = r#"{ + "partialFix": "5.0.52", + "completeFix": "6.0.0", + "partialFixDistance": "MINOR", + "completeFixDistance": "MAJOR" + }"#; + + let remediation = + serde_json::from_str::(json).expect("Failed to deserialize"); + + assert_eq!(remediation.partial_fix.as_deref(), Some("5.0.52")); + assert_eq!(remediation.complete_fix.as_deref(), Some("6.0.0")); + assert_eq!(remediation.partial_fix_distance.as_deref(), Some("MINOR")); + assert_eq!(remediation.complete_fix_distance.as_deref(), Some("MAJOR")); + } + + #[test] + fn test_issue_without_remediation_deserializes() { + let json = r#"{ + "id": 28, + "type": "vulnerability", + "source": {"id": "npm+lodash$4.2.0"}, + "depths": {"direct": 1, "deep": 0}, + "statuses": {"active": 1, "ignored": 0}, + "projects": [] + }"#; + + let issue = serde_json::from_str::(json).expect("Failed to deserialize"); + + assert!(issue.remediation.is_none()); } #[test] @@ -669,7 +715,7 @@ pub struct IssueEpss { } /// Issue category for filtering. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, ValueEnum)] #[serde(rename_all = "lowercase")] pub enum IssueCategory { /// Security vulnerabilities. @@ -680,6 +726,15 @@ pub enum IssueCategory { Quality, } +impl IssueCategory { + /// Every category, in the order [`Issue::get`] probes them. + pub const ALL: [IssueCategory; 3] = [ + IssueCategory::Vulnerability, + IssueCategory::Licensing, + IssueCategory::Quality, + ]; +} + /// Query parameters for listing issues. #[derive(Debug, Clone, Default, Serialize)] #[serde(rename_all = "camelCase")] @@ -719,12 +774,32 @@ struct IssueListResponse { impl Get for Issue { type Id = u64; + /// Fetch an issue by ID, discovering its category. + /// + /// The API requires a category and answers `404` when the ID exists under a + /// different one, so this probes [`IssueCategory::ALL`] in order and returns + /// the first hit — up to three requests. Prefer + /// [`Issue::get_with_category`] when the category is already known. + /// + /// Only `404` advances to the next category; any other failure (auth, rate + /// limit, server error) is returned as-is rather than being reported as a + /// missing issue. #[tracing::instrument(skip(client))] async fn get(client: &FossaClient, id: Self::Id) -> Result { - let path = format!("v2/issues/{id}"); - let response = client.get(&path).await?; - let issue: Issue = response.json().await.map_err(FossaError::HttpError)?; - Ok(issue) + for category in IssueCategory::ALL { + match Issue::get_with_category(client, id, category).await { + Err(FossaError::ApiError { + status_code: Some(404), + .. + }) => continue, + result => return result, + } + } + + Err(FossaError::NotFound { + entity_type: "Issue", + id: id.to_string(), + }) } } diff --git a/tests/cli_args.rs b/tests/cli_args.rs index 3cc3d47..fdeb97a 100644 --- a/tests/cli_args.rs +++ b/tests/cli_args.rs @@ -4,6 +4,7 @@ use clap::Parser; use fossapi::cli::{Cli, Command, Entity, GetCommand, ListCommand}; +use fossapi::IssueCategory; #[test] fn test_cli_parses_get_subcommand() { @@ -92,7 +93,7 @@ fn test_entity_variants() { // Issue (get uses GetCommand with u64 id) let cli = Cli::parse_from(["fossapi", "get", "issue", "123"]); - assert!(matches!(cli.command, Command::Get { command: GetCommand::Issue { id: 123 } })); + assert!(matches!(cli.command, Command::Get { command: GetCommand::Issue { id: 123, .. } })); // Dependencies (list uses ListCommand with required revision) let cli = Cli::parse_from(["fossapi", "list", "dependencies", "loc"]); @@ -129,13 +130,32 @@ fn test_get_revision_parses_locator() { fn test_get_issue_parses_numeric_id() { let cli = Cli::parse_from(["fossapi", "get", "issue", "12345"]); match cli.command { - Command::Get { command: GetCommand::Issue { id } } => { + Command::Get { command: GetCommand::Issue { id, category } } => { assert_eq!(id, 12345u64); + assert_eq!(category, None); } _ => panic!("Expected GetCommand::Issue"), } } +#[test] +fn test_get_issue_with_category() { + let cli = Cli::parse_from(["fossapi", "get", "issue", "12345", "--category", "licensing"]); + match cli.command { + Command::Get { command: GetCommand::Issue { id, category } } => { + assert_eq!(id, 12345u64); + assert_eq!(category, Some(IssueCategory::Licensing)); + } + _ => panic!("Expected GetCommand::Issue"), + } +} + +#[test] +fn test_get_issue_rejects_unknown_category() { + let result = Cli::try_parse_from(["fossapi", "get", "issue", "12345", "--category", "bogus"]); + assert!(result.is_err(), "Expected unknown category to be rejected"); +} + // ============================================================================= // TDD Tests for ISS-10844: ListCommand type-safe parsing // ============================================================================= @@ -166,11 +186,21 @@ fn test_list_projects_with_pagination() { #[test] fn test_list_issues_parses() { - let cli = Cli::parse_from(["fossapi", "list", "issues"]); - assert!(matches!( - cli.command, - Command::List { command: ListCommand::Issues { .. } } - )); + let cli = Cli::parse_from(["fossapi", "list", "issues", "--category", "vulnerability"]); + match cli.command { + Command::List { command: ListCommand::Issues { page, count, category } } => { + assert_eq!(page, None); + assert_eq!(count, None); + assert_eq!(category, IssueCategory::Vulnerability); + } + _ => panic!("Expected ListCommand::Issues"), + } +} + +#[test] +fn test_list_issues_requires_category() { + let result = Cli::try_parse_from(["fossapi", "list", "issues"]); + assert!(result.is_err(), "Expected --category to be required"); } #[test] @@ -198,11 +228,14 @@ fn test_list_revisions_requires_project_arg() { #[test] fn test_list_issues_with_pagination() { - let cli = Cli::parse_from(["fossapi", "list", "issues", "--page", "3", "--count", "25"]); + let cli = Cli::parse_from([ + "fossapi", "list", "issues", "--page", "3", "--count", "25", "--category", "quality", + ]); match cli.command { - Command::List { command: ListCommand::Issues { page, count } } => { + Command::List { command: ListCommand::Issues { page, count, category } } => { assert_eq!(page, Some(3)); assert_eq!(count, Some(25)); + assert_eq!(category, IssueCategory::Quality); } _ => panic!("Expected ListCommand::Issues"), } diff --git a/tests/cli_get.rs b/tests/cli_get.rs index eb2f457..cfc32a4 100644 --- a/tests/cli_get.rs +++ b/tests/cli_get.rs @@ -2,8 +2,8 @@ //! //! Uses wiremock to mock the FOSSA API and test actual execution flow. -use fossapi::{FossaClient, Get, Project}; -use wiremock::matchers::{method, path}; +use fossapi::{FossaClient, FossaError, Get, Issue, IssueCategory, Project}; +use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; #[tokio::test] @@ -55,6 +55,122 @@ async fn test_get_calls_trait_method() { let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); let _ = Project::get(&client, "custom+123/test".to_string()).await; +} + +/// A 404 for `category`, mirroring what FOSSA returns when the issue exists +/// under a different category. +async fn mount_category_404(mock_server: &MockServer, category: &str) { + Mock::given(method("GET")) + .and(path("/v2/issues/500")) + .and(query_param("category", category)) + .respond_with(ResponseTemplate::new(404).set_body_json(serde_json::json!({ + "code": 2004, + "message": "Issue not found", + "name": "NotFoundError" + }))) + .mount(mock_server) + .await; +} + +fn issue_json(issue_type: &str) -> serde_json::Value { + serde_json::json!({ + "id": 500, + "type": issue_type, + "source": { "id": "npm+lodash$4.17.0" }, + "depths": { "direct": 1, "deep": 0 }, + "statuses": { "active": 1, "ignored": 0 }, + "projects": [] + }) +} + +#[tokio::test] +async fn test_get_issue_falls_through_404_to_next_category() { + let mock_server = MockServer::start().await; + + mount_category_404(&mock_server, "vulnerability").await; + Mock::given(method("GET")) + .and(path("/v2/issues/500")) + .and(query_param("category", "licensing")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json("policy_flag"))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let issue = Issue::get(&client, 500) + .await + .expect("Should fall through to the licensing category"); + + assert_eq!(issue.id, 500); + assert_eq!(issue.issue_type, "policy_flag"); +} + +#[tokio::test] +async fn test_get_issue_not_found_in_any_category() { + let mock_server = MockServer::start().await; + + for category in ["vulnerability", "licensing", "quality"] { + mount_category_404(&mock_server, category).await; + } + + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let err = Issue::get(&client, 500) + .await + .expect_err("Should report the issue as missing"); + + assert!( + matches!(err, FossaError::NotFound { entity_type: "Issue", ref id } if id == "500"), + "Expected NotFound, got: {err:?}" + ); +} + +#[tokio::test] +async fn test_get_issue_propagates_non_404_errors() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v2/issues/500")) + .and(query_param("category", "vulnerability")) + .respond_with(ResponseTemplate::new(500).set_body_json(serde_json::json!({ + "message": "Internal server error" + }))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let err = Issue::get(&client, 500) + .await + .expect_err("A 500 should not be swallowed"); + + assert!( + matches!( + err, + FossaError::ApiError { + status_code: Some(500), + .. + } + ), + "Expected the 500 to propagate, got: {err:?}" + ); +} + +#[tokio::test] +async fn test_get_issue_with_category_makes_one_request() { + let mock_server = MockServer::start().await; + + Mock::given(method("GET")) + .and(path("/v2/issues/500")) + .and(query_param("category", "quality")) + .respond_with(ResponseTemplate::new(200).set_body_json(issue_json("outdated_dependency"))) + .expect(1) + .mount(&mock_server) + .await; + + let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); + let issue = Issue::get_with_category(&client, 500, IssueCategory::Quality) + .await + .expect("Failed to get issue"); - // wiremock verifies the expectation on MockServer drop + assert_eq!(issue.issue_type, "outdated_dependency"); } diff --git a/tests/cli_list.rs b/tests/cli_list.rs index 337e608..d2fd04f 100644 --- a/tests/cli_list.rs +++ b/tests/cli_list.rs @@ -2,7 +2,7 @@ //! //! Uses wiremock to mock the FOSSA API and test actual execution flow. -use fossapi::{get_dependencies, FossaClient, Issue, List, Project}; +use fossapi::{get_dependencies, FossaClient, Issue, IssueCategory, IssueListQuery, List, Project}; use wiremock::matchers::{method, path, query_param}; use wiremock::{Mock, MockServer, ResponseTemplate}; @@ -116,15 +116,20 @@ async fn test_list_issues_returns_page() { Mock::given(method("GET")) .and(path("/v2/issues")) + .and(query_param("category", "vulnerability")) .respond_with(ResponseTemplate::new(200).set_body_json(&response)) .expect(1) .mount(&mock_server) .await; let client = FossaClient::new("test-token", &mock_server.uri()).unwrap(); - let page = Issue::list_page(&client, &Default::default(), 1, 20) + let query = IssueListQuery { + category: Some(IssueCategory::Vulnerability), + ..Default::default() + }; + let page = Issue::list_page(&client, &query, 1, 20) .await - .unwrap(); + .expect("Failed to list issues"); assert_eq!(page.items.len(), 2); assert_eq!(page.items[0].id, 1); diff --git a/tests/e2e_mock_server.rs b/tests/e2e_mock_server.rs index 4e0ec3c..8035835 100644 --- a/tests/e2e_mock_server.rs +++ b/tests/e2e_mock_server.rs @@ -7,9 +7,18 @@ use fossapi::mock_server::{Fixtures, MockServer, MockState}; use fossapi::{ - get_dependencies, FossaClient, Get, Issue, List, Project, Revision, Update, + get_dependencies, FossaClient, Get, Issue, IssueCategory, IssueListQuery, List, Project, + Revision, Update, }; +/// A list query scoped to one category, which the API requires. +fn issues_in(category: IssueCategory) -> IssueListQuery { + IssueListQuery { + category: Some(category), + ..Default::default() + } +} + // ============================================================================= // Server Lifecycle Tests // ============================================================================= @@ -190,7 +199,7 @@ async fn test_list_and_get_issues() { let client = FossaClient::new("test-token", server.url()).unwrap(); // Step 1: List all issues - let page = Issue::list_page(&client, &Default::default(), 1, 20) + let page = Issue::list_page(&client, &issues_in(IssueCategory::Vulnerability), 1, 20) .await .expect("Failed to list issues"); @@ -213,24 +222,22 @@ async fn test_issues_have_correct_types() { let server = MockServer::start().await; let client = FossaClient::new("test-token", server.url()).unwrap(); - let page = Issue::list_page(&client, &Default::default(), 1, 100) + let vulns = Issue::list_page(&client, &issues_in(IssueCategory::Vulnerability), 1, 100) .await - .expect("Failed to list issues"); - - // Default fixture should have both vulnerability and licensing issues - let vuln_issues: Vec<_> = page.items.iter().filter(|i| i.is_vulnerability()).collect(); - let license_issues: Vec<_> = page.items.iter().filter(|i| i.is_licensing()).collect(); + .expect("Failed to list vulnerability issues"); + let licenses = Issue::list_page(&client, &issues_in(IssueCategory::Licensing), 1, 100) + .await + .expect("Failed to list licensing issues"); - assert!(!vuln_issues.is_empty(), "Expected vulnerability issues"); - assert!(!license_issues.is_empty(), "Expected licensing issues"); + assert!(!vulns.items.is_empty(), "Expected vulnerability issues"); + assert!(!licenses.items.is_empty(), "Expected licensing issues"); - // Vulnerability issues should have CVE - for issue in vuln_issues { + for issue in &vulns.items { + assert!(issue.is_vulnerability(), "Expected a vulnerability issue"); assert!(issue.cve.is_some(), "Vulnerability should have CVE"); } - // Licensing issues should have license - for issue in license_issues { + for issue in &licenses.items { assert!(issue.license.is_some(), "Licensing issue should have license"); } @@ -281,7 +288,7 @@ async fn test_full_project_analysis_workflow() { } // Step 5: Check for issues - let issues = Issue::list_page(&client, &Default::default(), 1, 100) + let issues = Issue::list_page(&client, &issues_in(IssueCategory::Vulnerability), 1, 100) .await .expect("Failed to list issues"); // Issues exist in our test data @@ -342,7 +349,7 @@ async fn test_empty_server_returns_empty_lists() { assert!(projects.items.is_empty()); assert_eq!(projects.total, Some(0)); - let issues = Issue::list_page(&client, &Default::default(), 1, 100) + let issues = Issue::list_page(&client, &issues_in(IssueCategory::Vulnerability), 1, 100) .await .expect("Failed to list issues");