gcp-lite-rs 0.1.1

Lightweight HTTP client for Google Cloud Platform APIs
Documentation
//! Cloud DLP (Sensitive Data Protection) API client.
//!
//! Thin wrapper over generated ops. All URL construction and HTTP methods
//! are in `ops::dlp::DlpOps`. This layer adds ergonomic method signatures
//! and auto-pagination.
//!
//! Needed by GCP CIS benchmark checks:
//!   - CIS 7.4 (bq_data_classification): verify Sensitive Data Protection is
//!     configured and scanning BigQuery datasets for sensitive data.

use crate::{
    GcpHttpClient, Result,
    ops::dlp::DlpOps,
    types::dlp::{DiscoveryConfig, ProjectDataProfile},
};

/// Client for the Cloud DLP (Sensitive Data Protection) API.
pub struct DlpClient<'a> {
    ops: DlpOps<'a>,
}

impl<'a> DlpClient<'a> {
    /// Create a new Cloud DLP client.
    pub(crate) fn new(client: &'a GcpHttpClient) -> Self {
        Self {
            ops: DlpOps::new(client),
        }
    }

    // ── Discovery Configs ─────────────────────────────────────────────

    /// List Sensitive Data Protection discovery configurations for a project location (auto-paginated).
    ///
    /// Use `location = "global"` for org-level discovery configs; pass a specific region
    /// (e.g. `"us-central1"`) for regional configs.
    ///
    /// CIS 7.4: at least one RUNNING discovery configuration targeting BigQuery should exist.
    pub async fn list_discovery_configs(
        &self,
        project: &str,
        location: &str,
    ) -> Result<Vec<DiscoveryConfig>> {
        let parent = format!("projects/{}/locations/{}", project, location);
        let mut all = Vec::new();
        let mut page_token = String::new();
        loop {
            let resp = self
                .ops
                .list_discovery_configs(&parent, "100", &page_token, "")
                .await?;
            all.extend(resp.discovery_configs);
            match resp.next_page_token {
                Some(tok) if !tok.is_empty() => page_token = tok,
                _ => break,
            }
        }
        Ok(all)
    }

    // ── Project Data Profiles ─────────────────────────────────────────

    /// List project-level data profiles generated by Sensitive Data Protection scans (auto-paginated).
    ///
    /// Use `location = "global"` for the default location. Pass `filter = ""` for no filtering.
    ///
    /// CIS 7.4: presence of project data profiles indicates that Sensitive Data Protection
    /// is actively scanning resources; check `sensitivityScore` and `dataRiskLevel` for
    /// high-risk findings.
    pub async fn list_project_data_profiles(
        &self,
        project: &str,
        location: &str,
    ) -> Result<Vec<ProjectDataProfile>> {
        let parent = format!("projects/{}/locations/{}", project, location);
        let mut all = Vec::new();
        let mut page_token = String::new();
        loop {
            let resp = self
                .ops
                .list_project_data_profiles(&parent, "100", &page_token, "", "")
                .await?;
            all.extend(resp.project_data_profiles);
            match resp.next_page_token {
                Some(tok) if !tok.is_empty() => page_token = tok,
                _ => break,
            }
        }
        Ok(all)
    }
}

#[cfg(test)]
mod tests {
    use serde_json::json;

    #[tokio::test]
    async fn test_list_discovery_configs() {
        let mut mock = crate::MockClient::new();
        mock.expect_get("/v2/projects/my-project/locations/global/discoveryConfigs?pageSize=100")
            .returning_json(json!({
                "discoveryConfigs": [
                    {
                        "name": "projects/my-project/locations/global/discoveryConfigs/config-1",
                        "displayName": "BigQuery Scanner",
                        "status": "RUNNING",
                        "createTime": "2024-01-01T00:00:00Z",
                        "updateTime": "2025-01-01T00:00:00Z",
                        "lastRunTime": "2025-02-01T00:00:00Z"
                    }
                ]
            }))
            .times(1);

        let client = crate::GcpHttpClient::from_mock(mock);
        let dlp = client.dlp();
        let result = dlp.list_discovery_configs("my-project", "global").await;
        assert!(result.is_ok());
        let configs = result.unwrap();
        assert_eq!(configs.len(), 1);
        assert_eq!(
            configs[0].name,
            "projects/my-project/locations/global/discoveryConfigs/config-1"
        );
        assert_eq!(configs[0].status.as_deref(), Some("RUNNING"));
        assert_eq!(
            configs[0].last_run_time.as_deref(),
            Some("2025-02-01T00:00:00Z")
        );
    }

