test(crypto+db+cli): add master key, crypto, credentials_repo, and CLI tests

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 10:51:29 +08:00
parent 07adc98e8c
commit e7f41d6e27
7 changed files with 513 additions and 1 deletions

View File

@@ -15,6 +15,13 @@ _scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__
if _scripts not in sys.path: if _scripts not in sys.path:
sys.path.insert(0, _scripts) sys.path.insert(0, _scripts)
try:
import cryptography # noqa: F401
except ImportError as e:
raise RuntimeError(
"Stage B requires 'cryptography'. pip install cryptography --break-system-packages"
) from e
import pytest import pytest

View File

@@ -0,0 +1,150 @@
# -*- coding: utf-8 -*-
import json
import os
import subprocess
import sys
import tempfile
from dataclasses import dataclass
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
MAIN_PY = os.path.join(_scripts, "main.py")
def _iso_cli_env(data_root: str, uid: str = "cli_u") -> dict:
"""Align skill roots so subprocess + in-process tests hit the same DB."""
return {
"JIANGCHANG_DATA_ROOT": data_root,
"JIANGCHANG_USER_ID": uid,
"CLAW_DATA_ROOT": data_root,
"CLAW_USER_ID": uid,
}
@dataclass
class _ProcOut:
returncode: int
stdout: str
stderr: str
def _run_cli(env: dict, args: list[str], stdin: str | None = None) -> _ProcOut:
merged = {**os.environ, **env}
merged.pop("ACCOUNT_MANAGER_MASTER_KEY", None)
inp = stdin.encode("utf-8") if stdin is not None else None
r = subprocess.run(
[sys.executable, MAIN_PY, *args],
input=inp,
capture_output=True,
env=merged,
cwd=os.path.dirname(_scripts),
)
stdout_txt = r.stdout.decode("utf-8") if r.stdout else ""
stderr_txt = r.stderr.decode("utf-8", errors="replace") if r.stderr else ""
return _ProcOut(returncode=r.returncode, stdout=stdout_txt, stderr=stderr_txt)
def test_init_master_key_writes_file(monkeypatch):
tmp = tempfile.mkdtemp(prefix="cli_mk_")
data_root = os.path.join(tmp, "dr")
os.makedirs(data_root)
env = _iso_cli_env(data_root)
proc = _run_cli(env, ["account", "init-master-key"])
assert proc.returncode == 0
payload = json.loads(proc.stdout.strip().splitlines()[0])
assert payload["success"] is True
assert os.path.isfile(payload["path"])
def test_init_master_key_rejects_duplicate(monkeypatch):
tmp = tempfile.mkdtemp(prefix="cli_mk_d_")
data_root = os.path.join(tmp, "dr")
os.makedirs(data_root)
env = _iso_cli_env(data_root)
p1 = _run_cli(env, ["account", "init-master-key"])
assert p1.returncode == 0
p2 = _run_cli(env, ["account", "init-master-key"])
assert p2.returncode == 0
payload = json.loads(p2.stdout.strip().splitlines()[0])
assert payload["success"] is False
assert payload["error"]["code"] == "ERR_MASTER_KEY_EXISTS"
def test_export_import_roundtrip(monkeypatch):
tmp = tempfile.mkdtemp(prefix="cli_ex_")
data_root = os.path.join(tmp, "dr")
os.makedirs(data_root)
env = _iso_cli_env(data_root)
init = _run_cli(env, ["account", "init-master-key"])
assert init.returncode == 0
# Insert encrypted credential via repo in-process (same paths as CLI)
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", "cli_u")
monkeypatch.setenv("CLAW_DATA_ROOT", data_root)
monkeypatch.setenv("CLAW_USER_ID", "cli_u")
monkeypatch.delenv("ACCOUNT_MANAGER_MASTER_KEY", raising=False)
from util.constants import SKILL_SLUG
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_cli_inline")
from db.connection import get_conn, init_db
from db.credentials_repo import insert_credential_encrypted, read_credential_plaintext
init_db()
conn = get_conn()
try:
cid = insert_credential_encrypted(
conn,
account_id=None,
platform_key="icbc_sim",
credential_label="ICBC password label",
credential_type="password",
plaintext="roundtrip-secret",
expires_at=None,
extra_json="{}",
now=1_750_000_000,
)
conn.commit()
finally:
conn.close()
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM credentials")
assert int(cur.fetchone()[0]) == 1
finally:
conn.close()
ex = _run_cli(env, ["account", "export-credentials", "--plaintext"])
assert ex.returncode == 0
arr = json.loads(ex.stdout.strip())
assert len(arr) == 1
assert arr[0]["plaintext"] == "roundtrip-secret"
imp = _run_cli(
env,
["account", "import-credentials", "--replace-all"],
stdin=json.dumps(arr, ensure_ascii=False),
)
assert imp.returncode == 0
conn = get_conn()
try:
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM credentials WHERE secret_storage='local_encrypted'")
assert int(cur.fetchone()[0]) == 1
cur.execute("SELECT id FROM credentials LIMIT 1")
new_id = int(cur.fetchone()[0])
pt = read_credential_plaintext(conn, new_id)
assert pt == "roundtrip-secret"
finally:
conn.close()

