feat(onboarding): unified multi-target model + scan matrix foundation (#134)
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 7m36s
CI / Deploy Dashboard (push) Successful in 7m9s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m44s
CI / Check (push) Has been skipped
CI / Detect Changes (push) Successful in 5s
CI / Deploy Agent (push) Successful in 7m36s
CI / Deploy Dashboard (push) Successful in 7m9s
CI / Deploy Docs (push) Has been skipped
CI / Deploy MCP (push) Successful in 1m44s
This commit was merged in pull request #134.
This commit is contained in:
@@ -0,0 +1,587 @@
|
||||
//! The unified onboarding model.
|
||||
//!
|
||||
//! An [`OnboardedTarget`] is the single source of truth for anything the scanner
|
||||
//! can analyze. It records *what kind of software* the target is ([`TargetType`]),
|
||||
//! the concrete [`Artifact`]s that were provided for it (a git repo, a firmware
|
||||
//! image, a live URL, a PLC project, ...), the classifier's verdict, and the scan
|
||||
//! configuration. It replaces the older git-only `TrackedRepository` and the
|
||||
//! standalone `DastTarget`, both of which fold into this type as artifacts.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::dast::{DastAuthConfig, DastTargetType};
|
||||
use super::issue::TrackerType;
|
||||
use super::pentest::{Environment, PentestConfig, PentestStrategy};
|
||||
use super::scan::ScanType;
|
||||
|
||||
/// The family of software a target belongs to.
|
||||
///
|
||||
/// Targets look endlessly varied but fall into a small enumerable set classified
|
||||
/// by where the analyzable signal lives. This drives the scan-applicability
|
||||
/// matrix and the onboarding wizard's type selection.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum TargetType {
|
||||
/// Browser-facing web application (front end + server).
|
||||
WebApp,
|
||||
/// Headless backend service / API (REST, GraphQL, gRPC).
|
||||
BackendService,
|
||||
/// Desktop application (Windows/macOS/Linux GUI or CLI binary).
|
||||
DesktopApp,
|
||||
/// Android application (APK / AAB).
|
||||
AndroidApp,
|
||||
/// iOS application (IPA).
|
||||
IosApp,
|
||||
/// Bare-metal embedded firmware (no operating system).
|
||||
FirmwareBareMetal,
|
||||
/// Embedded firmware running on an RTOS (Zephyr, FreeRTOS, ...).
|
||||
FirmwareRtos,
|
||||
/// Embedded Linux built with Yocto / OpenEmbedded (BSP + image).
|
||||
EmbeddedLinuxYocto,
|
||||
/// Programmable logic controller software (IEC 61131-3, PLCopen / SPS).
|
||||
PlcSps,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for TargetType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::WebApp => write!(f, "web_app"),
|
||||
Self::BackendService => write!(f, "backend_service"),
|
||||
Self::DesktopApp => write!(f, "desktop_app"),
|
||||
Self::AndroidApp => write!(f, "android_app"),
|
||||
Self::IosApp => write!(f, "ios_app"),
|
||||
Self::FirmwareBareMetal => write!(f, "firmware_bare_metal"),
|
||||
Self::FirmwareRtos => write!(f, "firmware_rtos"),
|
||||
Self::EmbeddedLinuxYocto => write!(f, "embedded_linux_yocto"),
|
||||
Self::PlcSps => write!(f, "plc_sps"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The kind of artifact provided for a target.
|
||||
///
|
||||
/// Which scans are possible is a function of the target type *and* which of
|
||||
/// these are present (SAST needs code, DAST needs a running URL, and so on).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ArtifactKind {
|
||||
/// A git repository (cloned for static analysis).
|
||||
GitRepo,
|
||||
/// A source archive (zip / tarball) with no live git remote.
|
||||
SourceArchive,
|
||||
/// A firmware image or binary blob.
|
||||
FirmwareImage,
|
||||
/// A mobile package: Android APK/AAB or iOS IPA.
|
||||
MobilePackage,
|
||||
/// An OCI/Docker container image reference.
|
||||
ContainerImage,
|
||||
/// A reachable running instance (base URL / endpoint) for dynamic testing.
|
||||
LiveUrl,
|
||||
/// A PLC project: PLCopen XML or Structured Text source.
|
||||
PlcProject,
|
||||
/// Free-form plaintext describing the target (feeds classification only).
|
||||
PlaintextDescription,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ArtifactKind {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::GitRepo => write!(f, "git_repo"),
|
||||
Self::SourceArchive => write!(f, "source_archive"),
|
||||
Self::FirmwareImage => write!(f, "firmware_image"),
|
||||
Self::MobilePackage => write!(f, "mobile_package"),
|
||||
Self::ContainerImage => write!(f, "container_image"),
|
||||
Self::LiveUrl => write!(f, "live_url"),
|
||||
Self::PlcProject => write!(f, "plc_project"),
|
||||
Self::PlaintextDescription => write!(f, "plaintext_description"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Credentials attached to an artifact.
|
||||
///
|
||||
/// This folds both `TrackedRepository`'s git auth (`auth_token` / `auth_username`
|
||||
/// / SSH key) and `DastAuthConfig`'s HTTP auth (form / bearer / cookie) into one
|
||||
/// shape so a single artifact carries whatever it needs to be fetched or probed.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct ArtifactAuth {
|
||||
/// Auth method: `none` | `token` | `basic` | `bearer` | `cookie` | `form` | `ssh`.
|
||||
#[serde(default)]
|
||||
pub method: String,
|
||||
/// Username (git user, basic-auth user, or `x-access-token` for PATs).
|
||||
pub username: Option<String>,
|
||||
/// The secret credential: PAT, password, or bearer token. Encrypted at rest.
|
||||
pub secret: Option<String>,
|
||||
/// Path to an SSH private key for git-over-SSH.
|
||||
pub ssh_key_path: Option<String>,
|
||||
/// Login URL for form-based authentication.
|
||||
pub login_url: Option<String>,
|
||||
/// Extra headers to send when authenticating / probing.
|
||||
pub headers: Option<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl From<DastAuthConfig> for ArtifactAuth {
|
||||
fn from(c: DastAuthConfig) -> Self {
|
||||
Self {
|
||||
method: c.method,
|
||||
username: c.username,
|
||||
// Prefer a bearer token; otherwise fall back to the password.
|
||||
secret: c.token.or(c.password),
|
||||
ssh_key_path: None,
|
||||
login_url: c.login_url,
|
||||
headers: c.headers,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Git-specific configuration for a [`ArtifactKind::GitRepo`] artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GitArtifactConfig {
|
||||
/// Branch to scan.
|
||||
pub default_branch: String,
|
||||
/// Commit SHA of the last completed scan (change-detection watermark).
|
||||
pub last_scanned_commit: Option<String>,
|
||||
/// Local clone path once the repo has been fetched.
|
||||
pub local_path: Option<String>,
|
||||
}
|
||||
|
||||
impl GitArtifactConfig {
|
||||
/// Config for a fresh git artifact on the given branch.
|
||||
pub fn on_branch(branch: impl Into<String>) -> Self {
|
||||
Self {
|
||||
default_branch: branch.into(),
|
||||
last_scanned_commit: None,
|
||||
local_path: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for GitArtifactConfig {
|
||||
fn default() -> Self {
|
||||
Self::on_branch("main")
|
||||
}
|
||||
}
|
||||
|
||||
/// Dynamic-analysis configuration for a [`ArtifactKind::LiveUrl`] artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct WebArtifactConfig {
|
||||
/// Whether the endpoint is a web app, REST API, or GraphQL API.
|
||||
pub target_kind: DastTargetType,
|
||||
/// URL paths to exclude from crawling / scanning.
|
||||
#[serde(default)]
|
||||
pub excluded_paths: Vec<String>,
|
||||
/// Maximum crawl depth.
|
||||
pub max_crawl_depth: u32,
|
||||
/// Rate limit in requests per second.
|
||||
pub rate_limit: u32,
|
||||
/// Whether destructive methods (DELETE / PUT) are permitted.
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
}
|
||||
|
||||
impl Default for WebArtifactConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
target_kind: DastTargetType::WebApp,
|
||||
excluded_paths: Vec::new(),
|
||||
max_crawl_depth: 3,
|
||||
rate_limit: 10,
|
||||
allow_destructive: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The source format of a PLC project artifact.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum PlcFormat {
|
||||
/// PLCopen XML project export.
|
||||
PlcopenXml,
|
||||
/// IEC 61131-3 Structured Text source.
|
||||
StructuredText,
|
||||
}
|
||||
|
||||
/// PLC-specific configuration for a [`ArtifactKind::PlcProject`] artifact.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlcArtifactConfig {
|
||||
/// The project source format.
|
||||
pub format: PlcFormat,
|
||||
}
|
||||
|
||||
/// A single fact discovered about a target by ingest or classification
|
||||
/// (e.g. `language=rust`, `build_system=cmake`, `mcu=stm32f429`).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DetectedFact {
|
||||
/// The fact name.
|
||||
pub key: String,
|
||||
/// The fact value.
|
||||
pub value: String,
|
||||
/// What produced the fact (e.g. `tramiton`, `language-fingerprint`).
|
||||
pub source: String,
|
||||
}
|
||||
|
||||
impl DetectedFact {
|
||||
/// Build a fact from its parts.
|
||||
pub fn new(
|
||||
key: impl Into<String>,
|
||||
value: impl Into<String>,
|
||||
source: impl Into<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
key: key.into(),
|
||||
value: value.into(),
|
||||
source: source.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One concrete thing provided for a target: code, a binary, a URL, etc.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Artifact {
|
||||
/// Stable per-artifact id (UUID v4) — scan steps reference this.
|
||||
pub id: String,
|
||||
/// What kind of artifact this is.
|
||||
pub kind: ArtifactKind,
|
||||
/// The source reference: git URL, blob id, live URL, or image ref.
|
||||
pub source_ref: String,
|
||||
/// Optional human-friendly label.
|
||||
pub display_name: Option<String>,
|
||||
/// Content-addressed storage path once ingested (blobs only).
|
||||
pub stored_path: Option<String>,
|
||||
/// SHA-256 of the ingested content (git artifacts store the head SHA).
|
||||
pub content_hash: Option<String>,
|
||||
/// Size of the stored blob in bytes.
|
||||
pub size_bytes: Option<u64>,
|
||||
/// Credentials for fetching or probing this artifact.
|
||||
pub auth: Option<ArtifactAuth>,
|
||||
/// Git configuration (present for [`ArtifactKind::GitRepo`]).
|
||||
pub git: Option<GitArtifactConfig>,
|
||||
/// Dynamic-analysis configuration (present for [`ArtifactKind::LiveUrl`]).
|
||||
pub web: Option<WebArtifactConfig>,
|
||||
/// PLC configuration (present for [`ArtifactKind::PlcProject`]).
|
||||
pub plc: Option<PlcArtifactConfig>,
|
||||
/// Facts discovered about this artifact by ingest / classification.
|
||||
#[serde(default)]
|
||||
pub detected: Vec<DetectedFact>,
|
||||
/// When this artifact was last ingested.
|
||||
#[serde(default, with = "super::serde_helpers::opt_bson_datetime")]
|
||||
pub ingested_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl Artifact {
|
||||
/// A bare artifact of the given kind and source reference.
|
||||
fn bare(kind: ArtifactKind, source_ref: impl Into<String>) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
kind,
|
||||
source_ref: source_ref.into(),
|
||||
display_name: None,
|
||||
stored_path: None,
|
||||
content_hash: None,
|
||||
size_bytes: None,
|
||||
auth: None,
|
||||
git: None,
|
||||
web: None,
|
||||
plc: None,
|
||||
detected: Vec::new(),
|
||||
ingested_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A git-repository artifact tracking the given branch.
|
||||
pub fn git_repo(url: impl Into<String>, branch: impl Into<String>) -> Self {
|
||||
let mut a = Self::bare(ArtifactKind::GitRepo, url);
|
||||
a.git = Some(GitArtifactConfig::on_branch(branch));
|
||||
a
|
||||
}
|
||||
|
||||
/// A live-URL artifact with default crawl settings.
|
||||
pub fn live_url(url: impl Into<String>) -> Self {
|
||||
let mut a = Self::bare(ArtifactKind::LiveUrl, url);
|
||||
a.web = Some(WebArtifactConfig::default());
|
||||
a
|
||||
}
|
||||
|
||||
/// A firmware-image artifact referenced by name (blob ingested later).
|
||||
pub fn firmware_image(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::FirmwareImage, source_ref)
|
||||
}
|
||||
|
||||
/// A source-archive artifact referenced by name (blob ingested later).
|
||||
pub fn source_archive(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::SourceArchive, source_ref)
|
||||
}
|
||||
|
||||
/// A mobile-package artifact (APK/AAB/IPA) referenced by name.
|
||||
pub fn mobile_package(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::MobilePackage, source_ref)
|
||||
}
|
||||
|
||||
/// A container-image artifact referenced by OCI ref.
|
||||
pub fn container_image(source_ref: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::ContainerImage, source_ref)
|
||||
}
|
||||
|
||||
/// A PLC-project artifact in the given format.
|
||||
pub fn plc_project(source_ref: impl Into<String>, format: PlcFormat) -> Self {
|
||||
let mut a = Self::bare(ArtifactKind::PlcProject, source_ref);
|
||||
a.plc = Some(PlcArtifactConfig { format });
|
||||
a
|
||||
}
|
||||
|
||||
/// A plaintext-description artifact (classification input only).
|
||||
pub fn plaintext(text: impl Into<String>) -> Self {
|
||||
Self::bare(ArtifactKind::PlaintextDescription, text)
|
||||
}
|
||||
}
|
||||
|
||||
/// One ranked candidate produced by the classifier.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TargetTypeCandidate {
|
||||
/// The candidate target type.
|
||||
pub target_type: TargetType,
|
||||
/// Confidence in `[0.0, 1.0]`.
|
||||
pub confidence: f32,
|
||||
/// Why this candidate was proposed.
|
||||
pub rationale: String,
|
||||
}
|
||||
|
||||
/// The classifier's verdict for a target: a suggested type plus ranked
|
||||
/// alternatives and the facts the decision rested on.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Classification {
|
||||
/// The top-ranked target type.
|
||||
pub suggested: TargetType,
|
||||
/// All candidates, sorted by descending confidence.
|
||||
#[serde(default)]
|
||||
pub candidates: Vec<TargetTypeCandidate>,
|
||||
/// Facts gathered during classification.
|
||||
#[serde(default)]
|
||||
pub facts: Vec<DetectedFact>,
|
||||
/// Which classifiers contributed (e.g. `["tramiton", "language-fingerprint"]`).
|
||||
#[serde(default)]
|
||||
pub detected_by: Vec<String>,
|
||||
/// When classification ran.
|
||||
#[serde(with = "super::serde_helpers::bson_datetime")]
|
||||
pub detected_at: DateTime<Utc>,
|
||||
/// Whether a human confirmed the suggestion.
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
}
|
||||
|
||||
/// Issue-tracker linkage, migrated from `TrackedRepository`'s `tracker_*` fields.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct IssueTrackerConfig {
|
||||
/// The tracker platform.
|
||||
pub tracker_type: Option<TrackerType>,
|
||||
/// Tracker owner / organization.
|
||||
pub owner: Option<String>,
|
||||
/// Tracker repository / project.
|
||||
pub repo: Option<String>,
|
||||
/// Per-target tracker access token.
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
/// How a target should be scanned.
|
||||
///
|
||||
/// `enabled_scans` / `disabled_scans` override the scan-applicability matrix
|
||||
/// defaults; the pentest and tracker blocks reuse the existing wizard config.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct TargetScanConfig {
|
||||
/// Scans explicitly turned on (empty means "use matrix defaults").
|
||||
#[serde(default)]
|
||||
pub enabled_scans: Vec<ScanType>,
|
||||
/// Scans explicitly turned off.
|
||||
#[serde(default)]
|
||||
pub disabled_scans: Vec<ScanType>,
|
||||
/// Target environment (gates destructive / active testing).
|
||||
#[serde(default)]
|
||||
pub environment: Environment,
|
||||
/// Whether destructive tests are permitted for this target.
|
||||
#[serde(default)]
|
||||
pub allow_destructive: bool,
|
||||
/// Pentest strategy selector.
|
||||
pub strategy: Option<PentestStrategy>,
|
||||
/// Full pentest wizard configuration.
|
||||
pub pentest: Option<PentestConfig>,
|
||||
/// Issue-tracker linkage.
|
||||
pub issue_tracker: Option<IssueTrackerConfig>,
|
||||
}
|
||||
|
||||
/// A target onboarded for scanning: the unified replacement for the legacy
|
||||
/// `TrackedRepository` (SAST) and `DastTarget` (DAST) records.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OnboardedTarget {
|
||||
/// Mongo id. Preserved from the legacy record during migration so every
|
||||
/// downstream collection keyed by `repo_id` / `target_id` keeps resolving.
|
||||
#[serde(rename = "_id", skip_serializing_if = "Option::is_none")]
|
||||
pub id: Option<bson::oid::ObjectId>,
|
||||
/// Human-friendly name.
|
||||
#[serde(default)]
|
||||
pub name: String,
|
||||
/// The software family this target belongs to.
|
||||
pub target_type: TargetType,
|
||||
/// Optional free-form description (also a classification input).
|
||||
pub description: Option<String>,
|
||||
/// The artifacts provided for this target.
|
||||
#[serde(default)]
|
||||
pub artifacts: Vec<Artifact>,
|
||||
/// The classifier's verdict, once run.
|
||||
pub classification: Option<Classification>,
|
||||
/// How this target should be scanned.
|
||||
#[serde(default)]
|
||||
pub scan_config: TargetScanConfig,
|
||||
/// Cron schedule for recurring scans, if any.
|
||||
pub scan_schedule: Option<String>,
|
||||
/// Whether inbound webhooks are enabled for this target.
|
||||
#[serde(default)]
|
||||
pub webhook_enabled: bool,
|
||||
/// HMAC secret for verifying inbound webhooks.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub webhook_secret: Option<String>,
|
||||
/// Cached count of findings across this target's scans.
|
||||
#[serde(default)]
|
||||
pub findings_count: u32,
|
||||
/// Creation timestamp.
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
)]
|
||||
pub created_at: DateTime<Utc>,
|
||||
/// Last-update timestamp.
|
||||
#[serde(
|
||||
default = "chrono::Utc::now",
|
||||
with = "super::serde_helpers::bson_datetime"
|
||||
)]
|
||||
pub updated_at: DateTime<Utc>,
|
||||
}
|
||||
|
||||
impl OnboardedTarget {
|
||||
/// A new target of the given type with a freshly generated webhook secret.
|
||||
pub fn new(name: String, target_type: TargetType) -> Self {
|
||||
let now = Utc::now();
|
||||
let webhook_secret = uuid::Uuid::new_v4().to_string().replace('-', "");
|
||||
Self {
|
||||
id: None,
|
||||
name,
|
||||
target_type,
|
||||
description: None,
|
||||
artifacts: Vec::new(),
|
||||
classification: None,
|
||||
scan_config: TargetScanConfig::default(),
|
||||
scan_schedule: None,
|
||||
webhook_enabled: false,
|
||||
webhook_secret: Some(webhook_secret),
|
||||
findings_count: 0,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
}
|
||||
}
|
||||
|
||||
/// The first artifact of the given kind, if present.
|
||||
pub fn first_of(&self, kind: ArtifactKind) -> Option<&Artifact> {
|
||||
self.artifacts.iter().find(|a| a.kind == kind)
|
||||
}
|
||||
|
||||
/// Whether the target has at least one artifact of the given kind.
|
||||
pub fn has(&self, kind: ArtifactKind) -> bool {
|
||||
self.artifacts.iter().any(|a| a.kind == kind)
|
||||
}
|
||||
|
||||
/// The primary code artifact (git repo or source archive), if any.
|
||||
pub fn code_artifact(&self) -> Option<&Artifact> {
|
||||
self.artifacts
|
||||
.iter()
|
||||
.find(|a| matches!(a.kind, ArtifactKind::GitRepo | ArtifactKind::SourceArchive))
|
||||
}
|
||||
|
||||
/// The live-URL artifact, if any.
|
||||
pub fn live_url(&self) -> Option<&Artifact> {
|
||||
self.first_of(ArtifactKind::LiveUrl)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[allow(clippy::expect_used, clippy::unwrap_used)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn sample_target() -> OnboardedTarget {
|
||||
let mut t = OnboardedTarget::new("acme-web".to_string(), TargetType::WebApp);
|
||||
t.artifacts.push(Artifact::git_repo(
|
||||
"https://git.example.com/acme.git",
|
||||
"main",
|
||||
));
|
||||
t.artifacts
|
||||
.push(Artifact::live_url("https://acme.example.com"));
|
||||
t
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn onboarded_target_bson_round_trip() {
|
||||
let t = sample_target();
|
||||
let b = bson::to_bson(&t).expect("serialize");
|
||||
let back: OnboardedTarget = bson::from_bson(b.clone()).expect("deserialize");
|
||||
let b2 = bson::to_bson(&back).expect("re-serialize");
|
||||
assert_eq!(b, b2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enum_display_is_snake_case() {
|
||||
assert_eq!(
|
||||
TargetType::FirmwareBareMetal.to_string(),
|
||||
"firmware_bare_metal"
|
||||
);
|
||||
assert_eq!(TargetType::PlcSps.to_string(), "plc_sps");
|
||||
assert_eq!(ArtifactKind::PlcProject.to_string(), "plc_project");
|
||||
assert_eq!(ArtifactKind::MobilePackage.to_string(), "mobile_package");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn helpers_locate_artifacts() {
|
||||
let t = sample_target();
|
||||
assert!(t.has(ArtifactKind::GitRepo));
|
||||
assert!(t.live_url().is_some());
|
||||
assert!(t.code_artifact().is_some());
|
||||
assert!(!t.has(ArtifactKind::FirmwareImage));
|
||||
assert_eq!(
|
||||
t.first_of(ArtifactKind::GitRepo).map(|a| a.kind),
|
||||
Some(ArtifactKind::GitRepo)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_target_generates_webhook_secret() {
|
||||
let t = OnboardedTarget::new("t".to_string(), TargetType::BackendService);
|
||||
let secret = t.webhook_secret.expect("secret present");
|
||||
assert_eq!(secret.len(), 32);
|
||||
assert!(!secret.contains('-'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dast_auth_folds_into_artifact_auth() {
|
||||
let dast = DastAuthConfig {
|
||||
method: "bearer".to_string(),
|
||||
login_url: Some("https://x/login".to_string()),
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pw".to_string()),
|
||||
token: Some("tok".to_string()),
|
||||
headers: None,
|
||||
};
|
||||
let auth = ArtifactAuth::from(dast);
|
||||
assert_eq!(auth.method, "bearer");
|
||||
// Bearer token wins over password.
|
||||
assert_eq!(auth.secret.as_deref(), Some("tok"));
|
||||
assert_eq!(auth.login_url.as_deref(), Some("https://x/login"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn each_artifact_gets_a_unique_id() {
|
||||
let a = Artifact::firmware_image("fw.bin");
|
||||
let b = Artifact::firmware_image("fw.bin");
|
||||
assert_ne!(a.id, b.id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user