claude-multiacc 2.0.2 → 2.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE_ACCS_TASK.md +7 -0
- package/README.md +28 -7
- package/bin/claude +83 -10
- package/bin/claude-accounts +184 -59
- package/bin/codex-accounts +47 -11
- package/docs/ACCOUNT_OPERATIONS.md +7 -3
- package/install.sh +24 -8
- package/lib/__pycache__/audit.cpython-312.pyc +0 -0
- package/lib/__pycache__/keychain.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_policy.cpython-312.pyc +0 -0
- package/lib/__pycache__/selector_primitives.cpython-312.pyc +0 -0
- package/lib/audit.py +87 -21
- package/lib/codex_reset.py +170 -0
- package/lib/common.sh +62 -5
- package/lib/credential.py +9 -1
- package/lib/keychain.py +210 -0
- package/lib/report.py +33 -8
- package/package.json +1 -1
- package/tests/run-tests.sh +287 -0
- package/tests/test_codex_reset.py +170 -0
- package/tests/test_keychain.py +204 -0
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Sandboxed integration tests for automatic Codex usage-reset redemption."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import base64
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
import subprocess
|
|
12
|
+
import tempfile
|
|
13
|
+
import threading
|
|
14
|
+
import time
|
|
15
|
+
import unittest
|
|
16
|
+
import uuid
|
|
17
|
+
|
|
18
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def jwt(claims: dict) -> str:
|
|
22
|
+
payload = base64.urlsafe_b64encode(json.dumps(claims).encode()).decode().rstrip("=")
|
|
23
|
+
return f"header.{payload}.signature"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ResetHandler(BaseHTTPRequestHandler):
|
|
27
|
+
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
|
|
28
|
+
state = self.server.state
|
|
29
|
+
if self.path == "/wham/usage":
|
|
30
|
+
state["usage_gets"] += 1
|
|
31
|
+
body = state["usage"]
|
|
32
|
+
elif self.path == "/wham/rate-limit-reset-credits":
|
|
33
|
+
state["credit_gets"] += 1
|
|
34
|
+
body = state["credits"]
|
|
35
|
+
else:
|
|
36
|
+
self.send_error(404)
|
|
37
|
+
return
|
|
38
|
+
encoded = json.dumps(body).encode()
|
|
39
|
+
self.send_response(200)
|
|
40
|
+
self.send_header("Content-Type", "application/json")
|
|
41
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
42
|
+
self.end_headers()
|
|
43
|
+
self.wfile.write(encoded)
|
|
44
|
+
|
|
45
|
+
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler contract
|
|
46
|
+
state = self.server.state
|
|
47
|
+
if self.path != "/wham/rate-limit-reset-credits/consume":
|
|
48
|
+
self.send_error(404)
|
|
49
|
+
return
|
|
50
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
51
|
+
state["posts"].append(json.loads(self.rfile.read(length)))
|
|
52
|
+
status, body = state["responses"].pop(0)
|
|
53
|
+
encoded = json.dumps(body).encode()
|
|
54
|
+
self.send_response(status)
|
|
55
|
+
self.send_header("Content-Type", "application/json")
|
|
56
|
+
self.send_header("Content-Length", str(len(encoded)))
|
|
57
|
+
self.end_headers()
|
|
58
|
+
self.wfile.write(encoded)
|
|
59
|
+
|
|
60
|
+
def log_message(self, _format: str, *_args) -> None:
|
|
61
|
+
return
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class CodexResetIntegrationTest(unittest.TestCase):
|
|
65
|
+
def setUp(self) -> None:
|
|
66
|
+
self.temp = tempfile.TemporaryDirectory(prefix="multiacc-reset-")
|
|
67
|
+
self.pool = Path(self.temp.name) / "pool"
|
|
68
|
+
account = self.pool / "acct-01"
|
|
69
|
+
account.mkdir(parents=True)
|
|
70
|
+
manifest = {"version": 1, "threshold": 90, "accounts": [
|
|
71
|
+
{"id": "acct-01", "email": "test@example.invalid", "home": "mac"}]}
|
|
72
|
+
(self.pool / "accounts.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|
73
|
+
claims = {"exp": int(time.time()) + 86400,
|
|
74
|
+
"https://api.openai.com/auth": {"chatgpt_account_id": "account-test"}}
|
|
75
|
+
auth = {"auth_mode": "chatgpt", "tokens": {
|
|
76
|
+
"access_token": jwt(claims), "refresh_token": "fake-refresh",
|
|
77
|
+
"account_id": "account-test"}}
|
|
78
|
+
(account / "auth.json").write_text(json.dumps(auth), encoding="utf-8")
|
|
79
|
+
self.server = ThreadingHTTPServer(("127.0.0.1", 0), ResetHandler)
|
|
80
|
+
self.server.state = {"usage_gets": 0, "credit_gets": 0, "posts": [],
|
|
81
|
+
"usage": {}, "credits": {}, "responses": []}
|
|
82
|
+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
83
|
+
self.thread.start()
|
|
84
|
+
|
|
85
|
+
def tearDown(self) -> None:
|
|
86
|
+
self.server.shutdown()
|
|
87
|
+
self.server.server_close()
|
|
88
|
+
self.thread.join(timeout=5)
|
|
89
|
+
self.temp.cleanup()
|
|
90
|
+
|
|
91
|
+
def run_limits(self) -> subprocess.CompletedProcess:
|
|
92
|
+
base = f"http://127.0.0.1:{self.server.server_port}/wham"
|
|
93
|
+
env = os.environ.copy()
|
|
94
|
+
env.update({"CODEX_ACCOUNTS_ROOT": str(self.pool),
|
|
95
|
+
"CODEX_MULTIACC_NO_SYNC": "1", "CODEX_MULTIACC_MIN_FETCH": "0",
|
|
96
|
+
"CODEX_MULTIACC_AUTO_RESET": "1", "PYTHONDONTWRITEBYTECODE": "1",
|
|
97
|
+
"CODEX_MULTIACC_USAGE_URL": f"{base}/usage"})
|
|
98
|
+
env.pop("CODEX_MULTIACC_RESET_CREDITS_URL", None)
|
|
99
|
+
env.pop("CODEX_MULTIACC_RESET_CONSUME_URL", None)
|
|
100
|
+
return subprocess.run([REPO / "bin/codex-accounts", "limits", "--force"],
|
|
101
|
+
capture_output=True, text=True, env=env, timeout=20, check=False)
|
|
102
|
+
|
|
103
|
+
def test_redeems_at_five_percent_remaining_once(self) -> None:
|
|
104
|
+
now = int(time.time())
|
|
105
|
+
self.server.state["usage"] = {"plan_type": "pro", "rate_limit": {
|
|
106
|
+
"allowed": True, "primary_window": {"used_percent": 95,
|
|
107
|
+
"limit_window_seconds": 18000, "reset_at": now + 3600}},
|
|
108
|
+
"rate_limit_reset_credits": {"available_count": 2}}
|
|
109
|
+
self.server.state["credits"] = {"available_count": 2, "credits": [
|
|
110
|
+
{"id": "later", "status": "available", "expires_at": "2026-09-02T00:00:00Z"},
|
|
111
|
+
{"id": "sooner", "status": "available", "expires_at": "2026-09-01T00:00:00Z"}]}
|
|
112
|
+
self.server.state["responses"] = [(200, {"code": "reset", "windows_reset": 2})]
|
|
113
|
+
result = self.run_limits()
|
|
114
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
115
|
+
self.assertIn("usage reset redeemed automatically at 95% used", result.stdout)
|
|
116
|
+
self.assertEqual(len(self.server.state["posts"]), 1)
|
|
117
|
+
request = self.server.state["posts"][0]
|
|
118
|
+
self.assertEqual(request["credit_id"], "sooner")
|
|
119
|
+
uuid.UUID(request["redeem_request_id"])
|
|
120
|
+
state = json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
|
|
121
|
+
self.assertEqual(state["state"], "complete")
|
|
122
|
+
marker = self.pool / "acct-01/.limited"
|
|
123
|
+
self.assertFalse(marker.exists())
|
|
124
|
+
marker.write_text(f"{now + 3600}\nreason=client-rate-limit\n", encoding="utf-8")
|
|
125
|
+
(self.pool / "acct-01/.usage-reset.json").unlink()
|
|
126
|
+
self.server.state["responses"] = [(200, {"code": "already_redeemed"})]
|
|
127
|
+
result = self.run_limits()
|
|
128
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
129
|
+
self.assertFalse(marker.exists())
|
|
130
|
+
self.assertEqual(self.server.state["posts"][0]["redeem_request_id"],
|
|
131
|
+
self.server.state["posts"][1]["redeem_request_id"])
|
|
132
|
+
self.run_limits()
|
|
133
|
+
self.assertEqual(len(self.server.state["posts"]), 2)
|
|
134
|
+
|
|
135
|
+
def test_does_not_redeem_above_five_percent_remaining(self) -> None:
|
|
136
|
+
now = int(time.time())
|
|
137
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": True,
|
|
138
|
+
"primary_window": {"used_percent": 94, "limit_window_seconds": 18000,
|
|
139
|
+
"reset_at": now + 3600}},
|
|
140
|
+
"rate_limit_reset_credits": {"available_count": 1}}
|
|
141
|
+
result = self.run_limits()
|
|
142
|
+
self.assertEqual(result.returncode, 0, result.stderr)
|
|
143
|
+
self.assertEqual(self.server.state["credit_gets"], 0)
|
|
144
|
+
self.assertEqual(self.server.state["posts"], [])
|
|
145
|
+
self.assertFalse((self.pool / "acct-01/.usage-reset.json").exists())
|
|
146
|
+
|
|
147
|
+
def test_retries_a_lost_response_with_the_same_idempotency_key(self) -> None:
|
|
148
|
+
now = int(time.time())
|
|
149
|
+
self.server.state["usage"] = {"rate_limit": {"allowed": False,
|
|
150
|
+
"limit_reached": True, "primary_window": {"used_percent": 100,
|
|
151
|
+
"limit_window_seconds": 604800, "reset_at": now + 86400}},
|
|
152
|
+
"rate_limit_reset_credits": {"available_count": 1}}
|
|
153
|
+
self.server.state["credits"] = {"available_count": 1, "credits": []}
|
|
154
|
+
self.server.state["responses"] = [
|
|
155
|
+
(500, {"error": "response lost"}),
|
|
156
|
+
(200, {"code": "already_redeemed", "windows_reset": 2})]
|
|
157
|
+
first = self.run_limits()
|
|
158
|
+
self.assertIn("retry is idempotent", first.stdout)
|
|
159
|
+
self.assertEqual(json.loads((self.pool / "acct-01/.usage-reset.json").read_text())
|
|
160
|
+
["state"], "pending")
|
|
161
|
+
second = self.run_limits()
|
|
162
|
+
self.assertEqual(second.returncode, 0, second.stderr)
|
|
163
|
+
self.assertEqual(len(self.server.state["posts"]), 2)
|
|
164
|
+
keys = [request["redeem_request_id"] for request in self.server.state["posts"]]
|
|
165
|
+
self.assertEqual(keys[0], keys[1])
|
|
166
|
+
self.assertEqual(self.server.state["credit_gets"], 1)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
if __name__ == "__main__":
|
|
170
|
+
unittest.main()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Unit tests for lib/keychain.py and its audit/report integration.
|
|
3
|
+
|
|
4
|
+
Runs everywhere: a fake `security` on PATH serves generic-password items from a
|
|
5
|
+
temp dir, and CLAUDE_MULTIACC_KEYCHAIN=1 forces the lookup on off-macOS. Nothing
|
|
6
|
+
here can ever touch a real keychain — the fake shadows /usr/bin/security.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import tempfile
|
|
17
|
+
import unittest
|
|
18
|
+
|
|
19
|
+
REPO = Path(__file__).resolve().parents[1]
|
|
20
|
+
sys.path.insert(0, str(REPO / 'lib'))
|
|
21
|
+
|
|
22
|
+
FAKE_SECURITY = r'''#!/usr/bin/env bash
|
|
23
|
+
KC="${FAKE_KEYCHAIN_DIR:-/nonexistent-keychain}"
|
|
24
|
+
cmd="${1:-}"; shift || true
|
|
25
|
+
svc=""; acct=""; want_pw=0; hexdata=""
|
|
26
|
+
while [ $# -gt 0 ]; do
|
|
27
|
+
case "$1" in
|
|
28
|
+
-s) svc="$2"; shift 2 ;;
|
|
29
|
+
-a) acct="$2"; shift 2 ;;
|
|
30
|
+
-w) want_pw=1; shift ;;
|
|
31
|
+
-X) hexdata="$2"; shift 2 ;;
|
|
32
|
+
*) shift ;;
|
|
33
|
+
esac
|
|
34
|
+
done
|
|
35
|
+
case "$cmd" in
|
|
36
|
+
find-generic-password)
|
|
37
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
38
|
+
if [ "$want_pw" = "1" ]; then
|
|
39
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
40
|
+
cat "$KC/$svc"; exit 0
|
|
41
|
+
fi
|
|
42
|
+
a="tester"; [ -f "$KC/$svc.acct" ] && a="$(cat "$KC/$svc.acct")"
|
|
43
|
+
printf 'attributes:\n "acct"<blob>="%s"\n' "$a"
|
|
44
|
+
printf ' "mdat"<timedate>=0x00 "20260828043137Z\\000"\n'
|
|
45
|
+
printf ' "svce"<blob>="%s"\n' "$svc"
|
|
46
|
+
exit 0 ;;
|
|
47
|
+
add-generic-password)
|
|
48
|
+
[ -n "${FAKE_KEYCHAIN_LOCKED:-}" ] && exit 36
|
|
49
|
+
mkdir -p "$KC"
|
|
50
|
+
printf '%s' "$hexdata" | python3 -c 'import sys;sys.stdout.buffer.write(bytes.fromhex(sys.stdin.read().strip()))' > "$KC/$svc"
|
|
51
|
+
printf '%s' "${acct:-tester}" > "$KC/$svc.acct"
|
|
52
|
+
exit 0 ;;
|
|
53
|
+
delete-generic-password)
|
|
54
|
+
[ -f "$KC/$svc" ] || exit 44
|
|
55
|
+
rm -f "$KC/$svc" "$KC/$svc.acct"; exit 0 ;;
|
|
56
|
+
*) exit 1 ;;
|
|
57
|
+
esac
|
|
58
|
+
'''
|
|
59
|
+
|
|
60
|
+
DOC = {'claudeAiOauth': {'accessToken': 'sk-ant-oat01-x', 'refreshToken': 'r',
|
|
61
|
+
'expiresAt': 9999999999999,
|
|
62
|
+
'refreshTokenExpiresAt': 9999999999999}}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class KeychainTests(unittest.TestCase):
|
|
66
|
+
def setUp(self):
|
|
67
|
+
self.work = tempfile.TemporaryDirectory()
|
|
68
|
+
work = Path(self.work.name)
|
|
69
|
+
fakebin = work / 'bin'
|
|
70
|
+
fakebin.mkdir()
|
|
71
|
+
tool = fakebin / 'security'
|
|
72
|
+
tool.write_text(FAKE_SECURITY)
|
|
73
|
+
tool.chmod(0o755)
|
|
74
|
+
self.kcdir = work / 'keychain'
|
|
75
|
+
self.saved = {k: os.environ.get(k) for k in
|
|
76
|
+
('PATH', 'CLAUDE_MULTIACC_KEYCHAIN', 'FAKE_KEYCHAIN_DIR',
|
|
77
|
+
'FAKE_KEYCHAIN_LOCKED')}
|
|
78
|
+
os.environ['PATH'] = f"{fakebin}:{os.environ['PATH']}"
|
|
79
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
80
|
+
os.environ['FAKE_KEYCHAIN_DIR'] = str(self.kcdir)
|
|
81
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
|
|
82
|
+
# (re)load with the fake in place
|
|
83
|
+
import importlib
|
|
84
|
+
import keychain
|
|
85
|
+
self.keychain = importlib.reload(keychain)
|
|
86
|
+
self.root = work / 'accounts'
|
|
87
|
+
(self.root / 'acct-01').mkdir(parents=True)
|
|
88
|
+
(self.root / 'acct-01' / 'tmp').mkdir(exist_ok=True)
|
|
89
|
+
|
|
90
|
+
def tearDown(self):
|
|
91
|
+
for key, value in self.saved.items():
|
|
92
|
+
if value is None:
|
|
93
|
+
os.environ.pop(key, None)
|
|
94
|
+
else:
|
|
95
|
+
os.environ[key] = value
|
|
96
|
+
self.work.cleanup()
|
|
97
|
+
|
|
98
|
+
# -- the layout facts this whole feature stands on ------------------------------
|
|
99
|
+
def test_service_name_matches_the_client(self):
|
|
100
|
+
# Observed live 2026-08-28 (Claude Code 2.1.250, my-mini): this config dir's
|
|
101
|
+
# login sat under exactly this service. The hash input is the PATH STRING as
|
|
102
|
+
# the client received it — no realpath, no trailing slash.
|
|
103
|
+
self.assertEqual(
|
|
104
|
+
self.keychain.service_name('/Users/gas/.claude-accounts/acct-16'),
|
|
105
|
+
'Claude Code-credentials-4219e2b1')
|
|
106
|
+
|
|
107
|
+
def test_absent_then_present_roundtrip(self):
|
|
108
|
+
d = str(self.root / 'acct-01')
|
|
109
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
110
|
+
self.assertTrue(self.keychain.write(d, DOC, account='gas'))
|
|
111
|
+
res = self.keychain.probe(d)
|
|
112
|
+
self.assertEqual(res['state'], 'present')
|
|
113
|
+
self.assertEqual(res['doc'], DOC)
|
|
114
|
+
# -U update path keeps a single item and the account name it was made with
|
|
115
|
+
doc2 = {'claudeAiOauth': dict(DOC['claudeAiOauth'], accessToken='sk-ant-oat01-y')}
|
|
116
|
+
self.assertTrue(self.keychain.write(d, doc2))
|
|
117
|
+
again = self.keychain.probe(d)
|
|
118
|
+
self.assertEqual(again['doc']['claudeAiOauth']['accessToken'], 'sk-ant-oat01-y')
|
|
119
|
+
self.assertTrue(self.keychain.delete(d))
|
|
120
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
121
|
+
self.assertTrue(self.keychain.delete(d)) # deleting a missing item is fine
|
|
122
|
+
|
|
123
|
+
def test_locked_is_distinguished_from_absent(self):
|
|
124
|
+
d = str(self.root / 'acct-01')
|
|
125
|
+
self.keychain.write(d, DOC, account='tester')
|
|
126
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
127
|
+
res = self.keychain.probe(d)
|
|
128
|
+
self.assertEqual(res['state'], 'locked')
|
|
129
|
+
self.assertIsNone(res['doc'])
|
|
130
|
+
self.assertEqual(res['account'], 'tester')
|
|
131
|
+
self.assertGreater(self.keychain.item_mtime(d), 0)
|
|
132
|
+
# an id with NO item is still absent, not locked
|
|
133
|
+
self.assertEqual(self.keychain.probe(d + '-other')['state'], 'absent')
|
|
134
|
+
|
|
135
|
+
def test_corrupt_item(self):
|
|
136
|
+
d = str(self.root / 'acct-01')
|
|
137
|
+
svc = self.keychain.service_name(d)
|
|
138
|
+
self.kcdir.mkdir(exist_ok=True)
|
|
139
|
+
(self.kcdir / svc).write_text('not json at all')
|
|
140
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'corrupt')
|
|
141
|
+
|
|
142
|
+
def test_kill_switch(self):
|
|
143
|
+
d = str(self.root / 'acct-01')
|
|
144
|
+
self.keychain.write(d, DOC)
|
|
145
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '0'
|
|
146
|
+
self.assertFalse(self.keychain.enabled())
|
|
147
|
+
self.assertEqual(self.keychain.probe(d)['state'], 'absent')
|
|
148
|
+
os.environ['CLAUDE_MULTIACC_KEYCHAIN'] = '1'
|
|
149
|
+
|
|
150
|
+
# -- audit + report integration --------------------------------------------------
|
|
151
|
+
def _manifest(self):
|
|
152
|
+
(self.root / 'accounts.json').write_text(json.dumps({
|
|
153
|
+
'version': 1, 'server': 'root@203.0.113.1',
|
|
154
|
+
'server_root': '/root/.claude-accounts',
|
|
155
|
+
'server_repo': '/root/claude-multiacc', 'threshold': 90,
|
|
156
|
+
'accounts': [{'id': 'acct-01', 'email': 'kc@test', 'home': 'mac',
|
|
157
|
+
'added_at': '2026-08-28T00:00:00Z'}]}))
|
|
158
|
+
|
|
159
|
+
def test_audit_sees_a_keychain_login(self):
|
|
160
|
+
import audit
|
|
161
|
+
self._manifest()
|
|
162
|
+
d = str(self.root / 'acct-01')
|
|
163
|
+
self.assertEqual(
|
|
164
|
+
audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
165
|
+
'home': 'mac'}, machine='mac')['state'],
|
|
166
|
+
'missing')
|
|
167
|
+
self.keychain.write(d, DOC)
|
|
168
|
+
row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
169
|
+
'home': 'mac'}, machine='mac')
|
|
170
|
+
self.assertEqual((row['state'], row['store']), ('ok', 'keychain'))
|
|
171
|
+
os.environ['FAKE_KEYCHAIN_LOCKED'] = '1'
|
|
172
|
+
row = audit.audit_account(str(self.root), {'id': 'acct-01', 'email': 'kc@test',
|
|
173
|
+
'home': 'mac'}, machine='mac')
|
|
174
|
+
self.assertEqual(row['state'], 'locked')
|
|
175
|
+
self.assertEqual(row['label'], 'KEYCHAIN LOCKED')
|
|
176
|
+
os.environ.pop('FAKE_KEYCHAIN_LOCKED', None)
|
|
177
|
+
|
|
178
|
+
def test_report_class_and_status(self):
|
|
179
|
+
self._manifest()
|
|
180
|
+
d = str(self.root / 'acct-01')
|
|
181
|
+
self.keychain.write(d, DOC)
|
|
182
|
+
out = subprocess.run(
|
|
183
|
+
[sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
|
|
184
|
+
'claude', 'mac', 'list'],
|
|
185
|
+
capture_output=True, text=True, check=True, env=os.environ.copy())
|
|
186
|
+
row = json.loads(out.stdout)['accounts'][0]
|
|
187
|
+
self.assertEqual(row['status'], 'active')
|
|
188
|
+
self.assertEqual(row['credential_class'], 'machine-local')
|
|
189
|
+
self.assertEqual(row['credentials']['oauth_store'], 'keychain')
|
|
190
|
+
self.assertEqual(row['credentials']['keychain'], 'readable')
|
|
191
|
+
env = dict(os.environ, FAKE_KEYCHAIN_LOCKED='1')
|
|
192
|
+
out = subprocess.run(
|
|
193
|
+
[sys.executable, str(REPO / 'lib' / 'report.py'), str(self.root),
|
|
194
|
+
'claude', 'mac', 'list'],
|
|
195
|
+
capture_output=True, text=True, check=True, env=env)
|
|
196
|
+
row = json.loads(out.stdout)['accounts'][0]
|
|
197
|
+
self.assertEqual(row['status'], 'locked')
|
|
198
|
+
self.assertEqual(row['credential_class'], 'machine-local')
|
|
199
|
+
self.assertFalse(row['selectable'])
|
|
200
|
+
self.assertFalse(row['needs_login'])
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
if __name__ == '__main__':
|
|
204
|
+
unittest.main()
|