diff --git a/.githooks/pre-push b/.githooks/pre-push new file mode 100755 index 00000000..ef4dd517 --- /dev/null +++ b/.githooks/pre-push @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +# Pre-push hook: run gitleaks to detect secrets before pushing. +# Install: git config core.hooksPath .githooks +set -euo pipefail + +if ! command -v gitleaks &>/dev/null; then + echo "gitleaks not found — skipping secret scan (install from https://github.com/gitleaks/gitleaks)" + exit 0 +fi + +echo "Running gitleaks secret scan..." +gitleaks protect --staged --config .gitleaks.toml --verbose diff --git a/.github/workflows/license-check.yml b/.github/workflows/license-check.yml new file mode 100644 index 00000000..5e2caccd --- /dev/null +++ b/.github/workflows/license-check.yml @@ -0,0 +1,130 @@ +name: License Compliance Check + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + rust-licenses-contracts: + name: Rust License Check — contracts/predict-iq + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-deny-contracts-${{ hashFiles('**/Cargo.lock') }} + + - name: Install cargo-deny + run: cargo install cargo-deny --locked + + - name: Check licenses — contracts workspace + run: cargo deny check licenses + working-directory: contracts/predict-iq + + rust-licenses-api: + name: Rust License Check — services/api + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo registry + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-deny-api-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Install cargo-deny + run: cargo install cargo-deny --locked + + - name: Check licenses — services/api + run: cargo deny check licenses + working-directory: services/api + + npm-licenses-frontend: + name: NPM License Check — frontend + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: frontend/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: frontend + + - name: Install license-checker + run: npm install -g license-checker + + - name: Check licenses — frontend + run: | + license-checker \ + --onlyAllow "$(node -e " + const cfg = require('./.license-checker.json'); + process.stdout.write(cfg.onlyAllow.join(';')); + ")" \ + --excludePrivatePackages \ + --production + working-directory: frontend + + npm-licenses-tts: + name: NPM License Check — services/tts + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "18" + cache: "npm" + cache-dependency-path: services/tts/package-lock.json + + - name: Install dependencies + run: npm ci + working-directory: services/tts + + - name: Install license-checker + run: npm install -g license-checker + + - name: Check licenses — services/tts + run: | + license-checker \ + --onlyAllow "$(node -e " + const cfg = require('./.license-checker.json'); + process.stdout.write(cfg.onlyAllow.join(';')); + ")" \ + --excludePrivatePackages \ + --production + working-directory: services/tts + + all-license-checks-passed: + name: All License Checks Passed + needs: + - rust-licenses-contracts + - rust-licenses-api + - npm-licenses-frontend + - npm-licenses-tts + runs-on: ubuntu-latest + steps: + - name: Success + run: echo "All dependency licenses are compliant." diff --git a/.github/workflows/openapi-validation.yml b/.github/workflows/openapi-validation.yml new file mode 100644 index 00000000..44b4d540 --- /dev/null +++ b/.github/workflows/openapi-validation.yml @@ -0,0 +1,82 @@ +name: OpenAPI Spec Validation + +on: + push: + branches: [main, develop] + paths: + - "services/api/openapi.yaml" + - "services/api/src/**" + - "services/api/tests/openapi_contract_test.rs" + pull_request: + branches: [main, develop] + paths: + - "services/api/openapi.yaml" + - "services/api/src/**" + - "services/api/tests/openapi_contract_test.rs" + +jobs: + spectral-lint: + name: Spectral — OpenAPI structural lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: "18" + + - name: Install Spectral + run: npm install -g @stoplight/spectral-cli + + - name: Lint openapi.yaml + run: spectral lint services/api/openapi.yaml --ruleset @stoplight/spectral-oas + + - name: Fail on errors + if: failure() + run: | + echo "Spectral found structural issues in services/api/openapi.yaml." + echo "Run 'spectral lint services/api/openapi.yaml --ruleset @stoplight/spectral-oas' locally to see details." + exit 1 + + contract-tests: + name: OpenAPI Contract Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - name: Cache cargo dependencies + uses: actions/cache@v5 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + target + key: ${{ runner.os }}-cargo-api-openapi-${{ hashFiles('services/api/Cargo.lock') }} + + - name: Run OpenAPI contract tests + run: cargo test --test openapi_contract_test -- --nocapture + working-directory: services/api + + - name: Fail if spec and routes diverge + if: failure() + run: | + echo "OpenAPI contract tests failed." + echo "The spec (services/api/openapi.yaml) and SPEC_ROUTES in" + echo "services/api/tests/openapi_contract_test.rs have diverged." + echo "" + echo "To fix:" + echo " 1. If you added/removed a route in Rust: update openapi.yaml to match." + echo " 2. If you updated openapi.yaml: update SPEC_ROUTES in the contract test." + exit 1 + + all-openapi-checks-passed: + name: All OpenAPI Checks Passed + needs: [spectral-lint, contract-tests] + runs-on: ubuntu-latest + steps: + - name: Success + run: echo "OpenAPI spec is valid and in sync with routes." diff --git a/.github/workflows/terraform.yml b/.github/workflows/terraform.yml new file mode 100644 index 00000000..68e2b8a8 --- /dev/null +++ b/.github/workflows/terraform.yml @@ -0,0 +1,204 @@ +name: Terraform Plan + +on: + pull_request: + branches: [main, develop] + paths: + - "infrastructure/terraform/**" + +permissions: + contents: read + pull-requests: write + +env: + TF_WORKING_DIR: infrastructure/terraform + TF_VERSION: "1.9.0" + +jobs: + terraform-fmt: + name: Terraform Format Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Terraform fmt + id: fmt + run: terraform fmt -check -recursive + working-directory: ${{ env.TF_WORKING_DIR }} + continue-on-error: true + + - name: Post fmt failure comment + if: steps.fmt.outcome == 'failure' + uses: actions/github-script@v9 + with: + script: | + github.rest.issues.createComment({ + issue_number: context.issue.number, + owner: context.repo.owner, + repo: context.repo.repo, + body: `## Terraform Format Check Failed\n\nRun \`terraform fmt -recursive\` inside \`infrastructure/terraform/\` and commit the result.` + }) + + - name: Fail on fmt errors + if: steps.fmt.outcome == 'failure' + run: exit 1 + + terraform-validate: + name: Terraform Validate + runs-on: ubuntu-latest + strategy: + matrix: + environment: [dev, staging, production] + steps: + - uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + + - name: Terraform Init (local backend for validation) + run: terraform init -backend=false + working-directory: ${{ env.TF_WORKING_DIR }} + + - name: Terraform Validate + run: terraform validate + working-directory: ${{ env.TF_WORKING_DIR }} + + terraform-plan: + name: Terraform Plan (${{ matrix.environment }}) + runs-on: ubuntu-latest + needs: [terraform-fmt, terraform-validate] + strategy: + matrix: + environment: [dev, staging, production] + environment: ${{ matrix.environment }} + env: + TF_VAR_environment: ${{ matrix.environment }} + steps: + - uses: actions/checkout@v4 + + - name: Setup Terraform + uses: hashicorp/setup-terraform@v3 + with: + terraform_version: ${{ env.TF_VERSION }} + terraform_wrapper: true + + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} + aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + aws-region: ${{ secrets.AWS_REGION }} + + - name: Terraform Init + id: init + run: | + terraform init \ + -backend-config=backend-config.hcl \ + -backend-config="key=${{ matrix.environment }}/terraform.tfstate" + working-directory: ${{ env.TF_WORKING_DIR }} + + - name: Resolve var-file path + id: varfile + run: | + # dev uses a flat file; staging and production use a subdirectory + if [ -f "environments/${{ matrix.environment }}.tfvars" ]; then + echo "path=environments/${{ matrix.environment }}.tfvars" >> "$GITHUB_OUTPUT" + else + echo "path=environments/${{ matrix.environment }}/terraform.tfvars" >> "$GITHUB_OUTPUT" + fi + working-directory: ${{ env.TF_WORKING_DIR }} + + - name: Terraform Plan + id: plan + run: | + terraform plan \ + -var-file="${{ steps.varfile.outputs.path }}" \ + -out="${{ matrix.environment }}.tfplan" \ + -no-color \ + 2>&1 | tee plan-output.txt + working-directory: ${{ env.TF_WORKING_DIR }} + continue-on-error: true + + - name: Upload plan artifact + uses: actions/upload-artifact@v7 + with: + name: terraform-plan-${{ matrix.environment }}-${{ github.sha }} + path: ${{ env.TF_WORKING_DIR }}/${{ matrix.environment }}.tfplan + retention-days: 7 + + - name: Post plan comment + uses: actions/github-script@v9 + with: + script: | + const fs = require('fs'); + const planOutput = fs.readFileSync('${{ env.TF_WORKING_DIR }}/plan-output.txt', 'utf8'); + const outcome = '${{ steps.plan.outcome }}'; + const env = '${{ matrix.environment }}'; + const statusEmoji = outcome === 'success' ? '✅' : '❌'; + + const truncated = planOutput.length > 60000 + ? planOutput.slice(0, 60000) + '\n\n... (truncated — download the plan artifact for full output)' + : planOutput; + + const body = [ + `## ${statusEmoji} Terraform Plan — \`${env}\``, + '', + `**Status:** ${outcome} | **Commit:** ${context.sha.slice(0, 7)}`, + '', + '
Show plan', + '', + '```hcl', + truncated, + '```', + '', + '
', + '', + `> Plan artifact: \`terraform-plan-${env}-${context.sha}\` (retained 7 days)`, + ].join('\n'); + + // Find and update an existing plan comment from this workflow if present + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const marker = `## ${statusEmoji} Terraform Plan — \`${env}\``; + const existing = comments.find(c => + c.user.login === 'github-actions[bot]' && c.body.includes(`Terraform Plan — \`${env}\``) + ); + + if (existing) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: existing.id, + body, + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body, + }); + } + + - name: Fail if plan failed + if: steps.plan.outcome == 'failure' + run: exit 1 + + all-terraform-checks-passed: + name: All Terraform Checks Passed + needs: [terraform-fmt, terraform-validate, terraform-plan] + runs-on: ubuntu-latest + steps: + - name: Success + run: echo "Terraform fmt, validate, and plan all passed." diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f270b8ad..cd93b121 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -327,10 +327,14 @@ jobs: - name: Install cargo-audit run: cargo install cargo-audit - - name: Run security audit + - name: Run security audit — contracts/predict-iq run: cargo audit working-directory: contracts/predict-iq + - name: Run security audit — services/api + run: cargo audit + working-directory: services/api + sast-scanning: name: SAST Security Scanning runs-on: ubuntu-latest @@ -346,6 +350,7 @@ jobs: config: >- p/security-audit p/rust + p/nodejs p/typescript p/javascript generateSarif: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a588632b..7281887c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -267,6 +267,47 @@ Sensitive paths have designated reviewers defined in [`.github/CODEOWNERS`](.git --- +## SAST and Security Tooling + +This project enforces security scanning at every stage of the development lifecycle. + +### Tools and thresholds + +| Tool | Scope | Threshold | +|------|-------|-----------| +| [cargo-audit](https://github.com/RustSec/rustsec/tree/main/cargo-audit) | Rust dependencies (contracts + API) | Fails CI on any known CVE | +| [Semgrep](https://semgrep.dev/) | Rust, Node.js, TypeScript, JavaScript source | Fails CI on `error`-level findings; rulesets: `p/security-audit`, `p/rust`, `p/nodejs`, `p/typescript`, `p/javascript` | +| [Gitleaks](https://github.com/gitleaks/gitleaks) | Git history and staged changes | Fails CI and pre-push hook on any detected secret | +| [TruffleHog](https://github.com/trufflesecurity/trufflehog) | Git history (verified secrets only) | Fails CI on verified secrets | +| [Trivy](https://github.com/aquasecurity/trivy) | Filesystem and container images | Fails CI on `CRITICAL` or `HIGH` findings | +| [CodeQL](https://codeql.github.com/) | JavaScript, TypeScript, Rust | Fails CI on security-extended queries | + +### Local setup + +Install and activate the gitleaks pre-push hook to catch secrets before they reach CI: + +```bash +# Install gitleaks (macOS) +brew install gitleaks + +# Or on Linux +wget https://github.com/gitleaks/gitleaks/releases/latest/download/gitleaks_linux_amd64 -O gitleaks +sudo install gitleaks /usr/local/bin/ + +# Activate the project hook (run once after cloning) +git config core.hooksPath .githooks +``` + +The hook runs `gitleaks protect --staged` on every `git push`, scanning staged commits against `.gitleaks.toml`. To skip in an emergency (e.g. a false positive you've already triaged): + +```bash +SKIP=gitleaks git push +``` + +Custom rules for Stellar keys and API tokens are defined in `.gitleaks.toml`. + +--- + ## Questions? Open an issue or start a discussion on [GitHub](https://github.com/solutions-plug/predictIQ/issues). diff --git a/deny.toml b/deny.toml new file mode 100644 index 00000000..ac2fe80e --- /dev/null +++ b/deny.toml @@ -0,0 +1,54 @@ +# cargo-deny configuration for the contracts workspace (contracts/predict-iq). +# Run locally: cargo deny check licenses +# CI: .github/workflows/license-check.yml + +[licenses] +# Acceptable SPDX license identifiers. +# Any dependency under a license not listed here will fail CI. +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", + "CC0-1.0", + "Zlib", + "0BSD", +] + +# Copyleft and network-copyleft licenses that must never appear. +deny = [ + "GPL-2.0", + "GPL-3.0", + "LGPL-2.0", + "LGPL-2.1", + "LGPL-3.0", + "AGPL-3.0", + "OSL-3.0", + "EUPL-1.1", + "EUPL-1.2", +] + +# Confidence threshold for license detection (0.0 – 1.0). +confidence-threshold = 0.8 + +[advisories] +# Use the RustSec advisory database. +db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +# cargo-audit handles vulnerability checks separately; deny advisories only +# for unmaintained crates to avoid duplicating audit output. +ignore = [] + +[bans] +# Prevent multiple versions of the same crate from being compiled unless +# explicitly allowed below. +multiple-versions = "warn" + +[sources] +# Only allow crates from crates.io and the project's own git repos. +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/frontend/.license-checker.json b/frontend/.license-checker.json new file mode 100644 index 00000000..dfaaf39b --- /dev/null +++ b/frontend/.license-checker.json @@ -0,0 +1,18 @@ +{ + "onlyAllow": [ + "MIT", + "MIT*", + "ISC", + "BSD-2-Clause", + "BSD-3-Clause", + "Apache-2.0", + "Apache*", + "CC0-1.0", + "CC-BY-3.0", + "CC-BY-4.0", + "Unlicense", + "0BSD", + "Python-2.0", + "BlueOak-1.0.0" + ] +} diff --git a/services/api/deny.toml b/services/api/deny.toml new file mode 100644 index 00000000..4aca3777 --- /dev/null +++ b/services/api/deny.toml @@ -0,0 +1,45 @@ +# cargo-deny configuration for services/api. +# Run locally: cargo deny check licenses +# CI: .github/workflows/license-check.yml + +[licenses] +allow = [ + "MIT", + "Apache-2.0", + "Apache-2.0 WITH LLVM-exception", + "BSD-2-Clause", + "BSD-3-Clause", + "ISC", + "Unicode-DFS-2016", + "CC0-1.0", + "Zlib", + "0BSD", + "OpenSSL", +] + +deny = [ + "GPL-2.0", + "GPL-3.0", + "LGPL-2.0", + "LGPL-2.1", + "LGPL-3.0", + "AGPL-3.0", + "OSL-3.0", + "EUPL-1.1", + "EUPL-1.2", +] + +confidence-threshold = 0.8 + +[advisories] +db-path = "~/.cargo/advisory-db" +db-urls = ["https://github.com/rustsec/advisory-db"] +ignore = [] + +[bans] +multiple-versions = "warn" + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +allow-registry = ["https://github.com/rust-lang/crates.io-index"] diff --git a/services/api/tests/openapi_contract_test.rs b/services/api/tests/openapi_contract_test.rs index c095374b..16e9bae9 100644 --- a/services/api/tests/openapi_contract_test.rs +++ b/services/api/tests/openapi_contract_test.rs @@ -2,24 +2,28 @@ /// /// Validates that every path declared in openapi.yaml is registered in the /// Axum router, and that admin routes declare the ApiKeyAuth security scheme. +/// +/// The SPEC_ROUTES table is the authoritative mirror of openapi.yaml paths. +/// The yaml_paths_match_spec_routes test parses the YAML at test-time and +/// fails if the two diverge, preventing silent spec drift. #[cfg(test)] mod tests { use std::collections::HashSet; - /// Paths declared in openapi.yaml (method + path pairs). - /// Keep in sync with services/api/openapi.yaml. + /// All (METHOD, path) pairs declared in openapi.yaml. + /// Must stay in sync — the yaml_paths_match_spec_routes test enforces this. const SPEC_ROUTES: &[(&str, &str)] = &[ ("GET", "/health"), - ("GET", "/api/statistics"), - ("GET", "/api/markets/featured"), - ("GET", "/api/content"), - ("POST", "/api/markets/{market_id}/resolve"), - ("GET", "/api/blockchain/health"), - ("GET", "/api/blockchain/markets/{market_id}"), - ("GET", "/api/blockchain/stats"), - ("GET", "/api/blockchain/users/{user}/bets"), - ("GET", "/api/blockchain/oracle/{market_id}"), - ("GET", "/api/blockchain/tx/{tx_hash}"), + ("GET", "/api/v1/statistics"), + ("GET", "/api/v1/markets/featured"), + ("GET", "/api/v1/content"), + ("POST", "/api/v1/markets/{market_id}/resolve"), + ("GET", "/api/v1/blockchain/health"), + ("GET", "/api/v1/blockchain/markets/{market_id}"), + ("GET", "/api/v1/blockchain/stats"), + ("GET", "/api/v1/blockchain/users/{user}/bets"), + ("GET", "/api/v1/blockchain/oracle/{market_id}"), + ("GET", "/api/v1/blockchain/tx/{tx_hash}"), ("POST", "/api/v1/newsletter/subscribe"), ("GET", "/api/v1/newsletter/confirm"), ("DELETE", "/api/v1/newsletter/unsubscribe"), @@ -29,18 +33,58 @@ mod tests { ("POST", "/api/v1/email/test"), ("GET", "/api/v1/email/analytics"), ("GET", "/api/v1/email/queue/stats"), + ("POST", "/api/blockchain/replay"), + ("GET", "/api/v1/email/queue/dead-letter"), + ("POST", "/api/v1/email/queue/dead-letter/{job_id}/requeue"), + ("GET", "/api/v1/audit/logs"), + ("GET", "/api/v1/audit/statistics"), ("POST", "/webhooks/sendgrid"), ]; /// Admin routes that must declare ApiKeyAuth security in the spec. const ADMIN_ROUTES: &[(&str, &str)] = &[ - ("POST", "/api/markets/{market_id}/resolve"), + ("POST", "/api/v1/markets/{market_id}/resolve"), ("GET", "/api/v1/email/preview/{template_name}"), ("POST", "/api/v1/email/test"), ("GET", "/api/v1/email/analytics"), ("GET", "/api/v1/email/queue/stats"), + ("POST", "/api/blockchain/replay"), + ("GET", "/api/v1/email/queue/dead-letter"), + ("POST", "/api/v1/email/queue/dead-letter/{job_id}/requeue"), + ("GET", "/api/v1/audit/logs"), + ("GET", "/api/v1/audit/statistics"), ]; + const OPENAPI_YAML: &str = include_str!("../openapi.yaml"); + + /// Parse (METHOD, path) pairs directly from openapi.yaml and return them. + /// Uses line-by-line parsing so no YAML library is required in dev-deps. + fn yaml_routes() -> Vec<(String, String)> { + let methods = ["get", "post", "put", "delete", "patch"]; + let mut routes = Vec::new(); + let mut current_path: Option = None; + + for line in OPENAPI_YAML.lines() { + // Top-level path entries start with exactly two spaces then "/" + if let Some(rest) = line.strip_prefix(" /") { + if let Some(path_str) = rest.strip_suffix(':') { + current_path = Some(format!("/{}", path_str)); + continue; + } + } + // Method entries are indented four spaces + if let Some(path) = ¤t_path { + for method in methods { + let prefix = format!(" {}:", method); + if line == prefix { + routes.push((method.to_uppercase(), path.clone())); + } + } + } + } + routes + } + #[test] fn spec_routes_are_unique() { let set: HashSet<_> = SPEC_ROUTES.iter().collect(); @@ -63,60 +107,125 @@ mod tests { } } + /// Fail if openapi.yaml contains a route missing from SPEC_ROUTES. + /// This catches new endpoints added to the spec without updating the table. + #[test] + fn yaml_paths_match_spec_routes_no_missing() { + let spec_set: HashSet<(&str, &str)> = + SPEC_ROUTES.iter().map(|(m, p)| (*m, *p)).collect(); + let yaml = yaml_routes(); + let mut missing = Vec::new(); + for (method, path) in &yaml { + if !spec_set.contains(&(method.as_str(), path.as_str())) { + missing.push(format!("{} {}", method, path)); + } + } + assert!( + missing.is_empty(), + "openapi.yaml has routes not listed in SPEC_ROUTES — add them:\n {}", + missing.join("\n ") + ); + } + + /// Fail if SPEC_ROUTES lists a route not present in openapi.yaml. + /// This catches stale entries left behind when endpoints are removed from the spec. + #[test] + fn spec_routes_no_stale_entries() { + let yaml_set: HashSet<(String, String)> = yaml_routes().into_iter().collect(); + let mut stale = Vec::new(); + for (method, path) in SPEC_ROUTES { + if !yaml_set.contains(&(method.to_string(), path.to_string())) { + stale.push(format!("{} {}", method, path)); + } + } + assert!( + stale.is_empty(), + "SPEC_ROUTES has entries missing from openapi.yaml — remove them:\n {}", + stale.join("\n ") + ); + } + #[test] fn openapi_yaml_server_url_matches_default_bind() { - let yaml = include_str!("../openapi.yaml"); // Default bind is 0.0.0.0:8080; the spec server URL must use port 8080. assert!( - yaml.contains("localhost:8080"), + OPENAPI_YAML.contains(":8080"), "openapi.yaml server URL must reference port 8080 (default API_BIND_ADDR)" ); } #[test] fn openapi_yaml_declares_api_key_security_scheme() { - let yaml = include_str!("../openapi.yaml"); assert!( - yaml.contains("ApiKeyAuth"), + OPENAPI_YAML.contains("ApiKeyAuth"), "openapi.yaml must declare the ApiKeyAuth security scheme" ); assert!( - yaml.contains("X-API-Key"), + OPENAPI_YAML.contains("X-API-Key"), "ApiKeyAuth scheme must use X-API-Key header" ); } #[test] fn admin_routes_have_security_in_spec() { - let yaml = include_str!("../openapi.yaml"); - // Each admin operationId must appear alongside a security block. let admin_operation_ids = [ "resolveMarket", "emailPreview", "emailSendTest", "getEmailAnalytics", "getEmailQueueStats", + "blockchainReplay", + "getEmailDeadLetterList", + "requeueEmailDeadLetterJob", + "getAuditLogs", + "getAuditStatistics", ]; for op_id in admin_operation_ids { assert!( - yaml.contains(op_id), + OPENAPI_YAML.contains(op_id), "operationId {op_id} missing from openapi.yaml" ); } - // The spec must contain at least one security: - ApiKeyAuth: [] block. assert!( - yaml.contains("- ApiKeyAuth: []"), + OPENAPI_YAML.contains("- ApiKeyAuth: []"), "openapi.yaml must apply ApiKeyAuth security to admin routes" ); } #[test] fn api_error_schema_has_code_field() { - let yaml = include_str!("../openapi.yaml"); - // ApiError schema must include the machine-readable `code` field. assert!( - yaml.contains("code:") && yaml.contains("INTERNAL_ERROR"), + OPENAPI_YAML.contains("code:") && OPENAPI_YAML.contains("INTERNAL_ERROR"), "ApiError schema must declare a `code` field with example INTERNAL_ERROR" ); } + + /// Every path in the spec must have an operationId so CI tools and clients + /// can reference operations by stable name. + #[test] + fn every_route_has_operation_id() { + let yaml = yaml_routes(); + let total = yaml.len(); + let id_count = OPENAPI_YAML.matches("operationId:").count(); + assert_eq!( + id_count, total, + "expected {total} operationId entries (one per route), found {id_count}" + ); + } + + /// Every path in the spec must declare at least one success (2xx) response. + #[test] + fn every_route_has_success_response() { + // Count "200:", "201:", "204:" response codes; there must be at least + // one per route to satisfy the acceptance criterion. + let success_count = OPENAPI_YAML.matches("\"200\"").count() + + OPENAPI_YAML.matches("\"201\"").count() + + OPENAPI_YAML.matches("\"204\"").count(); + let route_count = yaml_routes().len(); + assert!( + success_count >= route_count, + "fewer success responses ({success_count}) than routes ({route_count}) — \ + every endpoint must document at least one 2xx response" + ); + } } diff --git a/services/tts/.license-checker.json b/services/tts/.license-checker.json new file mode 100644 index 00000000..dfaaf39b --- /dev/null +++ b/services/tts/.license-checker.json @@ -0,0 +1,18 @@ +{ + "onlyAllow": [ + "MIT", + "MIT*", + "ISC", + "BSD-2-Clause", + "BSD-3-Clause", + "Apache-2.0", + "Apache*", + "CC0-1.0", + "CC-BY-3.0", + "CC-BY-4.0", + "Unlicense", + "0BSD", + "Python-2.0", + "BlueOak-1.0.0" + ] +}