Release v1.0.56: Credential & Browser Profile Manager
All checks were successful
技能自动化发布 / release (push) Successful in 7s
All checks were successful
技能自动化发布 / release (push) Successful in 7s
This commit is contained in:
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# account-manager tests
|
||||
49
tests/conftest.py
Normal file
49
tests/conftest.py
Normal file
@@ -0,0 +1,49 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test fixtures for account-manager tests.
|
||||
|
||||
All tests use an isolated temporary database under a fake JIANGCHANG_DATA_ROOT
|
||||
so they never touch real user data.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
# Ensure scripts/ is on the path
|
||||
_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)
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def fake_env():
|
||||
"""Set up a fake JIANGCHANG_DATA_ROOT / JIANGCHANG_USER_ID for the session."""
|
||||
tmp = tempfile.mkdtemp(prefix="account_manager_test_")
|
||||
data_root = os.path.join(tmp, "claw-data")
|
||||
os.makedirs(data_root)
|
||||
fake_uid = "99999"
|
||||
os.environ["JIANGCHANG_DATA_ROOT"] = data_root
|
||||
os.environ["JIANGCHANG_USER_ID"] = fake_uid
|
||||
yield {"data_root": data_root, "user_id": fake_uid, "tmp_dir": tmp}
|
||||
|
||||
|
||||
@pytest.fixture(scope="function")
|
||||
def clean_db(fake_env):
|
||||
"""Each test gets a fresh in-memory DB (init_db uses the env vars set by fake_env)."""
|
||||
# Import after env vars are set
|
||||
from db.connection import get_conn, init_db
|
||||
from util.constants import SKILL_SLUG
|
||||
from util.logging_config import setup_skill_logging
|
||||
|
||||
setup_skill_logging(SKILL_SLUG, "openclaw.skill.account_manager_test")
|
||||
init_db()
|
||||
conn = get_conn()
|
||||
conn.execute("DELETE FROM account_leases")
|
||||
conn.execute("DELETE FROM credentials")
|
||||
conn.execute("DELETE FROM accounts")
|
||||
conn.commit()
|
||||
yield conn
|
||||
conn.close()
|
||||
21
tests/run_tests.py
Normal file
21
tests/run_tests.py
Normal file
@@ -0,0 +1,21 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Test runner for account-manager.
|
||||
|
||||
Run:
|
||||
pytest tests/ -v
|
||||
python tests/run_tests.py -v
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
|
||||
_script_dir = os.path.join(os.path.dirname(__file__), "..", "scripts")
|
||||
if _script_dir not in sys.path:
|
||||
sys.path.insert(0, _script_dir)
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
|
||||
tests_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
args = [tests_dir, "-v"] + sys.argv[1:]
|
||||
sys.exit(pytest.main(args))
|
||||
297
tests/test_account_service.py
Normal file
297
tests/test_account_service.py
Normal file
@@ -0,0 +1,297 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for account CRUD operations and pick-web filtering.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAccountAddWeb:
|
||||
"""account add-web creates records correctly without requiring phone numbers."""
|
||||
|
||||
def test_add_web_email_login_id(self, clean_db):
|
||||
"""Email login_id should be accepted (no 11-digit phone restriction)."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
cmd_account_add_web(
|
||||
platform_input="maersk",
|
||||
login_id="demo@maersk.com",
|
||||
login_id_type="email",
|
||||
label="Maersk sim test",
|
||||
tenant_id="tenant-demo",
|
||||
environment="simulator",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
# Should succeed with JSON output
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="maersk")
|
||||
assert len(accs) == 1
|
||||
assert accs[0]["login_id"] == "demo@maersk.com"
|
||||
assert accs[0]["login_id_type"] == "email"
|
||||
assert accs[0]["environment"] == "simulator"
|
||||
assert accs[0]["role"] == "booking"
|
||||
assert accs[0]["tenant_id"] == "tenant-demo"
|
||||
assert accs[0]["status"] == "active"
|
||||
assert accs[0]["platform_key"] == "maersk"
|
||||
|
||||
def test_add_web_no_login_id(self, clean_db):
|
||||
"""login_id can be None (e.g. for browser_profile-only accounts)."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
cmd_account_add_web(
|
||||
platform_input="deepseek",
|
||||
login_id=None,
|
||||
login_id_type="unknown",
|
||||
label="DeepSeek prod",
|
||||
tenant_id=None,
|
||||
environment="production",
|
||||
role="api",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="deepseek")
|
||||
assert len(accs) == 1
|
||||
assert accs[0]["login_id"] == ""
|
||||
|
||||
def test_add_web_profile_dir_created(self, clean_db, fake_env):
|
||||
"""profile_dir should be auto-created under fake_env data root."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
cmd_account_add_web(
|
||||
platform_input="maersk",
|
||||
login_id="test@example.com",
|
||||
login_id_type="email",
|
||||
label="Profile dir test",
|
||||
tenant_id="tenant-test",
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="maersk")
|
||||
assert len(accs) == 1
|
||||
profile_dir = accs[0]["profile_dir"]
|
||||
assert profile_dir, "profile_dir should be non-empty"
|
||||
assert os.path.isdir(profile_dir), f"profile_dir should be created: {profile_dir}"
|
||||
# The path should be under the fake data root
|
||||
assert profile_dir.startswith(fake_env["data_root"]), \
|
||||
f"profile_dir should be under fake data root: {profile_dir}"
|
||||
|
||||
def test_add_web_extra_json(self, clean_db):
|
||||
"""extra_json field is stored and retrieved correctly."""
|
||||
from service.account_service import cmd_account_add_web
|
||||
|
||||
extra = {"api_version": "v1", "timeout": 30}
|
||||
cmd_account_add_web(
|
||||
platform_input="cma_cgm",
|
||||
login_id="cma@test.com",
|
||||
login_id_type="email",
|
||||
label="CMA CGM test",
|
||||
tenant_id="tenant-cma",
|
||||
environment="production",
|
||||
role="documentation",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=json.dumps(extra),
|
||||
profile_dir_override=None,
|
||||
)
|
||||
from db.accounts_repo import find_accounts
|
||||
accs = find_accounts(platform_key="cma_cgm")
|
||||
assert len(accs) == 1
|
||||
raw_ex = accs[0].get("extra_json")
|
||||
extra_parsed = raw_ex if isinstance(raw_ex, dict) else json.loads(raw_ex or "{}")
|
||||
assert extra_parsed["api_version"] == "v1"
|
||||
assert extra_parsed["timeout"] == 30
|
||||
|
||||
|
||||
class TestAccountGetList:
|
||||
"""account get/list operations."""
|
||||
|
||||
def test_get_account_found(self, clean_db):
|
||||
"""account get returns JSON with correct account fields."""
|
||||
from service.account_service import cmd_account_add_web, cmd_account_get
|
||||
import io
|
||||
import sys
|
||||
|
||||
# Add account first
|
||||
cmd_account_add_web(
|
||||
platform_input="evergreen",
|
||||
login_id="ship@evergreen.com",
|
||||
login_id_type="email",
|
||||
label="Evergreen booking",
|
||||
tenant_id="tenant-evg",
|
||||
environment="production",
|
||||
role="booking",
|
||||
provider_code=None,
|
||||
url=None,
|
||||
extra_json_str=None,
|
||||
profile_dir_override=None,
|
||||
)
|
||||
accs = find_accounts_()
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_get(accs[0]["id"])
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
data = json.loads(output)
|
||||
assert data["platform_key"] == "evergreen"
|
||||
assert data["login_id"] == "ship@evergreen.com"
|
||||
|
||||
def test_get_account_not_found(self, clean_db):
|
||||
"""account get for unknown id returns ERROR:ACCOUNT_NOT_FOUND."""
|
||||
from service.account_service import cmd_account_get
|
||||
import io
|
||||
import sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_get(9999)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
assert "ERROR:ACCOUNT_NOT_FOUND" in output
|
||||
|
||||
def test_list_platform_filter(self, clean_db):
|
||||
"""account list --platform filters by platform."""
|
||||
from service.account_service import cmd_account_add_web, cmd_account_list
|
||||
import io
|
||||
import sys
|
||||
|
||||
cmd_account_add_web("maersk", "a@maersk.com", "email", "m", "t1", "prod", "booking", None, None, None, None)
|
||||
cmd_account_add_web("maersk", "b@maersk.com", "email", "m2", "t1", "prod", "documentation", None, None, None, None)
|
||||
cmd_account_add_web("cma_cgm", "c@cma.com", "email", "c", "t2", "prod", "booking", None, None, None, None)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_list(platform_input="maersk")
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
accs = json.loads(output)
|
||||
assert len(accs) == 2
|
||||
assert all(a["platform_key"] == "maersk" for a in accs)
|
||||
|
||||
|
||||
def find_accounts_():
|
||||
from db.accounts_repo import find_accounts
|
||||
return find_accounts(limit=200)
|
||||
|
||||
|
||||
class TestPickWebFiltering:
|
||||
"""pick-web filtering by environment / role / tenant_id."""
|
||||
|
||||
def _add(self, platform, login, label, tenant, env, role):
|
||||
from service.account_service import cmd_account_add_web
|
||||
cmd_account_add_web(
|
||||
platform, login, "email", label, tenant, env, role,
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
def test_filter_environment(self, clean_db):
|
||||
"""pick-web --environment simulator selects only simulator account."""
|
||||
self._add("maersk", "sim@maersk.com", "S", "t1", "simulator", "booking")
|
||||
self._add("maersk", "prod@maersk.com", "P", "t1", "production", "booking")
|
||||
self._add("maersk", "prod2@maersk.com", "P2", "t1", "production", "documentation")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("maersk", environment="simulator", tenant_id=None, role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["login_id"] == "sim@maersk.com"
|
||||
assert result["environment"] == "simulator"
|
||||
|
||||
def test_filter_role(self, clean_db):
|
||||
"""pick-web --role booking selects only booking role."""
|
||||
self._add("maersk", "a@maersk.com", "A", "t1", "simulator", "booking")
|
||||
self._add("maersk", "b@maersk.com", "B", "t1", "simulator", "documentation")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("maersk", environment=None, tenant_id=None, role="documentation", do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["role"] == "documentation"
|
||||
|
||||
def test_filter_tenant(self, clean_db):
|
||||
"""pick-web --tenant-id filters correctly."""
|
||||
self._add("cma_cgm", "a@cma.com", "A", "tenant-a", "production", "booking")
|
||||
self._add("cma_cgm", "b@cma.com", "B", "tenant-b", "production", "booking")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("cma_cgm", environment=None, tenant_id="tenant-b", role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["tenant_id"] == "tenant-b"
|
||||
|
||||
def test_no_accounts_returns_error(self, clean_db):
|
||||
"""pick-web when no account matches returns ERROR:NO_ACCOUNT."""
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("maersk", environment="nonexistent", tenant_id=None, role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
output = captured.getvalue()
|
||||
err = json.loads(output)
|
||||
assert err["success"] is False
|
||||
assert err["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||
|
||||
def test_platform_alias_chinese(self, clean_db):
|
||||
"""pick-web resolves Chinese platform alias correctly."""
|
||||
self._add("maersk", "test@test.com", "Test", "t", "production", "booking")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web("马士基", environment=None, tenant_id=None, role=None, do_lease=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["platform_key"] == "maersk"
|
||||
44
tests/test_browser_open.py
Normal file
44
tests/test_browser_open.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Browser launch helper checks (no real browser started).
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPlaywrightLaunchHelpers:
|
||||
"""Persistent context launch parts must match RPA contract."""
|
||||
|
||||
def test_persistent_context_includes_maximized(self):
|
||||
from util.playwright_stealth import persistent_context_launch_parts
|
||||
|
||||
args, _ignore = persistent_context_launch_parts()
|
||||
assert "--start-maximized" in args
|
||||
|
||||
def test_open_child_runner_launch_keywords_documented(self):
|
||||
"""launch_persistent_context kwargs contract (static read of source)."""
|
||||
import os
|
||||
|
||||
path = os.path.join(
|
||||
os.path.dirname(__file__),
|
||||
"..",
|
||||
"scripts",
|
||||
"service",
|
||||
"open_child_runner.py",
|
||||
)
|
||||
path = os.path.abspath(path)
|
||||
with open(path, encoding="utf-8") as f:
|
||||
src = f.read()
|
||||
assert "launch_persistent_context" in src
|
||||
assert "headless=False" in src
|
||||
assert "no_viewport=True" in src
|
||||
assert '"channel"' in src or "'channel'" in src
|
||||
|
||||
|
||||
class TestResolveChromiumChannel:
|
||||
"""resolve_chromium_channel returns a channel string when browsers exist."""
|
||||
|
||||
def test_returns_string_or_none(self):
|
||||
from service.browser_service import resolve_chromium_channel
|
||||
|
||||
ch = resolve_chromium_channel()
|
||||
assert ch is None or ch in ("chrome", "msedge")
|
||||
340
tests/test_credentials.py
Normal file
340
tests/test_credentials.py
Normal file
@@ -0,0 +1,340 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for credential operations, secret storage, and reveal.
|
||||
|
||||
Note: windows_credential integration tests are skipped by default
|
||||
(the real Windows Credential Manager requires admin and a real user session).
|
||||
Secret store logic for windows_credential is tested via mocking.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import pytest
|
||||
|
||||
|
||||
class TestCredentialEnvStorage:
|
||||
"""credential add-secret env / pick --reveal / SQLite no-secret verification."""
|
||||
|
||||
def test_add_secret_env_saves_ref_not_value(self, clean_db):
|
||||
"""add-secret with storage=env stores only the env var name, not the secret."""
|
||||
from service.account_service import cmd_account_add_secret
|
||||
from db.connection import get_conn
|
||||
|
||||
# Set the env var so the command can read it if needed
|
||||
os.environ["TEST_DEEPSEEK_KEY"] = "sk-test-1234567890abcdef"
|
||||
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="DeepSeek test key",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="TEST_DEEPSEEK_KEY",
|
||||
account_id=None,
|
||||
environment="production",
|
||||
role="api",
|
||||
extra_json_str=None,
|
||||
)
|
||||
finally:
|
||||
del os.environ["TEST_DEEPSEEK_KEY"]
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT secret_ref, secret_mask, secret_storage FROM credentials WHERE platform_key = ?",
|
||||
("deepseek",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None, "credential should be inserted"
|
||||
secret_ref, secret_mask, secret_storage = row
|
||||
assert secret_storage == "env"
|
||||
assert secret_ref == "TEST_DEEPSEEK_KEY", "secret_ref should be the env var name"
|
||||
# secret_mask should be a masked form of "env:TEST_DEEPSEEK_KEY", NOT the actual secret
|
||||
assert "sk-test" not in secret_mask, "secret_mask must not contain the actual secret value"
|
||||
assert "****" in secret_mask, "mask should contain ****"
|
||||
conn.close()
|
||||
|
||||
def test_credential_pick_reveal_reads_environ(self, clean_db):
|
||||
"""credential pick --reveal retrieves secret value from os.environ."""
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
os.environ["MY_SECRET_KEY"] = "sk-actual-secret-value-abcdefgh"
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="Reveal test",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="MY_SECRET_KEY",
|
||||
account_id=None,
|
||||
environment=None,
|
||||
role=None,
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
# credential pick --reveal
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert result["secret"] == "sk-actual-secret-value-abcdefgh"
|
||||
assert result["secret_mask"] != result["secret"], "mask should differ from secret"
|
||||
finally:
|
||||
del os.environ["MY_SECRET_KEY"]
|
||||
|
||||
def test_credential_pick_no_reveal_masks(self, clean_db):
|
||||
"""credential pick (no --reveal) does NOT include the secret field."""
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
os.environ["MASK_TEST_KEY"] = "super-secret-value-xyz"
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="Mask test",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="MASK_TEST_KEY",
|
||||
account_id=None,
|
||||
environment=None,
|
||||
role=None,
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="api_key", reveal=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert "secret" not in result, "no secret field when reveal=False"
|
||||
assert result["secret_mask"] != "super-secret-value-xyz"
|
||||
finally:
|
||||
del os.environ["MASK_TEST_KEY"]
|
||||
|
||||
def test_env_missing_returns_error(self, clean_db):
|
||||
"""credential pick --reveal when env var is missing returns SECRET_ENV_MISSING."""
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
# Add credential with a ref to a non-existent env var
|
||||
cmd_account_add_secret(
|
||||
platform_input="deepseek",
|
||||
credential_type="api_key",
|
||||
label="Missing env test",
|
||||
secret_storage="env",
|
||||
secret_stdin=False,
|
||||
secret_ref="THIS_ENV_VAR_DOES_NOT_EXIST_AT_ALL",
|
||||
account_id=None,
|
||||
environment=None,
|
||||
role=None,
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="api_key", reveal=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:SECRET_ENV_MISSING"
|
||||
|
||||
|
||||
class TestCredentialNoneStorage:
|
||||
"""credential add-secret storage=none (browser_profile / note)."""
|
||||
|
||||
def test_add_secret_none(self, clean_db):
|
||||
"""storage=none works for browser_profile credential type."""
|
||||
from service.account_service import cmd_account_add_secret
|
||||
|
||||
cmd_account_add_secret(
|
||||
platform_input="maersk",
|
||||
credential_type="browser_profile",
|
||||
label="Maersk browser session",
|
||||
secret_storage="none",
|
||||
secret_stdin=False,
|
||||
secret_ref=None,
|
||||
account_id=None,
|
||||
environment="simulator",
|
||||
role="booking",
|
||||
extra_json_str=None,
|
||||
)
|
||||
|
||||
from db.connection import get_conn
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT secret_storage, secret_ref, secret_mask FROM credentials WHERE platform_key = ?",
|
||||
("maersk",),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
assert row is not None
|
||||
assert row[0] == "none"
|
||||
assert row[1] is None or row[1] == ""
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestCredentialWindowsCredentialMocked:
|
||||
"""Mocked tests for windows_credential secret store logic."""
|
||||
|
||||
def test_mask_secret_never_returns_full_value(self):
|
||||
"""mask_secret must never return the actual secret value."""
|
||||
from service.secret_store import mask_secret
|
||||
|
||||
secrets = [
|
||||
"sk-test-1234567890abcdefghijklmnop",
|
||||
"short",
|
||||
"abc",
|
||||
"a" * 100,
|
||||
"",
|
||||
None,
|
||||
]
|
||||
for s in secrets:
|
||||
if s is None or s == "":
|
||||
masked = mask_secret(s)
|
||||
assert masked == "****"
|
||||
continue
|
||||
result = mask_secret(s)
|
||||
# The mask should never equal the original
|
||||
assert result != s, f"mask_secret({s!r}) must not equal original"
|
||||
# The mask should always contain "****"
|
||||
assert "****" in result, f"mask should contain '****': {result}"
|
||||
# For long secrets, original should not appear in mask
|
||||
assert s not in result, f"original secret must not appear in mask: {s}"
|
||||
|
||||
def test_read_secret_env_success(self):
|
||||
"""read_secret with storage=env returns the os.environ value."""
|
||||
os.environ["TEST_ENV_FOR_READ"] = "env-secret-value-xyz"
|
||||
try:
|
||||
from service.secret_store import read_secret
|
||||
val = read_secret("env", "TEST_ENV_FOR_READ")
|
||||
assert val == "env-secret-value-xyz"
|
||||
finally:
|
||||
del os.environ["TEST_ENV_FOR_READ"]
|
||||
|
||||
def test_read_secret_env_missing_raises(self):
|
||||
"""read_secret with storage=env raises ValueError when env var is missing."""
|
||||
# Ensure the env var is not set
|
||||
os.environ.pop("NONEXISTENT_VAR_FOR_TEST_123", None)
|
||||
from service.secret_store import read_secret
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_secret("env", "NONEXISTENT_VAR_FOR_TEST_123")
|
||||
assert "SECRET_ENV_MISSING" in str(exc_info.value)
|
||||
|
||||
def test_read_secret_none(self):
|
||||
"""read_secret with storage=none returns empty string."""
|
||||
from service.secret_store import read_secret
|
||||
val = read_secret("none", "ignored-ref")
|
||||
assert val == ""
|
||||
|
||||
def test_write_secret_none_is_noop(self):
|
||||
"""write_secret with storage=none does not raise."""
|
||||
from service.secret_store import write_secret
|
||||
# Should not raise
|
||||
write_secret("none", "ignored-ref", "any-secret-value")
|
||||
|
||||
def test_windows_credential_write_fails_on_non_win32(self, monkeypatch):
|
||||
"""On non-Windows, windows_credential write raises OSError."""
|
||||
import sys
|
||||
monkeypatch.setattr(sys, "platform", "linux")
|
||||
from service.secret_store import write_secret
|
||||
with pytest.raises(OSError) as exc_info:
|
||||
write_secret("windows_credential", "some-target", "some-secret")
|
||||
assert "WINDOWS_CREDENTIAL_UNAVAILABLE" in str(exc_info.value) or \
|
||||
"not available" in str(exc_info.value).lower()
|
||||
|
||||
|
||||
class TestCredentialPickFiltering:
|
||||
"""credential pick filtering by credential_type / environment."""
|
||||
|
||||
def test_pick_by_credential_type(self, clean_db):
|
||||
from service.account_service import cmd_account_add_secret, cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
os.environ["CREDS_TYPE_KEY_A"] = "val-a"
|
||||
os.environ["CREDS_TYPE_KEY_B"] = "val-b"
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
"deepseek", "api_key", "A", "env", False, "CREDS_TYPE_KEY_A",
|
||||
None, None, None, None,
|
||||
)
|
||||
cmd_account_add_secret(
|
||||
"deepseek", "model_param", "B", "env", False, "CREDS_TYPE_KEY_B",
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="model_param", reveal=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert result["credential_type"] == "model_param"
|
||||
finally:
|
||||
del os.environ["CREDS_TYPE_KEY_A"]
|
||||
del os.environ["CREDS_TYPE_KEY_B"]
|
||||
|
||||
def test_pick_no_credential_returns_error(self, clean_db):
|
||||
"""credential pick when none matches returns CREDENTIAL_NOT_FOUND."""
|
||||
from service.account_service import cmd_credential_pick
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_credential_pick("deepseek", credential_type="nonexistent_type", reveal=False)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:CREDENTIAL_NOT_FOUND"
|
||||
|
||||
|
||||
class TestSQLiteNoPlaintextSecret:
|
||||
"""Verify that SQLite never stores actual secret values."""
|
||||
|
||||
def test_credentials_table_never_has_plaintext(self, clean_db):
|
||||
"""All credential rows should have secret_mask != original secret value."""
|
||||
from service.account_service import cmd_account_add_secret
|
||||
from db.connection import get_conn
|
||||
|
||||
test_secret = "SQLITE_NO_PLAINTEXT_TEST_SECRET_987654321"
|
||||
os.environ["SQLITE_NO_PLAINTEXT_TEST"] = test_secret
|
||||
try:
|
||||
cmd_account_add_secret(
|
||||
"deepseek", "api_key", "SQLite test", "env", False,
|
||||
"SQLITE_NO_PLAINTEXT_TEST", None, None, None, None,
|
||||
)
|
||||
finally:
|
||||
del os.environ["SQLITE_NO_PLAINTEXT_TEST"]
|
||||
|
||||
conn = get_conn()
|
||||
cur = conn.cursor()
|
||||
cur.execute("SELECT secret_ref, secret_mask FROM credentials")
|
||||
rows = cur.fetchall()
|
||||
conn.close()
|
||||
|
||||
for secret_ref, secret_mask in rows:
|
||||
# secret_ref for env storage is the env var name, not the secret
|
||||
if secret_ref == "SQLITE_NO_PLAINTEXT_TEST":
|
||||
# If it matches our test env var name, check the mask
|
||||
assert test_secret not in (secret_mask or ""), \
|
||||
f"Plaintext secret must not appear in secret_mask or secret_ref columns. mask={secret_mask}"
|
||||
242
tests/test_lease.py
Normal file
242
tests/test_lease.py
Normal file
@@ -0,0 +1,242 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Tests for lease acquire / conflict / release / cleanup.
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import pytest
|
||||
|
||||
|
||||
class TestLeaseAcquire:
|
||||
"""Lease acquisition on pick-web."""
|
||||
|
||||
def _add_account(self, platform, login, label, tenant, env, role):
|
||||
from service.account_service import cmd_account_add_web
|
||||
cmd_account_add_web(
|
||||
platform, login, "email", label, tenant, env, role,
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
def test_pick_web_lease_returns_token(self, clean_db):
|
||||
"""pick-web --lease returns lease_token in result."""
|
||||
self._add_account("maersk", "test@maersk.com", "Test", "t1", "production", "booking")
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"maersk",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="test-holder",
|
||||
purpose="rpa-run",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert "lease_token" in result
|
||||
assert len(result["lease_token"]) == 32
|
||||
assert "lease_expires_at" in result
|
||||
assert result["lease_token"] not in ("", "none")
|
||||
|
||||
def test_active_lease_blocks_pick(self, clean_db):
|
||||
"""When the only matching account is leased, pick-web finds no eligible row."""
|
||||
from db.accounts_repo import acquire_lease, find_account_ids
|
||||
|
||||
self._add_account("cma_cgm", "test@cma.com", "CMA", "t1", "production", "booking")
|
||||
ids = find_account_ids("cma_cgm")
|
||||
assert len(ids) == 1
|
||||
acc_id = ids[0]
|
||||
|
||||
acquire_lease(
|
||||
account_id=acc_id,
|
||||
lease_token="a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6",
|
||||
holder="first-holder",
|
||||
purpose="first-task",
|
||||
ttl_sec=900,
|
||||
)
|
||||
|
||||
assert find_account_ids("cma_cgm") == []
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"cma_cgm",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="second-holder",
|
||||
purpose="second-task",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:NO_ACCOUNT"
|
||||
|
||||
def test_release_then_pick(self, clean_db):
|
||||
"""After release, the same account can be picked again."""
|
||||
from db.accounts_repo import acquire_lease, release_lease, find_account_ids
|
||||
|
||||
self._add_account("evergreen", "ship@evergreen.com", "EVG", "t1", "production", "booking")
|
||||
ids = find_account_ids("evergreen")
|
||||
acc_id = ids[0]
|
||||
|
||||
token = "z9y8x7w6v5u4t3s2r1q0p9o8n7m6l5k4"
|
||||
acquire_lease(acc_id, token, "holder", "purpose", 900)
|
||||
ok = release_lease(token)
|
||||
assert ok, "release should succeed"
|
||||
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"evergreen",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="new-holder",
|
||||
purpose="new-task",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue().strip().splitlines()[-1])
|
||||
assert result["id"] == acc_id
|
||||
assert "lease_token" in result
|
||||
|
||||
|
||||
class TestLeaseRelease:
|
||||
"""lease release command."""
|
||||
|
||||
def test_release_unknown_token(self, clean_db):
|
||||
"""release of non-existent token returns LEASE_NOT_FOUND."""
|
||||
from service.account_service import cmd_lease_release
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_lease_release("nonexistent_token_123456789012")
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is False
|
||||
assert result["error"]["code"] == "ERROR:LEASE_NOT_FOUND"
|
||||
|
||||
|
||||
class TestLeaseCleanup:
|
||||
"""lease cleanup / expired lease handling."""
|
||||
|
||||
def test_expired_lease_allows_pick(self, clean_db):
|
||||
"""After cleanup marks expired, account can be picked again."""
|
||||
from db.accounts_repo import acquire_lease, cleanup_expired_leases, find_account_ids
|
||||
|
||||
self._add_account_("cargo_wise", "cw@cargowise.com", "CW", "t1", "production", "booking")
|
||||
ids = find_account_ids("cargo_wise")
|
||||
acc_id = ids[0]
|
||||
|
||||
# Acquire lease with very short TTL (1 second)
|
||||
acquire_lease(acc_id, "shortlived1234567890123456789012", "holder", "short", 1)
|
||||
# Wait for it to expire
|
||||
time.sleep(1.5)
|
||||
count = cleanup_expired_leases()
|
||||
assert count >= 1, "at least one lease should be cleaned"
|
||||
|
||||
# Now pick-web should succeed
|
||||
from service.account_service import cmd_account_pick_web
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_account_pick_web(
|
||||
"cargo_wise",
|
||||
environment=None,
|
||||
tenant_id=None,
|
||||
role=None,
|
||||
do_lease=True,
|
||||
ttl_sec=900,
|
||||
holder="after-cleanup",
|
||||
purpose="fresh-task",
|
||||
)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue().strip().splitlines()[-1])
|
||||
assert result["id"] == acc_id
|
||||
assert "lease_token" in result
|
||||
|
||||
def _add_account_(self, platform, login, label, tenant, env, role):
|
||||
from service.account_service import cmd_account_add_web
|
||||
cmd_account_add_web(
|
||||
platform, login, "email", label, tenant, env, role,
|
||||
None, None, None, None,
|
||||
)
|
||||
|
||||
|
||||
class TestLeaseList:
|
||||
"""lease list command."""
|
||||
|
||||
def test_list_no_leases(self, clean_db):
|
||||
"""lease list --active returns empty when no leases."""
|
||||
from service.account_service import cmd_lease_list
|
||||
import io, sys
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_lease_list(active_only=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
assert result["data"]["leases"] == []
|
||||
|
||||
def test_list_with_active_lease(self, clean_db):
|
||||
"""lease list --active shows active lease after acquisition."""
|
||||
from db.accounts_repo import acquire_lease, find_account_ids
|
||||
from service.account_service import cmd_account_add_web, cmd_lease_list
|
||||
import io, sys
|
||||
|
||||
cmd_account_add_web(
|
||||
"sinotrans", "sinotest@sinotrans.com", "email", "Sino", "t1", "production", "booking",
|
||||
None, None, None, None,
|
||||
)
|
||||
ids = find_account_ids("sinotrans")
|
||||
acquire_lease(ids[0], "listtest1234567890123456789012345", "holder", "purpose", 900)
|
||||
|
||||
captured = io.StringIO()
|
||||
sys.stdout = captured
|
||||
try:
|
||||
cmd_lease_list(active_only=True)
|
||||
finally:
|
||||
sys.stdout = sys.__stdout__
|
||||
|
||||
result = json.loads(captured.getvalue())
|
||||
assert result["success"] is True
|
||||
leases = result["data"]["leases"]
|
||||
assert len(leases) == 1
|
||||
assert leases[0]["account_id"] == ids[0]
|
||||
assert leases[0]["status"] == "active"
|
||||
83
tests/test_platforms.py
Normal file
83
tests/test_platforms.py
Normal file
@@ -0,0 +1,83 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
Platform resolution tests.
|
||||
"""
|
||||
import pytest
|
||||
|
||||
|
||||
class TestPlatformResolution:
|
||||
"""Platform key resolution: key / display_name / aliases -> canonical key."""
|
||||
|
||||
def test_maersk_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("maersk") == "maersk"
|
||||
|
||||
def test_maersk_alias_chinese(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("马士基") == "maersk"
|
||||
|
||||
def test_maersk_alias_upper(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("MAERSK") == "maersk"
|
||||
|
||||
def test_cma_cgm_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("cma_cgm") == "cma_cgm"
|
||||
|
||||
def test_cma_cgm_alias_chinese(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("达飞") == "cma_cgm"
|
||||
|
||||
def test_cma_cgm_alias_display(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("CMA CGM") == "cma_cgm"
|
||||
|
||||
def test_deepseek_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("deepseek") == "deepseek"
|
||||
|
||||
def test_deepseek_alias_capitalize(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("DeepSeek") == "deepseek"
|
||||
|
||||
def test_unknown_returns_none(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("not_a_platform_xyz") is None
|
||||
|
||||
def test_empty_returns_none(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("") is None
|
||||
assert resolve_platform_key(None) is None
|
||||
|
||||
def test_list_platforms_all(self):
|
||||
from util.platforms import list_platforms
|
||||
all_plat = list_platforms()
|
||||
assert any(p["platform_key"] == "maersk" for p in all_plat)
|
||||
assert any(p["platform_key"] == "deepseek" for p in all_plat)
|
||||
assert any(p["platform_key"] == "sohu" for p in all_plat)
|
||||
|
||||
def test_list_platforms_logistics_only(self):
|
||||
from util.platforms import list_platforms
|
||||
log_plat = list_platforms("logistics")
|
||||
assert any(p["platform_key"] == "maersk" for p in log_plat)
|
||||
assert any(p["platform_key"] == "cma_cgm" for p in log_plat)
|
||||
assert not any(p["platform_key"] == "deepseek" for p in log_plat)
|
||||
|
||||
def test_list_platforms_llm_only(self):
|
||||
from util.platforms import list_platforms
|
||||
llm_plat = list_platforms("llm")
|
||||
assert any(p["platform_key"] == "deepseek" for p in llm_plat)
|
||||
assert any(p["platform_key"] == "doubao" for p in llm_plat)
|
||||
assert not any(p["platform_key"] == "maersk" for p in llm_plat)
|
||||
|
||||
def test_evergreen_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("evergreen") == "evergreen"
|
||||
|
||||
def test_evergreen_alias_chinese(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("长荣") == "evergreen"
|
||||
|
||||
def test_cargo_wise_key(self):
|
||||
from util.platforms import resolve_platform_key
|
||||
assert resolve_platform_key("cargo_wise") == "cargo_wise"
|
||||
Reference in New Issue
Block a user