mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 14:38:51 +00:00
feat: add Vault PKI and DigiCert CertCentral issuer connectors (M32 + M37)
Vault PKI: synchronous issuance via /v1/{mount}/sign/{role}, token auth,
revocation, CA cert retrieval, 14 tests. DigiCert CertCentral: async order
model (submit → poll → download), X-DC-DEVKEY auth, OV/EV support, PEM
bundle parsing, 16 tests. Both conditionally registered based on env vars.
Includes OpenAPI enum updates, seed data, connector docs, architecture docs,
README badges, and testing guide sign-off (Parts 38 + 39, 12 automated
smoke test assertions all passing).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -570,7 +570,7 @@ type Connector interface {
|
||||
}
|
||||
```
|
||||
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), and **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||
Built-in issuers: **Local CA** (self-signed or sub-CA mode using `crypto/x509`), **ACME v2** (HTTP-01, DNS-01, and DNS-PERSIST-01 challenges, compatible with Let's Encrypt, ZeroSSL, Sectigo, Google Trust Services, and any ACME-compliant CA), **step-ca** (Smallstep private CA via native /sign API with JWK provisioner auth), **OpenSSL/Custom CA** (script-based signing delegating to user-provided shell scripts), **Vault PKI** (HashiCorp Vault's PKI secrets engine via /sign API with token auth), and **DigiCert** (commercial CA via CertCentral REST API with async order processing). The ACME connector uses `golang.org/x/crypto/acme`, generates an ECDSA P-256 account key, handles account registration with ToS acceptance and optional External Account Binding (EAB) for CAs that require it (ZeroSSL, Google Trust Services, SSL.com), order creation, challenge solving (HTTP-01 via built-in server, DNS-01 via script-based hooks, DNS-PERSIST-01 via standing TXT records with auto-fallback to DNS-01), order finalization, and DER-to-PEM chain conversion. For ZeroSSL, EAB credentials are auto-fetched from ZeroSSL's public API when the directory URL is detected as ZeroSSL and no EAB credentials are provided — zero-friction onboarding with no dashboard visit required.
|
||||
|
||||
**ACME Renewal Information (ARI, RFC 9702):** The ACME connector supports CA-directed renewal timing via the `GetRenewalInfo()` method. Instead of using fixed thresholds (e.g., renew 30 days before expiry), the CA tells certctl when to renew by providing a `suggestedWindow` with start and end times. This is useful for distributing renewal load during maintenance windows and coordinating mass-revocation scenarios. Enable with `CERTCTL_ACME_ARI_ENABLED=true`. Cert ID is computed as `base64url(SHA-256(DER cert))` per RFC 9702. If the CA doesn't support ARI (404 from the ARI endpoint), certctl automatically falls back to threshold-based renewal — no operator intervention required. Errors from the CA are logged as warnings.
|
||||
|
||||
@@ -647,7 +647,7 @@ type ESTService interface {
|
||||
}
|
||||
```
|
||||
|
||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, and OpenSSL connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||
**Issuer connector extension:** EST required adding `GetCACertPEM(ctx) (string, error)` to the issuer connector interface so the `/cacerts` endpoint can serve the CA chain. The Local CA connector returns its CA certificate PEM; ACME, step-ca, OpenSSL, Vault, and DigiCert connectors return errors (they don't expose a static CA chain — their chains are per-issuance).
|
||||
|
||||
**Audit:** Every EST enrollment is recorded in the audit trail with `protocol: "EST"`, the CN, SANs, issuer ID, serial number, and optional profile ID.
|
||||
|
||||
|
||||
+47
-4
@@ -312,12 +312,55 @@ The `GetCACertPEM()` method returns the PEM-encoded CA certificate chain, used b
|
||||
|
||||
Note: EST (Enrollment over Secure Transport) is not a connector — it's a protocol handler (`internal/api/handler/est.go`) that delegates certificate issuance to whichever issuer connector is configured via `CERTCTL_EST_ISSUER_ID`. See the [Architecture Guide](architecture.md#est-server-rfc-7030) for details.
|
||||
|
||||
### Coming in V2.1
|
||||
### Built-in: Vault PKI
|
||||
|
||||
The following issuer connectors are planned for the v2.1.0 release:
|
||||
The Vault PKI connector integrates with HashiCorp Vault's PKI secrets engine using its native `/sign` API with token-based authentication. This is ideal for organizations using Vault as their internal certificate authority — synchronous issuance without the complexity of ACME or challenge solving.
|
||||
|
||||
- **Vault PKI** — HashiCorp Vault's PKI secrets engine (`/v1/{mount}/sign/{role}` API) for organizations using Vault as their internal CA. Token auth, configurable mount and role.
|
||||
- **DigiCert** — Commercial CA integration via DigiCert CertCentral REST API. Async order model (submit → poll for completion). OV/EV certificate support.
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_VAULT_ADDR` | — | Vault server address (e.g., `https://vault.internal:8200`) |
|
||||
| `CERTCTL_VAULT_TOKEN` | — | Vault auth token with permissions on the PKI mount |
|
||||
| `CERTCTL_VAULT_MOUNT` | `pki` | PKI secrets engine mount path |
|
||||
| `CERTCTL_VAULT_ROLE` | — | PKI role name for certificate signing |
|
||||
| `CERTCTL_VAULT_TTL` | `8760h` | Certificate validity period (TTL) |
|
||||
|
||||
The connector is registered in the issuer registry under `iss-vault`. Vault issues certificates synchronously via the `/v1/{mount}/sign/{role}` API with `X-Vault-Token` header authentication. The issued certificate is parsed to extract serial number, validity dates, and chain information.
|
||||
|
||||
**Note:** CRL and OCSP are managed by Vault itself. Clients should validate certificate status against Vault's own CRL/OCSP endpoints (`GET /v1/{mount}/crl` and Vault's OCSP responder). certctl does not generate local CRL/OCSP for Vault-issued certificates. Revocation is recorded locally but Vault is the authoritative source.
|
||||
|
||||
Location: `internal/connector/issuer/vault/vault.go`
|
||||
|
||||
### Built-in: DigiCert CertCentral
|
||||
|
||||
The DigiCert connector integrates with DigiCert's CertCentral REST API for ordering and managing certificates from DigiCert's commercial CA. It supports both Domain Validated (DV) and Organization/Extended Validated (OV/EV) certificates, with async order processing.
|
||||
|
||||
**Configuration:**
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| `CERTCTL_DIGICERT_API_KEY` | — | DigiCert API key (X-DC-DEVKEY header) |
|
||||
| `CERTCTL_DIGICERT_ORG_ID` | — | DigiCert organization ID |
|
||||
| `CERTCTL_DIGICERT_PRODUCT_TYPE` | `ssl_basic` | Certificate product (e.g., `ssl_basic`, `ssl_plus`, `ssl_ev`) |
|
||||
| `CERTCTL_DIGICERT_BASE_URL` | `https://www.digicert.com/services/v2` | DigiCert API base URL |
|
||||
|
||||
The connector submits certificate orders to DigiCert's `/order/certificate/create` API. DV certificates may issue immediately; OV/EV certificates require validation (handled by DigiCert) and poll-based completion. The connector periodically checks order status via `/order/certificate/{order_id}` until the certificate is available.
|
||||
|
||||
**Authentication:** API key passed via `X-DC-DEVKEY` header, with organization ID in request body.
|
||||
|
||||
**Note:** CRL and OCSP are managed by DigiCert. Clients should validate certificate status against DigiCert's infrastructure. certctl records the revocation locally but does not notify DigiCert for revocation — use DigiCert's dashboard for revocation management.
|
||||
|
||||
Location: `internal/connector/issuer/digicert/digicert.go`
|
||||
|
||||
### Coming in V2.2+
|
||||
|
||||
The following issuer connectors are planned for future releases:
|
||||
|
||||
- **Entrust** — Enterprise CA via Entrust API
|
||||
- **Sectigo** — Commercial CA integration via Sectigo REST API
|
||||
- **Google CAS** — Google Cloud Certificate Authority Service
|
||||
- **AWS ACM Private CA** — AWS-managed private CA
|
||||
|
||||
Note: ADCS (Active Directory Certificate Services) integration is handled via the **sub-CA mode** of the Local CA issuer, not as a separate connector. certctl operates as a subordinate CA with its signing certificate issued by ADCS, so all certctl-issued certs chain to the enterprise ADCS root. See the Local CA section above.
|
||||
|
||||
|
||||
+240
-3
@@ -42,6 +42,8 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
||||
- [Part 35: ARI (RFC 9702) Scheduler Integration](#part-35-ari-rfc-9702-scheduler-integration)
|
||||
- [Part 36: Agent Work Routing (M31)](#part-36-agent-work-routing-m31)
|
||||
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||
- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
||||
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37)
|
||||
- [Release Sign-Off](#release-sign-off)
|
||||
|
||||
---
|
||||
@@ -5166,6 +5168,210 @@ curl -s -H "Authorization: Bearer $API_KEY" \
|
||||
|
||||
---
|
||||
|
||||
## Part 38: Vault PKI Connector (M32)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Vault server running with PKI secrets engine enabled at `pki` mount
|
||||
- PKI role created with appropriate certificate generation policy
|
||||
- Vault token with read/sign permissions on the PKI path
|
||||
- Environment variables configured:
|
||||
```bash
|
||||
export CERTCTL_VAULT_ADDR="https://vault.internal:8200"
|
||||
export CERTCTL_VAULT_TOKEN="s.xxxxxxxxxxxxxxxx"
|
||||
export CERTCTL_VAULT_MOUNT="pki"
|
||||
export CERTCTL_VAULT_ROLE="certctl-role"
|
||||
export CERTCTL_VAULT_TTL="8760h"
|
||||
```
|
||||
|
||||
### 38.1 Register Vault PKI Issuer
|
||||
|
||||
**Test:** Register a Vault PKI issuer via the API.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/issuers" \
|
||||
-d '{
|
||||
"id": "iss-vault-prod",
|
||||
"name": "Vault PKI Production",
|
||||
"type": "VaultPKI",
|
||||
"config": {
|
||||
"vault_addr": "'"$CERTCTL_VAULT_ADDR"'",
|
||||
"vault_token": "'"$CERTCTL_VAULT_TOKEN"'",
|
||||
"vault_mount": "'"$CERTCTL_VAULT_MOUNT"'",
|
||||
"vault_role": "'"$CERTCTL_VAULT_ROLE"'",
|
||||
"vault_ttl": "'"$CERTCTL_VAULT_TTL"'"
|
||||
}
|
||||
}' | jq '.id'
|
||||
```
|
||||
|
||||
**Expected:** Returns issuer ID `iss-vault-prod`.
|
||||
**PASS if** issuer is registered and appears in `GET /api/v1/issuers`.
|
||||
|
||||
### 38.2 Issue Certificate via Vault PKI
|
||||
|
||||
**Test:** Create a certificate and issue it through Vault PKI.
|
||||
|
||||
```bash
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "vault-test.example.com",
|
||||
"issuer_id": "iss-vault-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq '.job_id'
|
||||
```
|
||||
|
||||
**Expected:** Renewal job created and eventually moves to Completed status.
|
||||
**PASS if** certificate is issued by Vault with valid serial number and chain.
|
||||
|
||||
### 38.3 Verify Certificate Serial and Subject
|
||||
|
||||
**Test:** Check that the issued certificate has correct Vault metadata.
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID" | jq '.versions[0] | {serial, subject_dn, not_before, not_after}'
|
||||
```
|
||||
|
||||
**Expected:** Serial, DN, and validity dates from Vault PKI.
|
||||
**PASS if** certificate metadata is populated from Vault's response.
|
||||
|
||||
### 38.4 Revocation Records Locally
|
||||
|
||||
**Test:** Revoke the certificate and verify local recording.
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/revoke" \
|
||||
-d '{"reason": "superseded"}' | jq '.revoked_at'
|
||||
```
|
||||
|
||||
**Expected:** Returns `revoked_at` timestamp.
|
||||
**PASS if** revocation is recorded locally in the audit trail but not propagated to Vault (Vault is authoritative for its own revocation).
|
||||
|
||||
---
|
||||
|
||||
## Part 39: DigiCert Connector (M37)
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- DigiCert CertCentral account with API access
|
||||
- API key and organization ID from DigiCert
|
||||
- Environment variables configured:
|
||||
```bash
|
||||
export CERTCTL_DIGICERT_API_KEY="xxxxxxxxxxxxxxxxxxxxxxxx"
|
||||
export CERTCTL_DIGICERT_ORG_ID="123456"
|
||||
export CERTCTL_DIGICERT_PRODUCT_TYPE="ssl_basic"
|
||||
export CERTCTL_DIGICERT_BASE_URL="https://www.digicert.com/services/v2"
|
||||
```
|
||||
|
||||
### 39.1 Register DigiCert Issuer
|
||||
|
||||
**Test:** Register a DigiCert CertCentral issuer via the API.
|
||||
|
||||
```bash
|
||||
curl -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/issuers" \
|
||||
-d '{
|
||||
"id": "iss-digicert-prod",
|
||||
"name": "DigiCert CertCentral",
|
||||
"type": "DigiCert",
|
||||
"config": {
|
||||
"api_key": "'"$CERTCTL_DIGICERT_API_KEY"'",
|
||||
"org_id": "'"$CERTCTL_DIGICERT_ORG_ID"'",
|
||||
"product_type": "'"$CERTCTL_DIGICERT_PRODUCT_TYPE"'",
|
||||
"base_url": "'"$CERTCTL_DIGICERT_BASE_URL"'"
|
||||
}
|
||||
}' | jq '.id'
|
||||
```
|
||||
|
||||
**Expected:** Returns issuer ID `iss-digicert-prod`.
|
||||
**PASS if** issuer is registered and appears in `GET /api/v1/issuers`.
|
||||
|
||||
### 39.2 Issue DV Certificate via DigiCert
|
||||
|
||||
**Test:** Create a DV certificate order and track it to completion.
|
||||
|
||||
```bash
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "dv-test.example.com",
|
||||
"issuer_id": "iss-digicert-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
JOB_ID=$(curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id')
|
||||
|
||||
# Poll for job completion (DV certs may issue immediately)
|
||||
for i in {1..30}; do
|
||||
STATUS=$(curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/jobs/$JOB_ID" | jq -r '.status')
|
||||
echo "Job status: $STATUS"
|
||||
[ "$STATUS" = "Completed" ] && break
|
||||
sleep 2
|
||||
done
|
||||
```
|
||||
|
||||
**Expected:** Job eventually reaches Completed status with certificate issued.
|
||||
**PASS if** certificate has DigiCert serial number and chain.
|
||||
|
||||
### 39.3 Verify Order ID Tracking
|
||||
|
||||
**Test:** Check that the job record includes the DigiCert order ID for auditing.
|
||||
|
||||
```bash
|
||||
curl -s -H "$AUTH" \
|
||||
"$SERVER/api/v1/jobs/$JOB_ID" | jq '.metadata'
|
||||
```
|
||||
|
||||
**Expected:** Metadata includes `order_id` from DigiCert for order tracking.
|
||||
**PASS if** audit trail shows the DigiCert order lifecycle.
|
||||
|
||||
### 39.4 Async Poll Behavior
|
||||
|
||||
**Test:** Verify the connector polls for certificate completion (OV certs take longer).
|
||||
|
||||
```bash
|
||||
# Submit OV certificate order (requires validation)
|
||||
CERT_ID=$(curl -s -X POST -H "$AUTH" -H "$CT" \
|
||||
"$SERVER/api/v1/certificates" \
|
||||
-d '{
|
||||
"common_name": "ov-test.example.com",
|
||||
"issuer_id": "iss-digicert-prod",
|
||||
"key_algorithm": "RSA-2048"
|
||||
}' | jq -r '.id')
|
||||
|
||||
JOB_ID=$(curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/renew" | jq -r '.job_id')
|
||||
|
||||
# Check job status transitions
|
||||
curl -s -H "$AUTH" "$SERVER/api/v1/jobs/$JOB_ID" | jq '.status'
|
||||
```
|
||||
|
||||
**Expected:** Job status transitions through pending states as DigiCert validates.
|
||||
**PASS if** polling mechanism works and job reaches completion once DigiCert issues the certificate.
|
||||
|
||||
### 39.5 Revocation Records Locally
|
||||
|
||||
**Test:** Revoke a DigiCert-issued certificate.
|
||||
|
||||
```bash
|
||||
curl -s -X POST -H "$AUTH" \
|
||||
"$SERVER/api/v1/certificates/$CERT_ID/revoke" \
|
||||
-d '{"reason": "cessationOfOperation"}' | jq '.revoked_at'
|
||||
```
|
||||
|
||||
**Expected:** Returns `revoked_at` timestamp.
|
||||
**PASS if** revocation is recorded locally; operator manages revocation in DigiCert CertCentral dashboard.
|
||||
|
||||
---
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||
@@ -5715,14 +5921,45 @@ These must be green before starting manual QA:
|
||||
| 37.16 | TargetsPage — target names clickable to /targets/:id | Manual | ☐ | | |
|
||||
| 37.17 | Sidebar — Digest and Observability nav items | Manual | ☐ | | |
|
||||
|
||||
### Part 38: Vault PKI Connector (M32)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 38.s1 | Vault PKI issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.1 |
|
||||
| 38.s2 | Vault issuer type is VaultPKI | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.2 |
|
||||
| 38.s3 | Vault issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.3 |
|
||||
| 38.s4 | Vault connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.4 |
|
||||
| 38.s5 | Vault connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.5 |
|
||||
| 38.s6 | OpenAPI spec includes VaultPKI type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 38.6 |
|
||||
| 38.1 | Register Vault PKI issuer | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.2 | Issue certificate via Vault PKI | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.3 | Verify certificate serial and subject | Manual | ☐ | | Requires live Vault server |
|
||||
| 38.4 | Revocation records locally | Manual | ☐ | | Requires live Vault server |
|
||||
|
||||
### Part 39: DigiCert Connector (M37)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 39.s1 | DigiCert issuer exists in seed data | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.1 |
|
||||
| 39.s2 | DigiCert issuer type is DigiCert | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.2 |
|
||||
| 39.s3 | DigiCert issuer is enabled | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.3 |
|
||||
| 39.s4 | DigiCert connector passes go vet | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.4 |
|
||||
| 39.s5 | DigiCert connector tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.5 |
|
||||
| 39.s6 | OpenAPI spec includes DigiCert type | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 39.6 |
|
||||
| 39.1 | Register DigiCert issuer | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.2 | Issue DV certificate via DigiCert | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.3 | Verify order ID tracking | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.5 | Revocation records locally | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 127 |
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 136 |
|
||||
| — Skipped (preconditions not met in demo) | 5 |
|
||||
| ☐ Manual (requires hands-on verification) | 217 |
|
||||
| **Total** | **349** |
|
||||
| ☐ Manual (requires hands-on verification) | 226 |
|
||||
| **Total** | **367** |
|
||||
|
||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user