View File

@@ -0,0 +1,143 @@
# -*- coding: utf-8 -*-
import pytest
from cryptography.fernet import Fernet
from db.accounts_repo import insert_credential
class TestCredentialsRepo:
def test_encrypted_insert_and_read(self, monkeypatch, clean_db):
from db.credentials_repo import (
insert_credential_encrypted,
list_credentials_by_account,
read_credential_plaintext,
)
from service.master_key import ENV_MASTER_KEY
key = Fernet.generate_key()
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
conn = clean_db
now = 1_700_000_000
aid = insert_credential_encrypted(
conn,
account_id=7,
platform_key="icbc_sim",
credential_label="工行密码",
credential_type="password",
plaintext="my-password",
expires_at=None,
extra_json="{}",
now=now,
)
conn.commit()
pt = read_credential_plaintext(conn, aid)
assert pt == "my-password"
assert "my-password" not in "".join(
str(v) for v in list_credentials_by_account(conn, 7)
)
def test_insert_validation(self, monkeypatch, clean_db):
from db.credentials_repo import insert_credential_encrypted, insert_credential_external
from service.master_key import ENV_MASTER_KEY
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
conn = clean_db
now = 1
with pytest.raises(ValueError, match="credential_type"):
insert_credential_encrypted(
conn,
account_id=None,
platform_key="x",
credential_label="l",
credential_type="weird",
plaintext="x",
expires_at=None,
extra_json="{}",
now=now,
)
with pytest.raises(ValueError, match="plaintext"):
insert_credential_encrypted(
conn,
account_id=None,
platform_key="x",
credential_label="l",
credential_type="password",
plaintext="",
expires_at=None,
extra_json="{}",
now=now,
)
with pytest.raises(ValueError, match="secret_storage"):
insert_credential_external(
conn,
account_id=None,
platform_key="x",
credential_label="l",
credential_type="password",
secret_storage="bogus",
secret_ref=None,
secret_mask="",
expires_at=None,
extra_json="{}",
now=now,
)
def test_list_has_no_sensitive_keys(self, monkeypatch, clean_db):
from db.credentials_repo import insert_credential_encrypted, list_credentials_by_account
from service.master_key import ENV_MASTER_KEY
monkeypatch.setenv(ENV_MASTER_KEY, Fernet.generate_key().decode("ascii"))
conn = clean_db
now = 2
insert_credential_encrypted(
conn,
account_id=9,
platform_key="p",
credential_label="a",
credential_type="api_key",
plaintext="k1",
expires_at=None,
extra_json="{}",
now=now,
)
insert_credential_encrypted(
conn,
account_id=9,
platform_key="p",
credential_label="b",
credential_type="api_token",
plaintext="k2",
expires_at=None,
extra_json="{}",
now=now + 1,
)
conn.commit()
rows = list_credentials_by_account(conn, 9)
assert len(rows) == 2
for d in rows:
assert "plaintext" not in d
assert "secret_ciphertext" not in d
def test_none_storage_read_fails(self, clean_db):
from db.credentials_repo import CredentialNotEncryptedError, read_credential_plaintext
conn = clean_db
now = 3
cid = insert_credential(
conn,
account_id=None,
platform_key="p",
credential_label="n",
credential_type="note",
secret_storage="none",
secret_ref=None,
secret_mask="",
expires_at=None,
extra_json="{}",
now=now,
)
conn.commit()
with pytest.raises(CredentialNotEncryptedError) as ei:
read_credential_plaintext(conn, cid)
assert ei.value.code == "ERR_CREDENTIAL_NOT_ENCRYPTED"

