Extracted admin endpoints from chain_vault.py (2,178 lines) into
wallet_admin.py (768 lines). chain_vault.py is now 1,469 lines.
What moved to wallet_admin.py (29 routes):
- API keys: /api-keys, /api-keys/revoke
- Alerts: /alerts, /alerts/delete
- Webhooks: /webhooks, /webhooks/{id}, /webhooks/{id}/test,
/webhooks/{id}/retry, /webhooks/deliveries
- Audit trail: /audit-trail
- Bulk ops: /bulk/filter, /bulk/delete, /bulk/export, /export
- 2FA: /admin/2fa/{setup,verify-setup,disable,status}
- Config: /config
- Proof of Generation: /proof/{commit,provenance,verify,roots,stats}
What stayed in chain_vault.py (32 routes):
- Chain metadata: /chains, /stats, /healthz, /health-score
- RPC config: /rpc-chains
- Wallet gen: /generate, /generate/batch, /generate/all,
/import, /from-mnemonic, /derive-address, /hd-wallet
- Vault CRUD: /vault, /vault/{id}, /vault/{id}/full, DELETE
- Wallet ops: /tree, /cluster, /rotate, /rotate-sweep, /rotations,
/distribute, /sweep, /escrow, /escrow/release, /paper-wallet, /tx
- Validation: /validate/{chain}/{address}, /validate/all
- Balances: /balances, /balances/snapshot
- PDF: /paper-wallet/{id}/pdf, /wallet/{id}/birth-certificate
Helpers extracted to _persistent_store.py:
- _PersistentStore class (webhooks/alerts/payments SQLite store)
P3-7 fix: removed dead _webhooks global references in test/retry
endpoints — now uses _PersistentStore.all('webhooks')
Added to wallet_admin.py:
- _require_totp() helper (also kept in chain_vault.py for the
/vault/{id}/full endpoint that needs it)
- WebhookCreateRequest, BulkFilterRequest, BulkDeleteRequest models
(these were inlined in original chain_vault.py body — now in
the request schemas section)
P3-10 — WP plugin supported_chains() rewritten
Plugin used to advertise chains the backend doesn't support
('bitcoin' vs backend 'btc', 'ethereum' vs 'eth', etc.). Rewrote
to use correct backend keys + added a 'backend' field for clarity.
Now matches ADDRESS_GENERATION.md truth table.
main.py: updated import + include_router for wallet_admin.router.
Test results: 80 passed, 5 skipped (no regressions).
Refs: AUDIT.md P2-16, P3-7, P3-10, P3-17
372 lines
13 KiB
Python
372 lines
13 KiB
Python
"""Integration tests: chain-vault API endpoints."""
|
|
import pytest
|
|
|
|
|
|
def test_health(client):
|
|
resp = client.get("/health")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "ok"
|
|
assert data["service"] == "walletpress"
|
|
|
|
|
|
def test_healthz(client):
|
|
resp = client.get("/api/v1/chain-vault/healthz")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["status"] == "ok"
|
|
assert "timestamp" in data
|
|
|
|
|
|
def test_root(client):
|
|
resp = client.get("/")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["service"] == "WalletPress API"
|
|
|
|
|
|
def test_list_chains(client):
|
|
resp = client.get("/api/v1/chain-vault/chains")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
chains = data if isinstance(data, dict) and "chains" in data else data
|
|
assert len(chains) > 0
|
|
|
|
|
|
def test_get_stats(client, admin_headers):
|
|
resp = client.get("/api/v1/chain-vault/stats", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "vault" in data
|
|
assert "encryption_enabled" in data
|
|
|
|
|
|
@pytest.mark.parametrize("chain", ["eth", "sol", "btc", "trx", "base", "polygon", "bsc"])
|
|
def test_generate_wallet(client, admin_headers, chain):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": chain, "count": 1,
|
|
}, headers=admin_headers)
|
|
if resp.status_code in (400, 402):
|
|
pytest.skip(f"{chain}: {resp.json().get('detail', resp.json())}")
|
|
assert resp.status_code == 200, f"{chain}: {resp.json()}"
|
|
data = resp.json()
|
|
assert data["generated"] >= 1
|
|
assert data["wallets"][0]["address"]
|
|
|
|
|
|
def test_generate_batch(client, admin_headers):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 3,
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["generated"] == 3
|
|
assert len(data["wallets"]) == 3
|
|
|
|
|
|
def test_generate_all(client, admin_headers):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "all", "count": 1,
|
|
}, headers=admin_headers)
|
|
if resp.status_code == 402:
|
|
pytest.skip("License tier blocks 'all' chains")
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["generated"] >= 1
|
|
|
|
|
|
def test_generate_with_qr(client, admin_headers):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1, "include_qr": True,
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_from_mnemonic(client, admin_headers):
|
|
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
resp = client.post("/api/v1/chain-vault/derive-address", json={
|
|
"mnemonic": mnemonic, "chain": "eth",
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["chain"] == "eth"
|
|
assert data["mnemonic"].startswith("abandon")
|
|
assert data["address"].startswith("0x")
|
|
|
|
|
|
def test_vault_list(client, admin_headers):
|
|
client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
resp = client.get("/api/v1/chain-vault/vault", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "wallets" in data
|
|
assert data["total"] >= 1
|
|
|
|
|
|
def test_vault_get(client, admin_headers):
|
|
gen = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
wallet_id = gen.json()["wallets"][0]["id"]
|
|
resp = client.get(f"/api/v1/chain-vault/vault/{wallet_id}", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["id"] == wallet_id
|
|
assert data["chain"] == "eth"
|
|
assert data["address"].startswith("0x")
|
|
|
|
|
|
def test_vault_get_full(client, admin_headers):
|
|
gen = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
wallet_id = gen.json()["wallets"][0]["id"]
|
|
resp = client.get(f"/api/v1/chain-vault/vault/{wallet_id}/full", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["id"] == wallet_id
|
|
assert "private_key_hex" in data
|
|
|
|
|
|
def test_validate_address(client, admin_headers):
|
|
resp = client.get("/api/v1/chain-vault/validate/eth/0xd8da6bf26964af9d7eed9e03e53415d37aa96045", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["valid"] is True
|
|
assert data["chain"] == "eth"
|
|
assert data["address"] == "0xd8da6bf26964af9d7eed9e03e53415d37aa96045"
|
|
|
|
|
|
def test_validate_all(client, admin_headers):
|
|
resp = client.get("/api/v1/chain-vault/validate/all?address=0xd8da6bf26964af9d7eed9e03e53415d37aa96045", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "possible_chains" in data or "results" in data or isinstance(data.get("address"), str)
|
|
|
|
|
|
def test_wallet_tree(client, admin_headers):
|
|
client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
resp = client.get("/api/v1/chain-vault/tree", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "tree" in data
|
|
assert "total_families" in data
|
|
assert data["total_wallets"] >= 1
|
|
|
|
|
|
def test_derive_address(client, admin_headers):
|
|
mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
|
|
resp = client.post("/api/v1/chain-vault/derive-address", json={
|
|
"mnemonic": mnemonic, "chain": "eth",
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["chain"] == "eth"
|
|
assert data["address"].startswith("0x")
|
|
assert data["derivation_path"] != ""
|
|
|
|
|
|
def test_api_keys_crud(client, admin_headers):
|
|
create = client.post("/api/v1/chain-vault/api-keys", json={
|
|
"label": "test-key", "scopes": ["wallet.read"],
|
|
}, headers=admin_headers)
|
|
assert create.status_code == 200
|
|
key_data = create.json()
|
|
assert "api_key_id" in key_data
|
|
assert "api_key" in key_data
|
|
|
|
list_resp = client.get("/api/v1/chain-vault/api-keys", headers=admin_headers)
|
|
assert list_resp.status_code == 200
|
|
list_data = list_resp.json()
|
|
keys = list_data.get("api_keys", list_data.get("keys", []))
|
|
assert len(keys) >= 1
|
|
|
|
|
|
def test_team_keys_crud(client, admin_headers):
|
|
create = client.post("/api/v1/team/keys?label=test-team-key&role=operator", headers=admin_headers)
|
|
assert create.status_code == 200
|
|
key_data = create.json()
|
|
assert "key_id" in key_data
|
|
assert "api_key" in key_data
|
|
|
|
list_resp = client.get("/api/v1/team/keys", headers=admin_headers)
|
|
assert list_resp.status_code == 200
|
|
list_data = list_resp.json()
|
|
assert "keys" in list_data
|
|
|
|
|
|
def test_rotate_wallet(client, admin_headers):
|
|
gen = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
wallet_id = gen.json()["wallets"][0]["id"]
|
|
resp = client.post("/api/v1/chain-vault/rotate", json={
|
|
"wallet_id": wallet_id, "reason": "test",
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["rotated"] is True
|
|
assert data["old_wallet_id"] == wallet_id
|
|
|
|
|
|
def test_temporal_wallet(client, admin_headers):
|
|
create = client.post("/api/v1/chain-vault/temporal/create", json={
|
|
"chain": "eth", "ttl_seconds": 3600,
|
|
}, headers=admin_headers)
|
|
assert create.status_code == 200
|
|
data = create.json()
|
|
assert "temporal_id" in data
|
|
assert data["chain"] == "eth"
|
|
|
|
|
|
def test_2fa_flow(client, admin_headers):
|
|
resp = client.get("/api/v1/chain-vault/admin/2fa/status", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "enabled" in data
|
|
|
|
|
|
def test_proof_of_generation(client, admin_headers):
|
|
gen = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
wallet_id = gen.json()["wallets"][0]["id"]
|
|
|
|
prov = client.get(f"/api/v1/chain-vault/proof/provenance/{wallet_id}", headers=admin_headers)
|
|
assert prov.status_code == 200
|
|
data = prov.json()
|
|
assert "attested" in data or "wallet_id" in data
|
|
|
|
|
|
def test_config_endpoint(client, admin_headers):
|
|
resp = client.get("/api/v1/chain-vault/config", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "version" in data
|
|
assert "features" in data
|
|
|
|
|
|
def test_delete_wallet(client, admin_headers):
|
|
gen = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
wallet_id = gen.json()["wallets"][0]["id"]
|
|
resp = client.delete(f"/api/v1/chain-vault/vault/{wallet_id}", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["deleted"] is True
|
|
assert data["wallet_id"] == wallet_id
|
|
|
|
|
|
def test_auth_required_for_mutations(client):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={"chain": "eth", "count": 1})
|
|
assert resp.status_code == 401, f"Expected 401, got {resp.status_code}: {resp.text[:200]}"
|
|
|
|
|
|
def test_invalid_api_key_rejected(client):
|
|
bad_headers = {"X-API-Key": "this-key-is-wrong"}
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=bad_headers)
|
|
assert resp.status_code in (401, 403)
|
|
|
|
|
|
def test_unsupported_chain_returns_error(client, admin_headers):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "nonexistent", "count": 1,
|
|
}, headers=admin_headers)
|
|
# License check may return 402 before chain validation, or 400 if chain is invalid
|
|
assert resp.status_code in (400, 402)
|
|
|
|
|
|
def test_empty_label_generates_default(client, admin_headers):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["wallets"][0]["label"] != ""
|
|
|
|
|
|
def test_generate_multiple_wallets(client, admin_headers):
|
|
resp = client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 5,
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert data["generated"] == 5
|
|
assert len(data["wallets"]) == 5
|
|
# each wallet must have a unique address
|
|
addresses = [w["address"] for w in data["wallets"]]
|
|
assert len(set(addresses)) == 5
|
|
|
|
|
|
def test_retention_portfolio(client, admin_headers):
|
|
client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
resp = client.get("/api/v1/portfolio", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "total_wallets" in data
|
|
|
|
|
|
def test_retention_groups(client, admin_headers):
|
|
resp = client.get("/api/v1/vault/groups", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "groups" in data
|
|
|
|
|
|
def test_wallet_memory_stubs(client, admin_headers):
|
|
resp = client.get("/api/v1/wallet-memory/cluster", headers=admin_headers)
|
|
assert resp.status_code in (200, 405, 404)
|
|
|
|
|
|
def test_webhook_crud(client, admin_headers):
|
|
create = client.post("/api/v1/chain-vault/webhooks", json={
|
|
"url": "https://example.com/webhook",
|
|
"events": ["wallet.generated"],
|
|
}, headers=admin_headers)
|
|
assert create.status_code == 200
|
|
data = create.json()
|
|
assert "webhook_id" in data
|
|
|
|
list_resp = client.get("/api/v1/chain-vault/webhooks", headers=admin_headers)
|
|
assert list_resp.status_code == 200
|
|
list_data = list_resp.json()
|
|
webhooks = list_data.get("webhooks", [])
|
|
assert len(webhooks) >= 1
|
|
|
|
wh_id = data["webhook_id"]
|
|
delete = client.delete(f"/api/v1/chain-vault/webhooks/{wh_id}", headers=admin_headers)
|
|
assert delete.status_code == 200
|
|
assert delete.json()["deleted"] is True
|
|
|
|
|
|
def test_bulk_filter(client, admin_headers):
|
|
client.post("/api/v1/chain-vault/generate", json={
|
|
"chain": "eth", "count": 1,
|
|
}, headers=admin_headers)
|
|
resp = client.post("/api/v1/chain-vault/bulk/filter", json={
|
|
"filters": {"chain": "eth"},
|
|
}, headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert "filtered" in data or "wallets" in data
|
|
|
|
|
|
def test_metrics(client, admin_headers):
|
|
resp = client.get("/metrics", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
|
|
|
|
def test_test_vectors(client, admin_headers):
|
|
resp = client.get("/api/v1/test-vectors", headers=admin_headers)
|
|
assert resp.status_code == 200
|
|
data = resp.json()
|
|
assert isinstance(data, dict) or isinstance(data, list)
|