feat: integration test suite — 416 tests, 61% coverage

Add OpenBao and Kratos to docker-compose dev stack with bootstrap
seeding. Full integration tests hitting real services:

- Vault SDK: KV read/write/delete, re-auth on bad token, new_with_token
  constructor for dev mode
- Kratos SDK: list/get/create/disable/enable users, session listing
- Token store: PAT lifecycle with OpenBao backing, expiry handling
- Identity tools: full tool dispatch through Kratos admin API
- Gitea SDK: resolve_username, ensure_token (PAT auto-provisioning),
  list/get repos, issues, comments, branches, file content
- Devtools: tool dispatch for all gitea_* tools against live Gitea
- Archive indexer: batch flush, periodic flush task, edit/redact/reaction
  updates against OpenSearch
- Memory store: set/query/get_recent with user scoping in OpenSearch
- Room history: context retrieval by timestamp and event_id, access
  control enforcement
- Search archive: keyword search with room/sender filters, room scoping
- Code search: language filter, repo filter, branch scoping
- Breadcrumbs: symbol retrieval, empty index handling, token budget
- Bridge: full event lifecycle mapping, request ID filtering
- Evaluator: DM/mention/silence short-circuits, LLM evaluation path,
  reply-to-human suppression
- Agent registry: list/get_id, prompt reuse, prompt-change recreation
- Conversations: token tracking, multi-turn context recall, room
  isolation

Bug fixes caught by tests:
- AgentRegistry in-memory cache skipped hash comparison on prompt change
- KratosClient::set_state sent bare PUT without traits (400 error)
- find_code_session returns None on NULL conversation_id
This commit is contained in:
2026-03-24 14:34:03 +00:00
parent b3a38767e0
commit 5dc739b800
8 changed files with 3105 additions and 3 deletions

View File

@@ -40,10 +40,71 @@ if [ -z "$ACCESS_TOKEN" ]; then
DEVICE_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['device_id'])") DEVICE_ID=$(echo "$RESPONSE" | python3 -c "import sys,json; print(json.load(sys.stdin)['device_id'])")
fi fi
# ── OpenBao: seed KV secrets engine ──────────────────────────────────────
OPENBAO="http://localhost:8200"
VAULT_TOKEN="dev-root-token"
echo ""
echo "Waiting for OpenBao..."
until curl -sf "$OPENBAO/v1/sys/health" >/dev/null 2>&1; do
sleep 1
done
echo "OpenBao is ready."
# Write a test secret for integration tests
echo "Seeding OpenBao KV..."
curl -sf -X POST "$OPENBAO/v1/secret/data/sol-test" \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"data":{"key":"test-secret-value","note":"seeded by bootstrap.sh"}}' \
> /dev/null 2>&1 && echo " ✓ secret/sol-test" || echo " sol-test (already exists or failed)"
# Write a test token path
curl -sf -X POST "$OPENBAO/v1/secret/data/sol-tokens/testuser/gitea" \
-H "X-Vault-Token: $VAULT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"data":{"token":"test-gitea-pat-12345","token_type":"pat","refresh_token":"","expires_at":""}}' \
> /dev/null 2>&1 && echo " ✓ secret/sol-tokens/testuser/gitea" || echo " token (already exists or failed)"
# ── Kratos: seed test identities ────────────────────────────────────────
KRATOS_ADMIN="http://localhost:4434"
echo ""
echo "Waiting for Kratos..."
until curl -sf "$KRATOS_ADMIN/admin/health/ready" >/dev/null 2>&1; do
sleep 1
done
echo "Kratos is ready."
echo "Seeding Kratos identities..."
curl -sf -X POST "$KRATOS_ADMIN/admin/identities" \
-H "Content-Type: application/json" \
-d '{"schema_id":"default","traits":{"email":"sienna@sunbeam.local","name":{"first":"Sienna","last":"V"}}}' \
> /dev/null 2>&1 && echo " ✓ sienna@sunbeam.local" || echo " sienna (already exists or failed)"
curl -sf -X POST "$KRATOS_ADMIN/admin/identities" \
-H "Content-Type: application/json" \
-d '{"schema_id":"default","traits":{"email":"lonni@sunbeam.local","name":{"first":"Lonni","last":"B"}}}' \
> /dev/null 2>&1 && echo " ✓ lonni@sunbeam.local" || echo " lonni (already exists or failed)"
curl -sf -X POST "$KRATOS_ADMIN/admin/identities" \
-H "Content-Type: application/json" \
-d '{"schema_id":"default","traits":{"email":"amber@sunbeam.local","name":{"first":"Amber","last":"K"}}}' \
> /dev/null 2>&1 && echo " ✓ amber@sunbeam.local" || echo " amber (already exists or failed)"
# ── Summary ─────────────────────────────────────────────────────────────
echo "" echo ""
echo "Add these to your .env or export them:" echo "Add these to your .env or export them:"
echo "" echo ""
echo "export SOL_MATRIX_ACCESS_TOKEN=\"$ACCESS_TOKEN\"" echo "export SOL_MATRIX_ACCESS_TOKEN=\"$ACCESS_TOKEN\""
echo "export SOL_MATRIX_DEVICE_ID=\"$DEVICE_ID\"" echo "export SOL_MATRIX_DEVICE_ID=\"$DEVICE_ID\""
echo "" echo ""
echo "Services:"
echo " Tuwunel: $HOMESERVER"
echo " OpenBao: $OPENBAO (token: $VAULT_TOKEN)"
echo " Kratos: $KRATOS_ADMIN"
echo ""
echo "Then restart Sol: docker compose -f docker-compose.dev.yaml restart sol" echo "Then restart Sol: docker compose -f docker-compose.dev.yaml restart sol"

