Skip to content
Talk to our solutions team

End-to-End Testing

Audience: customer developers and kis.ai DevSecOps. Two roles, no source-code access assumed for either:

RoleWhat they verify
Customer developer”Can my service get its secrets / mint a JWT from the local agent socket on my dev box?“
kis.ai DevSecOps”Did my install land correctly? Server up, agent up, attestation working, secrets/JWT round-trips clean, audit chain healthy, rotation actually rotating?”

This document is a self-contained test plan for the Vault service. Every command is copy-pasteable. Every API call has the matching curl and Bruno entry. We cover every non-cloud combination of vault.svc + vault-agent — fsvault / OpenBao / self-hosted HCP Vault for the backend, in-process RSA / SoftHSM2 for the signer, and every topology from “single binary on laptop” to “3-node HA in docker-compose.”

What works today vs. what’s scaffolded. A handful of routes return HTTP 501 with a phase marker (/workload/secret/get|write|delete, /workload/jwt returns a stub token, /operator/audit/*, /operator/root/*, /operator/backend/migrate, /operator/signer/test, /operator/policy/lint). Each test section calls these out so you know whether a 501 means “broken” or “expected pending phase X.”

ToolMin versionWhy
curl7.40UDS support via --unix-socket
jq1.6Parse JSON responses
openssl3.0Generate test CAs and operator certs
bruno1.20 (download)API explorer; OSS replacement for Postman
kis CLIlatest releaseConvenience wrapper over the agent socket
docker + docker-compose24.x / v2Multi-container topologies
softhsm22.6Local HSM emulation for PKCS#11 tests
vault (HashiCorp)1.14Optional — backend tests
bao (OpenBao)2.0Optional — backend tests

macOS / Linux: every command in this doc works on both. Windows users should run inside WSL2.

PortServiceNotes
7999vault.svc HTTPSConfigurable via httpserver.listenaddress
7991vault.svc gRPC (future)Reserved
n/a — UDSvault-agent/run/vault/vault.sock (root) or /tmp/horus/horus.sock (rootless dev)
8200HashiCorp Vault (optional backend)Default
8200OpenBao (optional backend)Default — same as HCP Vault, run on different hosts

Everything below assumes you’re in a scratch directory:

Terminal window
mkdir -p ~/kis-vault-test && cd ~/kis-vault-test

All generated certs, config files, and SoftHSM tokens land here.

Goal: prove vault.svc starts, accepts a healthz, and your toolchain (curl + jq + Bruno) talks to it. Run this before any of the longer recipes — it isolates “is my environment broken” from “is the test broken.”

Terminal window
# Generate a throwaway CA + leaf for vault.svc itself
openssl req -x509 -newkey rsa:2048 -nodes -keyout test-ca.key -out test-ca.crt \
-days 30 -subj "/CN=kis-test-ca"
openssl req -newkey rsa:2048 -nodes -keyout vault-svc.key -out vault-svc.csr \
-subj "/CN=vault.svc.local"
openssl x509 -req -in vault-svc.csr -CA test-ca.crt -CAkey test-ca.key -CAcreateserial \
-out vault-svc.crt -days 30 \
-extfile <(printf "subjectAltName=DNS:vault.svc.local,DNS:localhost,IP:127.0.0.1")
# Minimal config for fsvault + inproc signer, listening on 7999
cat > vault.svc.yaml <<'EOF'
service:
name: vault.svc
http:
listenaddress: 127.0.0.1:7999
horus:
vault:
type: fsvault
fsvault:
root_dir: ./fsvault-data
signer:
type: inproc
tls:
ca_certs: ["./test-ca.crt"]
cert: "./vault-svc.crt"
key: "./vault-svc.key"
require_client_cert: false # smoke test only; flip back on for real
EOF
mkdir -p fsvault-data
# Start it (foreground; ctrl-c to stop)
vault.svc -config ./vault.svc.yaml

In another shell:

Terminal window
curl -sk https://127.0.0.1:7999/healthz | jq .
# expect: {"status":"ok"} or similar — anything 2xx is healthy

If you get JSON back, stop here and continue with the recipes. If not, fix the environment before going further.

SymptomLikely causeFix
curl: (7) Failed to connectserver didn’t bind; check stderrlsof -i :7999
SSL routines:tls_process_server_certificate:certificate verify failedself-signed CAuse -k (smoke) or --cacert test-ca.crt
bad config: yaml: line NYAML indentationtabs vs. spaces; this config is spaces only
Server prints signer: inproc must be constructed via NewInProcWithKeythe inproc signer needs a key file; see §3.1

3. Setup recipes — every non-cloud combination

Section titled “3. Setup recipes — every non-cloud combination”

Each recipe lists: what you’re testing, how to start it, and what to verify. Recipes share the test-CA from §2.1; don’t regenerate.

3.1 Local binary, fsvault, inproc signer, no agent

Section titled “3.1 Local binary, fsvault, inproc signer, no agent”

The simplest end-to-end. Server only; consumers talk to it directly via HTTPS (legacy transport: remote).

Terminal window
# Generate the CA private key the inproc signer will use as the issuer
openssl genrsa -out horus-ca.key 4096
openssl req -x509 -new -key horus-ca.key -out horus-ca.crt -days 3650 \
-subj "/CN=kis Vault Root CA"
# Server config (replaces the smoke-test one)
cat > vault.svc.yaml <<'EOF'
service:
name: vault.svc
http: { listenaddress: 127.0.0.1:7999 }
horus:
vault:
type: fsvault
fsvault: { root_dir: ./fsvault-data }
signer:
type: inproc
inproc:
ca_cert: ./horus-ca.crt
ca_key: ./horus-ca.key
tls:
ca_certs: ["./test-ca.crt"]
cert: "./vault-svc.crt"
key: "./vault-svc.key"
require_client_cert: true
EOF
vault.svc -config ./vault.svc.yaml
Terminal window
# Server-side health
curl -sk https://127.0.0.1:7999/healthz | jq .
# Operator-issued cert (using the test CA's leaf as the operator's mTLS pair)
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key https://127.0.0.1:7999/healthz

Tests: server boot, fsvault initialization, inproc signer key load, mTLS handshake. Does NOT test: agent attestation, JWT minting, /workload/* routes.

The reference dev setup. Server on 127.0.0.1:7999, agent on /tmp/horus/horus.sock, both share the test CA.

The agent needs a node cert. We mint a single-use token from the server, then vault-agent init exchanges it.

Terminal window
# Issue an operator token (operator cert is test-ca + vault-svc leaf for now)
TOKEN=$(curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/operator/node-token/issue \
-H 'Content-Type: application/json' \
-d '{"dc":"local","cluster":"dev","ttl":"30m"}' \
| jq -r .token)
echo "Issued token: $TOKEN"
Terminal window
sudo mkdir -p /run/horus /var/lib/horus
sudo chown $(whoami) /run/horus /var/lib/horus
vault-agent init \
--server https://127.0.0.1:7999 \
--token "$TOKEN" \
--trust-bundle ./test-ca.crt \
--dc local --cluster dev
vault-agent run --foreground &
AGENT_PID=$!
Terminal window
# Health probe (unauthenticated)
curl --unix-socket /run/vault/vault.sock http://agent/healthz
# Attested identity probe — uses the calling shell's pid for SO_PEERCRED
curl --unix-socket /run/vault/vault.sock http://agent/workload/identity \
-X GET
# expect 401 unless you've registered a selector matching curl's binary

Tests: server↔agent handshake, node-cert issuance, refresh-token storage, agent socket listening, SO_PEERCRED attestation refusal for unregistered workloads.

3.3 Server + agent in separate Docker containers

Section titled “3.3 Server + agent in separate Docker containers”

Closer to production: server in one container, agent in another, on the same Docker network. Tests the mTLS hop “for real” (different routable hosts).

docker-compose.yaml
version: "3.8"
services:
vault-svc:
image: kis-ai/vault.svc:dev
volumes:
- ./test-ca.crt:/etc/horus/test-ca.crt:ro
- ./vault-svc.crt:/etc/horus/vault-svc.crt:ro
- ./vault-svc.key:/etc/horus/vault-svc.key:ro
- ./horus-ca.crt:/etc/horus/horus-ca.crt:ro
- ./horus-ca.key:/etc/horus/horus-ca.key:ro
- vault-data:/var/lib/horus
command: ["-config", "/etc/horus/vault.svc.yaml"]
networks: { kisnet: { aliases: [vault.svc] } }
ports: ["7999:7999"]
vault-agent:
image: kis-ai/vault-agent:dev
depends_on: [vault-svc]
volumes:
- ./test-ca.crt:/etc/horus/test-ca.crt:ro
- agent-state:/var/lib/horus
- /run/horus:/run/horus
cap_add: [SYS_PTRACE] # for /proc/<pid>/exe reads of peer workloads
command:
- run
- --foreground
volumes: { vault-data: {}, agent-state: {} }
networks: { kisnet: {} }
Terminal window
docker compose up -d
# Bootstrap the agent (still requires a token issued by the operator)
TOKEN=$(docker compose exec vault-svc /usr/local/bin/vault.svc operator node-token issue \
--dc local --cluster dev --ttl 30m | jq -r .token)
docker compose exec vault-agent vault-agent init \
--server https://vault.svc:7999 --token "$TOKEN" \
--trust-bundle /etc/horus/test-ca.crt --dc local --cluster dev

Tests: real DNS resolution, container-isolated trust bundle, two-process /healthz, mTLS-over-bridge-network handshake.

Same as §3.3 but the network hop is real (different machines, real interfaces). Use Vagrant, two laptops, or two cloud VMs (skip cloud-managed services — use plain t3.micro running our binaries).

Terminal window
# On VM-A (server):
ip addr show # note the LAN IP, e.g. 192.168.1.20
vault.svc -config /etc/horus/vault.svc.yaml
# On VM-B (agent):
echo "192.168.1.20 vault.svc.local" | sudo tee -a /etc/hosts
vault-agent init --server https://vault.svc.local:7999 \
--token "$TOKEN" --trust-bundle /etc/horus/test-ca.crt --dc local --cluster dev
vault-agent run --foreground

Tests: all of §3.3 plus real-network NIC behavior (MTU, TLS-SNI), recovery from packet loss (drop a few ICMPs with tc to simulate).

OpenBao is wire-compatible with HashiCorp Vault. Useful for sites that want OSS-licensed backend without the BSL.

Terminal window
# Run OpenBao in dev mode (root token printed on stdout — capture it)
docker run --rm -d --name openbao -p 8200:8200 \
-e BAO_DEV_ROOT_TOKEN_ID=dev-root \
-e BAO_DEV_LISTEN_ADDRESS=0.0.0.0:8200 \
openbao/openbao:latest server -dev
# Enable the v2 KV secrets engine at the mount the Vault service expects
docker exec openbao bao secrets enable -version=2 -path=horus kv
horus:
vault:
type: openbao
openbao:
address: http://127.0.0.1:8200
mount: horus
token_env: BAO_TOKEN # `export BAO_TOKEN=dev-root` before starting
Terminal window
# Put a secret via the server, confirm it shows up in OpenBao directly
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/secret/myname \
-H 'Content-Type: application/json' \
-d '{"secret":"hunter2","dc":"local","cluster":"dev"}'
# Read back via OpenBao CLI to confirm storage location
docker exec -e BAO_TOKEN=dev-root openbao bao kv get horus/secret/local/dev/myname

Tests: backend swap, version cap, key-collision behaviour.

3.6 HashiCorp Vault self-hosted (no HCP cloud)

Section titled “3.6 HashiCorp Vault self-hosted (no HCP cloud)”

Identical to §3.5 with baovault and type: hcpvault. Useful if your org already runs HashiCorp Vault on-prem.

Terminal window
docker run --rm -d --name hcv -p 8200:8200 \
-e VAULT_DEV_ROOT_TOKEN_ID=dev-root \
hashicorp/vault:latest
docker exec hcv vault secrets enable -version=2 -path=horus kv
horus:
vault:
type: hcpvault
hcpvault:
address: http://127.0.0.1:8200
mount: horus
token_env: VAULT_TOKEN

Tests the HSM path without buying hardware.

Terminal window
# Init a SoftHSM token + generate a CA key on it
mkdir -p softhsm-tokens
cat > softhsm2.conf <<EOF
directories.tokendir = $PWD/softhsm-tokens
objectstore.backend = file
EOF
export SOFTHSM2_CONF=$PWD/softhsm2.conf
softhsm2-util --init-token --slot 0 --label horus-test \
--pin 1234 --so-pin 5678
# Generate a 4096-bit RSA key labeled horus-ca-key
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so \
--login --pin 1234 \
--keypairgen --key-type RSA:4096 \
--label horus-ca-key --id 01
# Tell vault.svc to use it
cat >> vault.svc.yaml <<'EOF'
horus:
signer:
type: pkcs11
pkcs11:
module_path: /usr/lib/softhsm/libsofthsm2.so
slot_label: horus-test
pin_env: SOFTHSM_PIN
key_label: horus-ca-key
EOF
export SOFTHSM_PIN=1234
vault.svc -config ./vault.svc.yaml

Issue a leaf and verify the issuer’s pubkey matches the HSM’s pubkey:

Terminal window
# Export the HSM pubkey for comparison
pkcs11-tool --module /usr/lib/softhsm/libsofthsm2.so --login --pin 1234 \
--read-object --label horus-ca-key --type pubkey --output-file hsm-pub.der
openssl pkey -inform DER -pubin -in hsm-pub.der -text -noout
# Issue a workload cert and inspect its issuer
# (uses the legacy /certificate/generate/internal endpoint; substitute your CN)
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/certificate/generate/internal \
-H 'Content-Type: application/json' \
-d '{"common_name":"hsm-test","dc":"local","cluster":"dev"}'

Tests: PKCS#11 module load, key location by label, signing through the HSM session. Failure modes: missing pin → server refuses to boot with a clear pkcs11.pin_env is empty error.

3.8 File-mode delivery (Postgres-style workload)

Section titled “3.8 File-mode delivery (Postgres-style workload)”

Use this when a workload (Postgres, ClickHouse, third-party binary) can’t speak the agent socket protocol.

# /etc/horus/agent.yaml (or wherever your agent reads from)
file_deliveries:
- name: postgres-prod
identity: spiffe://kis.dev/workload/postgres
cert_path: /var/lib/postgresql/server.crt
key_path: /var/lib/postgresql/server.key
trust_bundle_path: /var/lib/postgresql/trust.pem
owner: postgres
group: postgres
mode_cert: "0644"
mode_key: "0600"
mode_bundle: "0644"
allow_chown: true
rotation_interval: 12h
reload:
type: signal
signal: SIGHUP
pid_file: /var/run/postgresql/postgres.pid
Terminal window
# Watch the agent rotation log
journalctl -u vault-agent -f &
# Force a rotation immediately
sudo systemctl reload vault-agent
# Confirm Postgres saw the new cert
sudo -u postgres openssl x509 -in /var/lib/postgresql/server.crt -noout -dates
# notBefore should be within the last minute

Tests: atomic write, fsync, chown without disrupting Postgres, SIGHUP delivery. Edge case to confirm: kill Postgres mid-rotation. Should never see a half-written cert because rename is atomic.

The migration tool (kis vault migrate-service) recommends transport: both as the canary state. As of this release the chassis-side dual-call sampler is not yet implemented (P2 in the pending list). Until it lands, you can manually verify shape parity by running both transports in two terminals:

Terminal window
# Terminal A — direct HTTPS (legacy)
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X PATCH https://127.0.0.1:7999/secret/myname \
-H 'Content-Type: application/json' \
-d '{"dc":"local","cluster":"dev"}'
# Terminal B — through the agent
curl --unix-socket /run/vault/vault.sock http://agent/workload/secret \
-X POST -H 'Content-Type: application/json' \
-d '{"name":"vault:myname"}'
# Diff the bodies — shapes should match (modulo the agent's selector-derived tenant scoping)

For DevSecOps validating HA failover.

docker-compose.ha.yaml
version: "3.8"
services:
vault-svc-a: { image: kis-ai/vault.svc:dev, ... networks: [kisnet] }
vault-svc-b: { image: kis-ai/vault.svc:dev, ... networks: [kisnet] }
vault-svc-c: { image: kis-ai/vault.svc:dev, ... networks: [kisnet] }
nginx:
image: nginx:alpine
volumes: [./nginx.conf:/etc/nginx/nginx.conf:ro]
ports: ["7999:7999"]
networks: [kisnet]
agent-1: { image: kis-ai/vault-agent:dev, ... }
agent-2: { image: kis-ai/vault-agent:dev, ... }
agent-3: { image: kis-ai/vault-agent:dev, ... }

nginx.conf round-robins TCP over the three server replicas. Backend is shared OpenBao or a shared fsvault-data volume.

Tests: kill vault-svc-a mid-request, expect retry to land on -b or -c. Refresh-token flow must work across the failover.

4. Test plan — every API surface with curl

Section titled “4. Test plan — every API surface with curl”

We exercise four route families. All examples assume a running setup from §3.

MethodPathAuthNotes
GET/healthznoneLiveness, no chain-of-trust
Terminal window
curl -sk https://127.0.0.1:7999/healthz
# response: {"status":"ok"}

Agent’s parallel:

Terminal window
curl --unix-socket /run/vault/vault.sock http://agent/healthz
# response: {"status":"ok"}

These are called by vault-agent against vault.svc over mTLS, on behalf of attested workloads. You typically don’t curl them directly except for testing.

Terminal window
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/workload/identity \
-H 'Content-Type: application/json' \
-d '{
"selectors": {
"binary_path": "/usr/local/bin/vulcan",
"uid": 1000
}
}'
# response (200):
# {"spiffe_id":"spiffe://kis.dev/workload/vulcan","policy":"vulcan-default"}
# response (404 when no entry matches):
# {"code":"horus-workload-error","message":"no registration entry matches..."}
# response (409 ambiguous):
# {"code":"horus-workload-error","message":"selectors match multiple entries..."}

Pre-register an entry so the 200 path works:

Terminal window
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/operator/identity \
-H 'Content-Type: application/json' \
-d '{
"spiffe_id": "spiffe://kis.dev/workload/vulcan",
"selectors": {"binary_path": "/usr/local/bin/vulcan"},
"policy": "vulcan-default"
}'
Terminal window
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/workload/jwt \
-H 'Content-Type: application/json' \
-d '{
"selectors": {"binary_path": "/usr/local/bin/vulcan"},
"audience": ["janus"]
}'

Today’s response (stub):

{
"token": "horus.stub-jwt.spiffe://kis.dev/workload/vulcan.aud=janus",
"spiffe_id": "spiffe://kis.dev/workload/vulcan",
"note": "Phase-4-jwt: stub token; real SVID issuance lands with the JWT-SVID work item"
}

When real JWT-SVID lands, the token field becomes a signed JWS with rcv_aud: ["janus"]. The stub is intentional so consumers can wire shape today.

Currently 501. Calling them confirms the routes exist:

Terminal window
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/workload/secret/get \
-H 'Content-Type: application/json' \
-d '{"selectors":{"binary_path":"/x"},"name":"vault:foo"}'
# response (501):
# {"code":"horus-operator-scaffold","message":"workload secret-get is scaffolded;
# SPIFFE→tenant mapping lands in Phase 4-secrets"}
Terminal window
curl -sk --cacert ./test-ca.crt \
-X POST https://127.0.0.1:7999/agent/join \
-H 'Content-Type: application/json' \
-d '{
"secret": "hj_v1_abc12_DEADBEEF...",
"attributes": {
"hostname": "test-host",
"machine_id": "uuid-here",
"os": "linux"
},
"dc": "local",
"cluster": "dev"
}'
# 200 — returns node cert chain PEM + key PEM + trust bundle PEM + refresh token
# 401 — token invalid / consumed / scope mismatch
Terminal window
curl -sk --cacert ./test-ca.crt \
-X POST https://127.0.0.1:7999/agent/renew \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"rt_v1_xxx..."}'
# 200 — fresh node cert chain + (typically same) refresh token
# 401 — refresh token expired or revoked
Terminal window
curl -sk --cacert ./test-ca.crt \
-X POST https://127.0.0.1:7999/agent/decommission \
-H 'Content-Type: application/json' \
-d '{"node_spiffe_id":"spiffe://kis.dev/node/abc","reason":"reimage"}'
# 200 — {"status":"ok"}

All require an operator mTLS cert. The cert’s SAN encodes the role (today not yet enforced — P1 follow-up; any cert the cluster trusts is treated as operator-privileged).

Terminal window
# Issue
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/operator/node-token/issue \
-H 'Content-Type: application/json' \
-d '{"dc":"local","cluster":"dev","ttl":"30m"}'
# 200: {"token":"hj_v1_...","id":"abcde","expires_at":"..."}
# List (secret stripped)
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
https://127.0.0.1:7999/operator/node-token
# 200: {"tokens":[{"id":"abcde","dc":"local","cluster":"dev","not_after":"..."}]}
# Revoke
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X DELETE https://127.0.0.1:7999/operator/node-token/abcde
# 200: {"status":"ok","id":"abcde"}
Terminal window
# Register
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/operator/identity \
-H 'Content-Type: application/json' \
-d '{
"spiffe_id":"spiffe://kis.dev/workload/test",
"selectors":{"binary_path":"/usr/bin/test"},
"policy":"default"
}'
# List
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
https://127.0.0.1:7999/operator/identity
# Revoke (note: scheme stripped, leading slash kept by the router)
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X DELETE https://127.0.0.1:7999/operator/identity/kis.dev/workload/test

Audit / Root / Backend / Signer / Policy (all 501 today)

Section titled “Audit / Root / Backend / Signer / Policy (all 501 today)”
Terminal window
for ep in audit/query audit/verify root/rotate-plan \
backend/migrate signer/test policy/lint; do
echo "=== /operator/$ep ==="
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/operator/$ep \
-H 'Content-Type: application/json' -d '{}'
echo
done
# All return 501 with phase markers. This is expected scaffolding.

These predate /workload/* and stay during migration. Verifying they still work guards against regressions.

Terminal window
# Save (POST creates, PUT updates)
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X POST https://127.0.0.1:7999/secret/db_password \
-H 'Content-Type: application/json' \
-d '{
"secret":"hunter2",
"datacenter":"local","cluster":"dev",
"customer":"acme","product":"widget","environment":"dev","tenant":"acme-1"
}'
# Retrieve
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X PATCH https://127.0.0.1:7999/secret/db_password \
-H 'Content-Type: application/json' \
-d '{"datacenter":"local","cluster":"dev","customer":"acme","product":"widget","environment":"dev","tenant":"acme-1"}'
# Exists
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X PATCH https://127.0.0.1:7999/checksecret/db_password \
-H 'Content-Type: application/json' \
-d '{"datacenter":"local","cluster":"dev","customer":"acme","product":"widget","environment":"dev","tenant":"acme-1"}'
# Delete
curl -sk --cert ./vault-svc.crt --key ./vault-svc.key \
-X DELETE https://127.0.0.1:7999/secret/db_password \
-H 'Content-Type: application/json' \
-d '{"datacenter":"local","cluster":"dev","customer":"acme","product":"widget","environment":"dev","tenant":"acme-1"}'

curl --unix-socket works against the agent the same way it does against the server.

Terminal window
# Health
curl --unix-socket /run/vault/vault.sock http://agent/healthz
# Identity (attested — must be called by a binary that matches a selector)
curl --unix-socket /run/vault/vault.sock http://agent/workload/identity
# Mint a JWT
curl --unix-socket /run/vault/vault.sock \
-X POST http://agent/workload/jwt \
-H 'Content-Type: application/json' \
-d '{"audience":["janus"]}'
# Read a secret (501 today; routes exist)
curl --unix-socket /run/vault/vault.sock \
-X POST http://agent/workload/secret \
-H 'Content-Type: application/json' \
-d '{"name":"vault:db_password"}'

The kis CLI wraps these so you don’t have to remember the socket path:

Terminal window
kis vault status # GET /healthz + /workload/identity
kis vault jwt --audience janus
kis vault get vault:db_password

5. API collection — single OpenAPI 3.0 spec

Section titled “5. API collection — single OpenAPI 3.0 spec”

The full API surface ships as a single OpenAPI 3.0 spec for the vault service — one file, no directory tree. Importable into Bruno, Insomnia, Postman, and Swagger UI without modification.

  1. Open Bruno → CollectionsImport CollectionOpenAPI Specification.

  2. Pick the vault service OpenAPI spec file.

  3. Bruno generates one folder per tags: group (Health, Workload, Agent handshake, Operator — node tokens, …) with one request per operation.

  4. Create an environment in Bruno with these vars (matching the OpenAPI servers: and securitySchemes:):

    VariableLocal dev valueNotes
    baseUrlhttps://127.0.0.1:7999Override per environment
    agentSocket/run/vault/vault.sockFor agent UDS routes
    operatorCert~/kis-vault-test/vault-svc.crtmTLS client cert
    operatorKey~/kis-vault-test/vault-svc.keymTLS client key
    caBundle~/kis-vault-test/test-ca.crtServer trust anchor
    dc, clusterlocal, devSample tenancy
  5. In Bruno’s collection-level Auth tab, select mTLS Client Certificate and point at {{operatorCert}} / {{operatorKey}} / {{caBundle}}. Every operation that declares mtlsOperator security inherits this automatically.

5.2 Open it in Insomnia / Postman / Swagger UI

Section titled “5.2 Open it in Insomnia / Postman / Swagger UI”
  • Insomnia: Create → Import → Filekis-vault.openapi.yaml. Auto-generates a workspace with all routes.

  • Postman: Import → File → pick the YAML. Postman converts to its collection format.

  • Swagger UI: point a local swagger-ui Docker container at the file:

    Terminal window
    docker run --rm -p 8080:8080 \
    -e SWAGGER_JSON=/spec/kis-vault.openapi.yaml \
    -v $PWD:/spec \
    swaggerapi/swagger-ui

    Browse http://localhost:8080. The “Try it out” button works directly against any of the servers: URLs.

The spec is the source of truth — keep it in sync with the code.

Terminal window
# Lint
docker run --rm -v $PWD:/specs stoplight/spectral lint \
/specs/kis-vault.openapi.yaml
# Contract-test using openapi-cli or schemathesis
schemathesis run --checks all kis-vault.openapi.yaml \
--base-url https://127.0.0.1:7999 \
--request-tls-verify false

The CI gate runs both on every PR that touches the REST layer or the spec file.

The spec includes example bodies for every operation. Bruno’s post-response variable extraction (and Insomnia’s response chaining) lets you wire happy-path flows by hand:

Source operationCaptured fieldUsed by
POST /operator/node-token/issueres.body.tokenissuedTokenPOST /agent/join (in secret field)
POST /agent/joinres.body.refresh_tokenrefreshTokenPOST /agent/renew
POST /operator/identityres.body.spiffe_idlastIdentityDELETE /operator/identity/{spiffe}

In Bruno: open the request → Vars tab → Post-responseissuedToken: res.body.token. The variable persists across requests in the same run.

The recipes give you reproducible setups. The scenarios below are flows — sequences that exercise multiple subsystems together.

6.1 Cold-start: server, then agent, then workload

Section titled “6.1 Cold-start: server, then agent, then workload”
StepActionExpected
1Start vault.svc/healthz returns 200 in <2s
2Issue node-token200 with hj_v1_... body
3Run vault-agent init with that token/var/lib/horus/node.crt, node.key, trust_bundle.pem, refresh.dat all created
4Run vault-agent runAgent’s /healthz reachable; systemd reports active; sd_notify READY fires
5Register workload identity for curl’s binary200
6curl --unix-socket .../workload/identity200 with SPIFFE ID

If any step fails, the next ones can’t succeed — diagnose top-down.

StepActionExpected
1Set node_cert_lifetime: 5m in vault.svc.yaml so renewal kicks in fastrestart server
2Run vault-agent for 7 minutesAt ~150s the agent’s stderr logs rotation due then node cert renewed; lifetime: ...
3While rotating, run a tight loop of kis vault status from another shellEvery call returns 200; no errors during the cert swap

This catches: race conditions in agent identity reload, mTLS-cache misses on the server side.

6.3 Server failure → agent serves cached creds

Section titled “6.3 Server failure → agent serves cached creds”
StepActionExpected
1Bring up §3.2 setupHealthy
2Stop vault.svc (pkill vault.svc)Agent’s /healthz still 200
3Call /workload/jwt via agentEither succeeds from cache OR returns a clear “server backend: … not reachable” 5xx — never a hang
4Wait beyond cache TTL, retryClear 5xx
5Restart vault.svcNext call succeeds without manual intervention

This catches: the agent must not crash when the server is gone (degraded-mode contract).

StepActionExpected
1Issue node-token200
2vault-agent init with it200
3vault-agent init AGAIN with the same token401 with ErrTokenAlreadyUsed shape
4List tokens via operator endpointThe consumed token doesn’t appear (or shows uses_left:0)
StepActionExpected
1§3.5: Run with fsvault. Write 10 secrets.All readable.
2Stop server. Switch config to openbao.(Backend has no data yet — expected.)
3Start server, attempt to read a secret404
4Run kis vault backend migrate --source fsvault --target openbao (currently 501 — see pending list)When implemented: produces manifest, copies values
5Verify via direct OpenBao CLISame 10 secrets present

The migration command is scaffolded; until it’s implemented, this scenario serves as the acceptance test for that future work.

StepActionExpected
1Issue 5 node-tokens via the operator endpoint5×200
2Query the audit chain (via the audit store directly until /operator/audit/query lands)5 entries, each chained to the previous via prev_hash
3Hex-dump one entry’s signature in the audit store’s storage; flip one bitTamper
4Re-run audit verifyChain breaks at the tampered entry

This catches a class of “silently lost audit” bugs — the chain must refuse to validate if any link is mutated.

6.7 File-mode delivery survives Postgres restart

Section titled “6.7 File-mode delivery survives Postgres restart”
StepActionExpected
1§3.8 setupPostgres comes up with cert from /var/lib/postgresql/server.crt
2Kill Postgres mid-pg_dumpTLS-mid-flight breaks; expected
3Wait for next agent rotation tickNew cert written atomically
4Start Postgres againPostgres reads new cert; serves cleanly
5Inspect /var/lib/postgresql/server.crt’s notAfterAt least the configured lifetime from now

Symptom → likely-cause → fastest fix.

SymptomLikely causeFix
connection refused on /healthzserver not listeninglsof -i :7999; check stderr
bad certificate from serverclient cert not signed by trusted CARe-issue using the test CA, or pass --cacert
Agent socket 401 “workload attestation failed”no registration entry matches the calling binary’s selectorsPOST /operator/identity with the binary’s path / systemd unit
Agent 401 ALWAYSrunning on macOS — attest.Attest is Linux-onlyUse the Docker setup (§3.3) for attestation testing
socket /run/vault/vault.sock unreachableagent not running, or /run/horus ownership wrongsystemctl status vault-agent; ls -ld /run/horus
agent /workload/jwt: HTTP 501(legacy path is 200 — see what changed)If you hit /workload/secret/get, that’s currently 501 by design
awskms: GetPublicKey ... no route to hostconstructor tried to dial AWS; you’re running on a laptopUse type: inproc or type: pkcs11 for non-cloud tests
Sign: pkcs11: 0x132: CKR_GENERAL_ERRORSoftHSM key was generated with mismatched mechanismRe-gen with --key-type RSA:4096
vault-agent init succeeds but vault-agent run exits with node identity not on diskinit wrote to a different state dirBoth commands must agree on state_dir; default is /var/lib/horus
Server log spam: dead JWT cache for vault:NAMEthe cache layer logs every cache miss in infoLower log level or ignore — it’s not an error
transport=both not splitting trafficfeature not yet implemented (P2 pending)Use manual two-terminal compare until chassis canary lands

For DevSecOps signing off a release. Skip if you’re a customer dev — these are infra-validation tests.

Terminal window
# 1000 sequential JWT mints over the agent socket
time for i in $(seq 1 1000); do
curl -s --unix-socket /run/vault/vault.sock -X POST \
http://agent/workload/jwt \
-H 'Content-Type: application/json' \
-d '{"audience":["bench"]}' >/dev/null
done

Expected (laptop, inproc signer): ~30–50ms p50 per call, no errors. Expected (PKCS#11/SoftHSM): ~80–120ms p50 — SoftHSM is software, but the protocol overhead is real.

Leave the §3.10 HA setup running for 24 hours under a steady 10 rps. Verify:

  • No memory growth over 100 MB per process
  • No goroutine leak (go tool pprof http://vault.svc:7999/debug/pprof/goroutine if pprof is enabled)
  • Rotation happens at the configured interval; no missed ticks
  • Audit chain has the expected event count (issuance × 10 rps × 86400 s = ~864k entries)
  • Kill -9 vault.svc at random times. Agent should never crash.
  • Drop the backend (docker stop openbao). Server should return 5xx from /secret/* with a clear message; /healthz itself should report degraded.
  • Clock skew: skew one host’s clock by +5 minutes. Cert validity windows should still accept the leaf within their not_before grace.
  • OOM the server: stress-ng --vm 4 --vm-bytes 80%. Server should restart cleanly (systemd Restart=always) and re-load identity material without re-bootstrapping.
#!/usr/bin/env bash
# Saves to ~/kis-vault-test and starts §3.2 (server + agent on same host).
set -euo pipefail
cd "$(mkdir -p ~/kis-vault-test && cd ~/kis-vault-test && pwd)"
[[ -f test-ca.crt ]] || {
openssl req -x509 -newkey rsa:2048 -nodes -keyout test-ca.key -out test-ca.crt \
-days 30 -subj "/CN=kis-test-ca"
openssl req -newkey rsa:2048 -nodes -keyout vault-svc.key -out vault-svc.csr \
-subj "/CN=vault.svc.local"
openssl x509 -req -in vault-svc.csr -CA test-ca.crt -CAkey test-ca.key -CAcreateserial \
-out vault-svc.crt -days 30 \
-extfile <(printf "subjectAltName=DNS:vault.svc.local,DNS:localhost,IP:127.0.0.1")
openssl genrsa -out horus-ca.key 4096
openssl req -x509 -new -key horus-ca.key -out horus-ca.crt -days 3650 \
-subj "/CN=kis Vault Root CA"
}
cat > vault.svc.yaml <<EOF
service: { name: vault.svc, http: { listenaddress: 127.0.0.1:7999 } }
horus:
vault: { type: fsvault, fsvault: { root_dir: ./fsvault-data } }
signer: { type: inproc, inproc: { ca_cert: ./horus-ca.crt, ca_key: ./horus-ca.key } }
tls:
ca_certs: ["./test-ca.crt"]
cert: "./vault-svc.crt"
key: "./vault-svc.key"
require_client_cert: true
EOF
mkdir -p fsvault-data /run/horus /var/lib/horus
echo "Server config at $PWD/vault.svc.yaml"
echo "Run: vault.svc -config ./vault.svc.yaml"
echo "Then: see §3.2 for agent bootstrap"
Terminal window
mkdir -p bruno/kis-vault/{environments,Health,Workload,Agent\ handshake,Operator,Legacy\ secret}
cat > bruno/kis-vault/bruno.json <<'EOF'
{"version":"1","name":"kis Vault","type":"collection","ignore":["node_modules",".git"]}
EOF
# environments/local-dev.bru and per-folder .bru files — copy from §5.3-5.4

After that, open Bruno → Collection → Open Collection → point at bruno/kis-vault/.

Done. If a request in this doc doesn’t behave as described, file an issue with the recipe number (§3.x) and the response body — that’s enough context to reproduce locally.