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'])")
|
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
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
|
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:
|
||||||
|
|||||||
@@ -59,10 +59,30 @@ 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(¤t_instructions);
|
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) {
|
if let Some(agent) = agents.get(&agent_name) {
|
||||||
|
// 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));
|
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
|
||||||
if let Some((agent_id, stored_hash)) = self.store.get_agent(&agent_name) {
|
if let Some((agent_id, stored_hash)) = self.store.get_agent(&agent_name) {
|
||||||
|
|||||||
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> {
|
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)
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
Reference in New Issue
Block a user