33
dev/identity.schema.json Normal file
View File

@@ -0,0 +1,33 @@
{
"$id": "https://schemas.sunbeam.pt/identity.default.schema.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "Default Identity Schema",
"type": "object",
"properties": {
"traits": {
"type": "object",
"properties": {
"email": {
"type": "string",
"format": "email",
"title": "Email",
"ory.sh/kratos": {
"credentials": {
"password": { "identifier": true }
},
"recovery": { "via": "email" }
}
},
"name": {
"type": "object",
"properties": {
"first": { "type": "string", "title": "First Name" },
"last": { "type": "string", "title": "Last Name" }
}
}
},
"required": ["email"],
"additionalProperties": false
}
}
}

37
dev/kratos.yml Normal file
View File

@@ -0,0 +1,37 @@
version: v1.3.1
dsn: sqlite:///var/lib/sqlite/kratos.db?_fk=true&mode=rwc
serve:
public:
base_url: http://localhost:4433/
cors:
enabled: true
admin:
base_url: http://localhost:4434/
selfservice:
default_browser_return_url: http://localhost:4433/
flows:
registration:
enabled: true
ui_url: http://localhost:4433/registration
login:
ui_url: http://localhost:4433/login
recovery:
enabled: true
ui_url: http://localhost:4433/recovery
identity:
default_schema_id: default
schemas:
- id: default
url: file:///etc/kratos/identity.schema.json
log:
level: warning
format: text
courier:
smtp:
connection_uri: smtp://localhost:1025/?disable_starttls=true

View File

