Honor explicit Kakao auth recovery overrides

The helper now treats manual auth overrides as a cache-bypassing recovery request and rejects invalid brute-force tuning flags at the CLI boundary so users get deterministic behavior instead of stale cached tuples or Python tracebacks. Regression coverage locks both paths before the PR follow-up lands.

Constraint: The helper must remain a thin read-only wrapper around kakaocli auth recovery
Rejected: Require --refresh whenever --user-id/--uuid is passed | worse UX than honoring overrides directly
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep explicit auth overrides ahead of cache reuse unless the CLI contract is redesigned and documented
Tested: python3 -m unittest scripts.test_kakaotalk_mac; node --test scripts/skill-docs.test.js; npm run ci; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id 800000000 --workers 8 --chunk-size 2000000; python3 scripts/kakaotalk_mac.py chats --limit 1 --json; python3 scripts/kakaotalk_mac.py auth --cache-path <bad-json>; python3 scripts/kakaotalk_mac.py auth --refresh --max-user-id -1; python3 scripts/kakaotalk_mac.py auth --refresh --workers 2 --chunk-size 0 --max-user-id 10; python3 scripts/kakaotalk_mac.py auth --cache-path <temp-cache> --user-id 999; python3 scripts/kakaotalk_mac.py auth --cache-path <temp-cache> --uuid <live-uuid>
Not-tested: Manual override success with a truly alternate valid user_id/uuid pair on a multi-account local install
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-04-16 18:55:32 +09:00
commit c81b710cae
2 changed files with 113 additions and 7 deletions

View file

@ -502,7 +502,8 @@ def resolve_auth(
workers: int | None,
chunk_size: int,
) -> ResolvedAuth:
if not refresh:
use_cache = not refresh and user_id_override is None and uuid_override is None
if use_cache:
cached = load_cached_auth(cache_path)
if cached is not None:
return cached
@ -576,10 +577,10 @@ def build_passthrough_command(command: str, auth: ResolvedAuth, forwarded_args:
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
args, forwarded_args = parser.parse_known_args(argv)
cache_path = Path(args.cache_path).expanduser()
try:
args, forwarded_args = parser.parse_known_args(argv)
cache_path = Path(args.cache_path).expanduser()
if args.command == "auth":
if forwarded_args:
raise AuthResolutionError(f"Unexpected auth arguments: {' '.join(forwarded_args)}")
@ -606,7 +607,7 @@ def main(argv: Sequence[str] | None = None) -> int:
)
result = subprocess.run(build_passthrough_command(args.command, resolved, forwarded_args))
return result.returncode
except AuthResolutionError as exc:
except (AuthResolutionError, ValueError) as exc:
print(f"error: {exc}", file=sys.stderr)
return 1
@ -634,9 +635,23 @@ def add_auth_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--cache-path", default=str(DEFAULT_CACHE_PATH))
parser.add_argument("--user-id", type=int, help="Explicit Kakao user_id override.")
parser.add_argument("--uuid", help="Explicit device UUID override.")
parser.add_argument("--max-user-id", type=int, default=DEFAULT_MAX_USER_ID)
parser.add_argument("--workers", type=int, default=None)
parser.add_argument("--chunk-size", type=int, default=DEFAULT_CHUNK_SIZE)
parser.add_argument("--max-user-id", type=non_negative_int, default=DEFAULT_MAX_USER_ID)
parser.add_argument("--workers", type=positive_int, default=None)
parser.add_argument("--chunk-size", type=positive_int, default=DEFAULT_CHUNK_SIZE)
def non_negative_int(value: str) -> int:
integer = int(value)
if integer < 0:
raise argparse.ArgumentTypeError("must be non-negative")
return integer
def positive_int(value: str) -> int:
integer = int(value)
if integer <= 0:
raise argparse.ArgumentTypeError("must be positive")
return integer
if __name__ == "__main__":

View file

@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import json
import io
import tempfile
import unittest
from pathlib import Path
@ -156,6 +157,76 @@ class KakaoTalkMacHelperTests(unittest.TestCase):
collect_state.assert_called_once_with(None)
resolve_state.assert_called_once()
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
persistable = make_resolved_auth(database_path=database_path, source="cache")
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
):
resolved = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=999,
uuid_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(resolved, override_result)
collect_state.assert_called_once_with(None)
resolve_state.assert_called_once_with(
mock.sentinel.state,
verify_access=kakaotalk_mac.verify_database_access,
cache_path=cache_path,
user_id_override=999,
max_user_id=1000,
workers=1,
chunk_size=100,
)
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
with tempfile.TemporaryDirectory() as tempdir:
cache_path = Path(tempdir) / "auth-cache.json"
database_path = Path(tempdir) / "kakaotalk.db"
database_path.write_text("", encoding="utf-8")
persistable = make_resolved_auth(database_path=database_path, source="cache")
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
with (
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
):
resolved = kakaotalk_mac.resolve_auth(
refresh=False,
cache_path=cache_path,
user_id_override=None,
uuid_override="override-uuid",
max_user_id=1000,
workers=1,
chunk_size=100,
)
self.assertEqual(resolved, override_result)
collect_state.assert_called_once_with("override-uuid")
resolve_state.assert_called_once_with(
mock.sentinel.state,
verify_access=kakaotalk_mac.verify_database_access,
cache_path=cache_path,
user_id_override=None,
max_user_id=1000,
workers=1,
chunk_size=100,
)
def test_render_auth_text_redacts_key_material(self) -> None:
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
@ -178,5 +249,25 @@ class KakaoTalkMacHelperTests(unittest.TestCase):
self.assertEqual(sorted(subcommands), ["auth", "chats", "messages", "schema", "search"])
self.assertNotIn("query", subcommands)
def test_build_parser_rejects_negative_max_user_id(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
parser.parse_args(["auth", "--max-user-id", "-1"])
self.assertEqual(exit_context.exception.code, 2)
self.assertIn("must be non-negative", stderr.getvalue())
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
parser.parse_args(["auth", "--chunk-size", "0"])
self.assertEqual(exit_context.exception.code, 2)
self.assertIn("must be positive", stderr.getvalue())
if __name__ == "__main__":
unittest.main()