47
tests/test_crypto.py Normal file
View File

@@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
import os
import sys
import tempfile
import pytest
from cryptography.fernet import Fernet
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from service.crypto import CredentialDecryptError, decrypt_secret, encrypt_secret # noqa: E402
from service.master_key import ENV_MASTER_KEY # noqa: E402
@pytest.fixture()
def crypto_env(monkeypatch):
tmp = tempfile.mkdtemp(prefix="crypto_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root, exist_ok=True)
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", "c_uid")
key = Fernet.generate_key()
monkeypatch.setenv(ENV_MASTER_KEY, key.decode("ascii"))
yield key
def test_encrypt_decrypt_roundtrip(crypto_env):
pt = "my-password-123"
ct = encrypt_secret(pt)
assert decrypt_secret(ct) == pt
def test_decrypt_invalid_token(crypto_env):
with pytest.raises(CredentialDecryptError) as ei:
decrypt_secret("AAAA-invalid-token-AAAA")
assert ei.value.code == "ERR_CREDENTIAL_DECRYPT_FAILED"
def test_decrypt_wrong_master_key(monkeypatch, crypto_env):
pt = "secret-value"
ct = encrypt_secret(pt)
other = Fernet.generate_key().decode("ascii")
monkeypatch.setenv(ENV_MASTER_KEY, other)
with pytest.raises(CredentialDecryptError):
decrypt_secret(ct)

60
tests/test_master_key.py Normal file
View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
import os
import sys
import tempfile
import pytest
from cryptography.fernet import Fernet
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from service.master_key import ( # noqa: E402
ENV_MASTER_KEY,
MasterKeyMissingError,
load_master_key,
write_master_key_file,
)
@pytest.fixture()
def iso_mk(monkeypatch):
tmp = tempfile.mkdtemp(prefix="mk_test_")
data_root = os.path.join(tmp, "claw-data")
os.makedirs(data_root, exist_ok=True)
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", "mk_uid")
monkeypatch.delenv(ENV_MASTER_KEY, raising=False)
yield tmp
def test_env_priority_over_file(iso_mk, monkeypatch):
key_env = Fernet.generate_key()
key_file = Fernet.generate_key()
monkeypatch.setenv(ENV_MASTER_KEY, key_env.decode("ascii"))
write_master_key_file(key_file, allow_overwrite=True)
assert load_master_key() == key_env
def test_file_fallback(iso_mk):
key_file = Fernet.generate_key()
write_master_key_file(key_file, allow_overwrite=True)
assert load_master_key() == key_file
def test_missing_raises(iso_mk):
with pytest.raises(MasterKeyMissingError) as ei:
load_master_key()
assert ei.value.code == "ERR_MASTER_KEY_MISSING"
def test_invalid_env_falls_back_to_file(iso_mk, monkeypatch):
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
with pytest.raises(MasterKeyMissingError):
load_master_key()
key_file = Fernet.generate_key()
write_master_key_file(key_file, allow_overwrite=True)
monkeypatch.setenv(ENV_MASTER_KEY, "not-a-real-key")
assert load_master_key() == key_file

View File

@@ -130,13 +130,14 @@ class TestFreshSchema:
assert "default_auth_strategy" in cols_plat assert "default_auth_strategy" in cols_plat
cols_cred = _column_names(conn, "credentials") cols_cred = _column_names(conn, "credentials")
assert "account_id" in cols_cred assert "account_id" in cols_cred
assert "secret_ciphertext" in cols_cred
cur = conn.cursor() cur = conn.cursor()
cur.execute( cur.execute(
"SELECT value FROM _schema_meta WHERE key = ?", "SELECT value FROM _schema_meta WHERE key = ?",
("schema_version",), ("schema_version",),
) )
row = cur.fetchone() row = cur.fetchone()
assert row is not None and row[0] == "2" assert row is not None and row[0] == "2.1"
finally: finally:
conn.close() conn.close()

View File

@@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
import os
import sqlite3
import sys
import tempfile
import pytest
_scripts = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "scripts")
if _scripts not in sys.path:
sys.path.insert(0, _scripts)
from util.constants import SKILL_SLUG # noqa: E402
def _db_path(root: str, uid: str) -> str:
return os.path.join(root, uid, SKILL_SLUG, "account-manager.db")
def _prep_logging():
from util.logging_config import setup_skill_logging
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_schema_v21")
def _column_names(conn: sqlite3.Connection, table: str) -> list[str]:
cur = conn.cursor()
cur.execute(f"PRAGMA table_info({table})")
return [row[1] for row in cur.fetchall()]
@pytest.fixture()
def iso_v21(monkeypatch):
with tempfile.TemporaryDirectory(prefix="acc_mgr_v21_", ignore_cleanup_errors=True) as tmp:
data_root = os.path.join(tmp, "data-root")
os.makedirs(data_root)
uid = "v21_uid"
monkeypatch.setenv("JIANGCHANG_DATA_ROOT", data_root)
monkeypatch.setenv("JIANGCHANG_USER_ID", uid)
db_path = _db_path(data_root, uid)
os.makedirs(os.path.dirname(db_path), exist_ok=True)
_prep_logging()
yield {"tmp": tmp, "db_path": db_path}
class TestSchemaV21:
def test_fresh_has_secret_ciphertext_and_meta(self, iso_v21):
from db.connection import get_conn, init_db
if os.path.isfile(iso_v21["db_path"]):
os.remove(iso_v21["db_path"])
init_db()
conn = get_conn()
try:
assert "secret_ciphertext" in _column_names(conn, "credentials")
cur = conn.cursor()
cur.execute(
"SELECT value FROM _schema_meta WHERE key = ?",
("schema_version",),
)
assert cur.fetchone()[0] == "2.1"
finally:
conn.close()
def test_v2_to_v21_idempotent(self, iso_v21, capsys):
from db.connection import get_conn, init_db
db_path = iso_v21["db_path"]
if os.path.isfile(db_path):
os.remove(db_path)
init_db()
conn = get_conn()
try:
conn.execute("ALTER TABLE credentials DROP COLUMN secret_ciphertext")
conn.execute(
"UPDATE _schema_meta SET value = ? WHERE key = ?",
("2", "schema_version"),
)
conn.commit()
finally:
conn.close()
capsys.readouterr()
init_db()
err = capsys.readouterr().err
assert "v2→v2.1" in err
conn = get_conn()
try:
assert "secret_ciphertext" in _column_names(conn, "credentials")
cur = conn.cursor()
cur.execute(
"SELECT value FROM _schema_meta WHERE key = ?",
("schema_version",),
)
assert cur.fetchone()[0] == "2.1"
finally:
conn.close()
capsys.readouterr()
init_db()
err2 = capsys.readouterr().err
assert "v2→v2.1" not in err2