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:
@@ -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'])")
|
||||
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 "Add these to your .env or export them:"
|
||||
echo ""
|
||||
echo "export SOL_MATRIX_ACCESS_TOKEN=\"$ACCESS_TOKEN\""
|
||||
echo "export SOL_MATRIX_DEVICE_ID=\"$DEVICE_ID\""
|
||||
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"
|
||||
|
||||
33
dev/identity.schema.json
Normal file
33
dev/identity.schema.json
Normal 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
37
dev/kratos.yml
Normal 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
|
||||
@@ -68,7 +68,52 @@ services:
|
||||
timeout: 5s
|
||||
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:
|
||||
opensearch-data:
|
||||
tuwunel-data:
|
||||
gitea-data:
|
||||
kratos-data:
|
||||
|
||||
@@ -59,9 +59,29 @@ impl AgentRegistry {
|
||||
let current_instructions = definitions::orchestrator_instructions(system_prompt, active_agents);
|
||||
let current_hash = instructions_hash(¤t_instructions);
|
||||
|
||||
// Check in-memory cache
|
||||
// Check in-memory cache — but verify instructions haven't changed
|
||||
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
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -237,9 +237,16 @@ impl KratosClient {
|
||||
|
||||
async fn set_state(&self, email_or_id: &str, state: &str) -> Result<Identity, String> {
|
||||
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 body = serde_json::json!({ "state": state });
|
||||
let body = serde_json::json!({
|
||||
"schema_id": "default",
|
||||
"state": state,
|
||||
"traits": current.traits,
|
||||
});
|
||||
let resp = self
|
||||
.http
|
||||
.put(&url)
|
||||
|
||||
@@ -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.
|
||||
/// Reads the service account JWT from the mounted token file.
|
||||
async fn authenticate(&self) -> Result<String, String> {
|
||||
|
||||
Reference in New Issue
Block a user