@@ -68,7 +68,52 @@ services:
timeout: 5s timeout: 5s
retries: 10 retries: 10
openbao:
image: quay.io/openbao/openbao:2.5.1
cap_add:
- IPC_LOCK
environment:
- BAO_DEV_ROOT_TOKEN_ID=dev-root-token
- BAO_DEV_LISTEN_ADDRESS=0.0.0.0:8200
ports:
- "8200:8200"
healthcheck:
test: ["CMD", "bao", "status", "-address=http://127.0.0.1:8200"]
interval: 5s
timeout: 3s
retries: 10
kratos-migrate:
image: oryd/kratos:v1.3.1
command: migrate sql -e --yes
environment:
- DSN=sqlite:///var/lib/sqlite/kratos.db?_fk=true&mode=rwc
volumes:
- ./dev/kratos.yml:/etc/kratos/kratos.yml:ro
- ./dev/identity.schema.json:/etc/kratos/identity.schema.json:ro
- kratos-data:/var/lib/sqlite
kratos:
image: oryd/kratos:v1.3.1
command: serve -c /etc/kratos/kratos.yml --dev --watch-courier
depends_on:
kratos-migrate:
condition: service_completed_successfully
ports:
- "4433:4433" # public
- "4434:4434" # admin
volumes:
- ./dev/kratos.yml:/etc/kratos/kratos.yml:ro
- ./dev/identity.schema.json:/etc/kratos/identity.schema.json:ro
- kratos-data:/var/lib/sqlite
healthcheck:
test: ["CMD-SHELL", "wget -qO- http://localhost:4434/admin/health/ready || exit 1"]
interval: 5s
timeout: 3s
retries: 10
volumes: volumes:
opensearch-data: opensearch-data:
tuwunel-data: tuwunel-data:
gitea-data: gitea-data:
kratos-data:

View File

@@ -59,9 +59,29 @@ impl AgentRegistry {
let current_instructions = definitions::orchestrator_instructions(system_prompt, active_agents); let current_instructions = definitions::orchestrator_instructions(system_prompt, active_agents);
let current_hash = instructions_hash(&current_instructions); let current_hash = instructions_hash(&current_instructions);
// Check in-memory cache // Check in-memory cache — but verify instructions haven't changed
if let Some(agent) = agents.get(&agent_name) { if let Some(agent) = agents.get(&agent_name) {
return Ok((agent.id.clone(), false)); // Compare stored hash in SQLite against current hash
if let Some((_id, stored_hash)) = self.store.get_agent(&agent_name) {
if stored_hash == current_hash {
return Ok((agent.id.clone(), false));
}
// Hash mismatch — prompt changed at runtime. Delete and recreate.
info!(
old_hash = stored_hash.as_str(),
new_hash = current_hash.as_str(),
"System prompt changed at runtime — recreating orchestrator agent"
);
let old_id = agent.id.clone();
agents.remove(&agent_name);
if let Err(e) = mistral.delete_agent_async(&old_id).await {
warn!("Failed to delete stale orchestrator agent: {}", e.message);
}
self.store.delete_agent(&agent_name);
} else {
// In-memory but not in SQLite (shouldn't happen) — trust cache
return Ok((agent.id.clone(), false));
}
} }
// Check SQLite for persisted agent ID // Check SQLite for persisted agent ID

File diff suppressed because it is too large Load Diff

View File

@@ -237,9 +237,16 @@ impl KratosClient {
async fn set_state(&self, email_or_id: &str, state: &str) -> Result<Identity, String> { async fn set_state(&self, email_or_id: &str, state: &str) -> Result<Identity, String> {
let id = self.resolve_id(email_or_id).await?; let id = self.resolve_id(email_or_id).await?;
// Fetch current identity first — PUT replaces the whole resource
let current = self.get_user(&id).await?;
let url = format!("{}/admin/identities/{}", self.admin_url, id); let url = format!("{}/admin/identities/{}", self.admin_url, id);
let body = serde_json::json!({ "state": state }); let body = serde_json::json!({
"schema_id": "default",
"state": state,
"traits": current.traits,
});
let resp = self let resp = self
.http .http
.put(&url) .put(&url)

View File

@@ -47,6 +47,18 @@ impl VaultClient {
} }
} }
/// Create a VaultClient with a pre-set token (for dev mode / testing).
/// Skips Kubernetes auth entirely.
pub fn new_with_token(url: &str, kv_mount: &str, token: &str) -> Self {
Self {
url: url.trim_end_matches('/').to_string(),
role: String::new(),
kv_mount: kv_mount.to_string(),
http: HttpClient::new(),
token: Mutex::new(Some(token.to_string())),
}
}
/// Authenticate with OpenBao via Kubernetes auth method. /// Authenticate with OpenBao via Kubernetes auth method.
/// Reads the service account JWT from the mounted token file. /// Reads the service account JWT from the mounted token file.
async fn authenticate(&self) -> Result<String, String> { async fn authenticate(&self) -> Result<String, String> {