    #[tokio::test]
    async fn test_list_discovery_configs_paginated() {
        let mut mock = crate::MockClient::new();
        // Second page first — more specific URL before the less specific first-page prefix
        mock.expect_get(
            "/v2/projects/my-project/locations/global/discoveryConfigs?pageSize=100&pageToken=tok123",
        )
        .returning_json(json!({
            "discoveryConfigs": [{"name": "projects/my-project/locations/global/discoveryConfigs/config-2", "status": "RUNNING"}]
        }))
        .times(1);
        // First page
        mock.expect_get(
            "/v2/projects/my-project/locations/global/discoveryConfigs?pageSize=100",
        )
        .returning_json(json!({
            "discoveryConfigs": [{"name": "projects/my-project/locations/global/discoveryConfigs/config-1", "status": "RUNNING"}],
            "nextPageToken": "tok123"
        }))
        .times(1);

        let client = crate::GcpHttpClient::from_mock(mock);
        let dlp = client.dlp();
        let result = dlp.list_discovery_configs("my-project", "global").await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap().len(), 2);
    }

    #[tokio::test]
    async fn test_list_project_data_profiles() {
        let mut mock = crate::MockClient::new();
        mock.expect_get(
            "/v2/projects/my-project/locations/global/projectDataProfiles?pageSize=100",
        )
        .returning_json(json!({
            "projectDataProfiles": [
                {
                    "name": "projects/my-project/locations/global/projectDataProfiles/my-project",
                    "projectId": "my-project",
                    "profileLastGenerated": "2025-02-10T12:00:00Z",
                    "sensitivityScore": {"score": "SENSITIVITY_HIGH"},
                    "dataRiskLevel": {"score": "RISK_HIGH"},
                    "tableDataProfileCount": "42",
                    "fileStoreDataProfileCount": "5"
                }
            ]
        }))
        .times(1);

        let client = crate::GcpHttpClient::from_mock(mock);
        let dlp = client.dlp();
        let result = dlp.list_project_data_profiles("my-project", "global").await;
        assert!(result.is_ok());
        let profiles = result.unwrap();
        assert_eq!(profiles.len(), 1);
        assert_eq!(
            profiles[0].name,
            "projects/my-project/locations/global/projectDataProfiles/my-project"
        );
        assert_eq!(profiles[0].project_id.as_deref(), Some("my-project"));
        let sens = profiles[0].sensitivity_score.as_ref().unwrap();
        assert_eq!(sens.score.as_deref(), Some("SENSITIVITY_HIGH"));
        let risk = profiles[0].data_risk_level.as_ref().unwrap();
        assert_eq!(risk.score.as_deref(), Some("RISK_HIGH"));
    }

    #[tokio::test]
    async fn test_list_project_data_profiles_paginated() {
        let mut mock = crate::MockClient::new();
        // Second page first — more specific URL before the less specific first-page prefix
        mock.expect_get(
            "/v2/projects/my-project/locations/global/projectDataProfiles?pageSize=100&pageToken=tok456",
        )
        .returning_json(json!({
            "projectDataProfiles": [{"name": "projects/my-project/locations/global/projectDataProfiles/proj-2", "projectId": "proj-2"}]
        }))
        .times(1);
        // First page
        mock.expect_get(
            "/v2/projects/my-project/locations/global/projectDataProfiles?pageSize=100",
        )
        .returning_json(json!({
            "projectDataProfiles": [{"name": "projects/my-project/locations/global/projectDataProfiles/proj-1", "projectId": "proj-1"}],
            "nextPageToken": "tok456"
        }))
        .times(1);

        let client = crate::GcpHttpClient::from_mock(mock);
        let dlp = client.dlp();
        let result = dlp.list_project_data_profiles("my-project", "global").await;
        assert!(result.is_ok());
        assert_eq!(result.unwrap().len(), 2);
    }
}