diff --git a/.gitignore b/.gitignore index 2c40c5a4..3a4491df 100644 --- a/.gitignore +++ b/.gitignore @@ -72,13 +72,7 @@ src/octop/dashboard/assets/* !src/octop/dashboard/__init__.py !src/octop/dashboard/.gitkeep -# 美团生活助手专家:随包分发 pt-passport 运行时(Docker 镜像无 npm, -# 依赖此预装产物执行认证;包内文件由来源 tgz 固定,不会被误提交污染) -# 注意:git 不会进入被忽略的目录,因此必须先反排除 node_modules 目录本身 -!src/octop/infra/agents/experts/library/meituan-living-assistant/ -!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/ -!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/ -!src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/ +# Ship Meituan pt-passport runtime; override global node_modules/ ignore. !src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/node_modules/ !src/octop/infra/agents/experts/library/meituan-living-assistant/skills/meituan-deals/scripts/node_modules/** @@ -98,6 +92,10 @@ logs/ # Local monorepo dev helpers (not for upstream) scripts/dev-local-link.sh +# Local downloaded platform-tools for mobile DinD testing +.tools/ +.tools-sdk/ + # Local agent planning docs (not for upstream) docs/superpowers/ scripts/lh_image_install_octop_tencentos.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 75a81a9b..5f812c6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ ## [Unreleased] +## [0.9.26] - 2026-08-23 + +### 新增 + +- 远程手机(实验性):安装时探测主机移动能力(`capabilities.mobile`,物理机 / Redroid / KVM);能力开启后开放 `GET /api/settings/capabilities` 与 `/api/mobile/*`。控制台「远程手机」支持 adb H.264/JPEG 推流、触控、画质预设、设备信息与 AI 助手面板;智能体移动工具绑定当前远程手机会话 +- 控制台布局支持经典 / 极简模式,聊天记录统一承载;用户可选填邮箱(邀请 / 登录);知识库支持应用内编辑 markdown / txt;远程桌面与远程手机合并为统一控制入口 + +### 修复 + +- 邀请链接统一为 `/invite?code=`,修复邀请页居中与移动端在 overflow-hidden 壳下的滚动;启动前显示 logo 加载动画;设置页邮箱输入图标对齐;移除聊天坞中的远程手机入口 +- 加固 dashboard 鉴权与请求层(setup 锁定 503、401 刷新)、登录页与 AuthGuard 体验;按服务器能力门控移动端功能;远程控制中枢页签文案缩短为「服务器 / 手机」 + ## [0.9.25] - 2026-08-21 ### 新增 diff --git a/README.md b/README.md index 32432a8e..c2d43c75 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@

Python 3.12+ License: MIT - Version + Version PyPI Code Style: Ruff GitHub stars diff --git a/dashboard/index.html b/dashboard/index.html index 1b25efbd..c2902f0b 100644 --- a/dashboard/index.html +++ b/dashboard/index.html @@ -30,6 +30,109 @@ Octop + + " + return json.dumps({"device": serial, "xml": out}, ensure_ascii=False) + except Exception as exc: + return json.dumps({"error": str(exc)}, ensure_ascii=False) + + async def mobile_handoff_to_user( + reason: Annotated[ + str, + Field(description="Why the user must take over (login, captcha, payment, etc.)."), + ], + ) -> str: + try: + ctx = _tool_ctx() + locale = str(ctx.get("locale") or "en") + _require_mobile_access(config, ctx, user_repo) + msg = tr( + "mobile.handoff_message", + locale, + reason=reason.strip() or tr("mobile.handoff_default", locale), + ) + return json.dumps({"handoff": True, "message": msg}, ensure_ascii=False) + except Exception as exc: + return json.dumps({"error": str(exc)}, ensure_ascii=False) + + if not find_adb(): + return [] + + return [ + StructuredTool.from_function( + coroutine=mobile_screenshot, + name=MOBILE_SCREENSHOT, + description="Capture the connected Android device screen (PNG saved under the agent workspace when possible).", + ), + StructuredTool.from_function( + coroutine=mobile_tap, + name=MOBILE_TAP, + description="Tap a coordinate on the connected Android device screen.", + ), + StructuredTool.from_function( + coroutine=mobile_swipe, + name=MOBILE_SWIPE, + description="Swipe on the connected Android device screen.", + ), + StructuredTool.from_function( + coroutine=mobile_launch_app, + name=MOBILE_LAUNCH_APP, + description="Launch an Android app by package name via adb.", + ), + StructuredTool.from_function( + coroutine=mobile_ui_dump, + name=MOBILE_UI_DUMP, + description="Dump the Android UI hierarchy (uiautomator XML) for element discovery.", + ), + StructuredTool.from_function( + coroutine=mobile_handoff_to_user, + name=MOBILE_HANDOFF, + description="Ask the human user to complete login, captcha, or other manual steps on the device.", + ), + ] diff --git a/src/octop/infra/server.py b/src/octop/infra/server.py index f76ebc6b..656e9ae0 100644 --- a/src/octop/infra/server.py +++ b/src/octop/infra/server.py @@ -21,6 +21,7 @@ from octop.infra.db.migrate import run_migrations from octop.infra.db.services import SharedServices, build_shared_services from octop.infra.gateway.gateway import Gateway +from octop.infra.mobile.config_probe import ensure_mobile_capabilities_probed from octop.infra.proactive.scheduler import ProactiveCareScheduler from octop.infra.proactive.service import ProactiveCareService from octop.infra.setup import password_file as _wizard_pw @@ -148,7 +149,7 @@ async def start(self) -> None: apply_env_file(env_file_path(self.paths.root)) self._setup_logging() - config = load_config(self.paths.config) + config = ensure_mobile_capabilities_probed(self.paths.config) self.config = config self.expert_catalog = ExpertCatalog( diff --git a/src/octop/infra/users/email.py b/src/octop/infra/users/email.py new file mode 100644 index 00000000..5196fd68 --- /dev/null +++ b/src/octop/infra/users/email.py @@ -0,0 +1,34 @@ +"""Email normalization and light validation for local users.""" + +from __future__ import annotations + +import re + +from octop.infra.errors import ErrorCode, OctopError + +# Practical address shape (not full RFC 5322). +_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") + + +def normalize_email(raw: str | None) -> str | None: + """Strip and lowercase; empty / whitespace-only becomes ``None``.""" + if raw is None: + return None + cleaned = raw.strip().lower() + return cleaned or None + + +def validate_email_format(email: str) -> None: + """Raise ``OctopError(EMAIL_INVALID)`` when *email* is not a plausible address.""" + if not _EMAIL_RE.match(email): + raise OctopError(ErrorCode.EMAIL_INVALID, "invalid email address", status=400) + + +def parse_optional_email(raw: str | None, *, required_valid: bool = True) -> str | None: + """Normalize optional email input; optionally validate non-empty values.""" + email = normalize_email(raw) + if email is None: + return None + if required_valid: + validate_email_format(email) + return email diff --git a/src/octop/infra/users/invites.py b/src/octop/infra/users/invites.py index 3ffb745b..969a748d 100644 --- a/src/octop/infra/users/invites.py +++ b/src/octop/infra/users/invites.py @@ -55,8 +55,8 @@ def generate_invite_code() -> str: def invite_path(code: str) -> str: - """Canonical share path (SPA root query), matching common invite links.""" - return f"/?invite={code}" + """Canonical share path for invite redemption UI.""" + return f"/invite?code={code}" def build_invite_url(base_url: str, code: str) -> str: @@ -155,7 +155,10 @@ def redeem( display_name: str | None, locale: str | None, register_user: Callable[[User], None], + email: str | None = None, ) -> User: + from octop.infra.users.email import parse_optional_email + cleaned_code = (code or "").strip() cleaned_username = (username or "").strip() if not cleaned_code: @@ -167,6 +170,7 @@ def redeem( name = display_name.strip() if isinstance(display_name, str) else None if name == "": name = None + normalized_email = parse_optional_email(email) try: user_id, _invite = self._repo.redeem_creating_user( code=cleaned_code, @@ -174,6 +178,7 @@ def redeem( password_hash=hash_password(password), display_name=name, locale=loc, + email=normalized_email, ) except LookupError as exc: raise OctopError(ErrorCode.INVITE_INVALID, "invite not found") from exc @@ -184,6 +189,11 @@ def redeem( ErrorCode.USERNAME_TAKEN, f"username {cleaned_username!r} already exists", ) from exc + if reason == "email_taken": + raise OctopError( + ErrorCode.EMAIL_TAKEN, + f"email {normalized_email!r} already exists", + ) from exc if reason == "used": raise OctopError(ErrorCode.INVITE_USED, "invite already used") from exc if reason == "expired": diff --git a/src/octop/infra/users/manager.py b/src/octop/infra/users/manager.py index 4e907843..d28a90f2 100644 --- a/src/octop/infra/users/manager.py +++ b/src/octop/infra/users/manager.py @@ -21,6 +21,7 @@ from octop.infra.db.repos.users import UserRepo from octop.infra.db.services import SharedServices from octop.infra.errors import ErrorCode, OctopError +from octop.infra.users.email import normalize_email, parse_optional_email from octop.infra.users.identity import Role, User from octop.infra.users.password import hash_password, validate_password_policy, verify_password from octop.infra.users.permissions import validate_permission_keys @@ -63,10 +64,7 @@ def allocate_username(repo: UserRepo, claims: dict[str, Any], subject: str) -> s def _normalized_claim_email(claims: dict[str, Any]) -> str | None: - email = claims.get("email") - if not isinstance(email, str): - return None - return email.strip().lower() or None + return normalize_email(claims.get("email") if isinstance(claims.get("email"), str) else None) def _claim_display_name(claims: dict[str, Any]) -> str | None: @@ -129,6 +127,7 @@ async def create( display_name: str | None = None, locale: str | None = None, permissions: builtins.list[str] | None = None, + email: str | None = None, ) -> User: if not username: raise OctopError(ErrorCode.USERNAME_TAKEN, "username must not be empty") @@ -138,20 +137,38 @@ async def create( keys = validate_permission_keys(permissions or []) except ValueError as exc: raise OctopError(ErrorCode.FORBIDDEN, str(exc), status=400) from exc + normalized_email = parse_optional_email(email) async with self._lock: if self._services.user_repo.get_by_username(username) is not None: raise OctopError( ErrorCode.USERNAME_TAKEN, f"username {username!r} already exists", ) - uid = self._services.user_repo.create( - username=username, - password_hash=hash_password(password), - role=role.value, - display_name=display_name, - locale=loc, - permissions=keys, - ) + if ( + normalized_email is not None + and self._services.user_repo.get_by_email(normalized_email) is not None + ): + raise OctopError( + ErrorCode.EMAIL_TAKEN, + f"email {normalized_email!r} already exists", + ) + try: + uid = self._services.user_repo.create( + username=username, + password_hash=hash_password(password), + role=role.value, + display_name=display_name, + locale=loc, + email=normalized_email, + permissions=keys, + ) + except Exception as exc: + if _is_unique_violation(exc) and normalized_email is not None: + raise OctopError( + ErrorCode.EMAIL_TAKEN, + f"email {normalized_email!r} already exists", + ) from exc + raise user = User( id=uid, username=username, @@ -176,6 +193,7 @@ async def create_from_invite( password: str, display_name: str | None = None, locale: str | None = None, + email: str | None = None, ) -> User: from octop.infra.users.invites import InviteService @@ -186,6 +204,7 @@ async def create_from_invite( password=password, display_name=display_name, locale=locale, + email=email, register_user=self.register_cached_user, ) @@ -296,7 +315,14 @@ async def resolve_or_create_sso_user( return cached_user async def authenticate(self, username: str, password: str) -> User | None: - row = self._services.user_repo.get_by_username(username) + identifier = (username or "").strip() + if not identifier: + return None + row = self._services.user_repo.get_by_username(identifier) + if row is None: + email = normalize_email(identifier) + if email is not None: + row = self._services.user_repo.get_by_email(email) if row is None: return None now = int(time.time()) @@ -320,7 +346,9 @@ async def authenticate(self, username: str, password: str) -> User | None: lockout_seconds=self._login_lockout_seconds, now=now, ) - self._services.audit_repo.write(actor=username, action="auth.failed", target=username) + self._services.audit_repo.write( + actor=row.username, action="auth.failed", target=row.username + ) if retry_after > 0: minutes = max(1, (retry_after + 59) // 60) raise OctopError( @@ -330,7 +358,7 @@ async def authenticate(self, username: str, password: str) -> User | None: ) return None self._services.user_repo.clear_login_lockout(row.id) - user = self._users.get(username) + user = self._users.get(row.username) if user is None: user = User( id=row.id, @@ -340,8 +368,8 @@ async def authenticate(self, username: str, password: str) -> User | None: locale=normalize_locale(row.locale), permissions=list(row.permissions), ) - self._users[username] = user - self._services.audit_repo.write(actor=username, action="auth.login") + self._users[row.username] = user + self._services.audit_repo.write(actor=row.username, action="auth.login") return user async def change_password(self, username: str, old: str, new: str) -> None: @@ -413,6 +441,33 @@ async def set_display_name(self, username: str, display_name: str | None) -> Non if current is not None: current.display_name = display_name + async def set_email(self, username: str, email: str | None) -> None: + row = self._services.user_repo.get_by_username(username) + if row is None: + raise OctopError(ErrorCode.NOT_FOUND, "user not found") + normalized = parse_optional_email(email) + if normalized is not None: + owner = self._services.user_repo.get_by_email(normalized) + if owner is not None and owner.id != row.id: + raise OctopError( + ErrorCode.EMAIL_TAKEN, + f"email {normalized!r} already exists", + ) + try: + self._services.user_repo.set_email(row.id, normalized) + except Exception as exc: + if _is_unique_violation(exc) and normalized is not None: + raise OctopError( + ErrorCode.EMAIL_TAKEN, + f"email {normalized!r} already exists", + ) from exc + raise + self._services.audit_repo.write( + actor=ACTOR_ADMIN, + action="user.set_email", + target=username, + ) + async def set_locale(self, username: str, locale: str) -> None: row = self._services.user_repo.get_by_username(username) if row is None: diff --git a/src/octop/infra/users/permissions.py b/src/octop/infra/users/permissions.py index cba45724..6b1d26ae 100644 --- a/src/octop/infra/users/permissions.py +++ b/src/octop/infra/users/permissions.py @@ -57,6 +57,7 @@ def _p( "terminal": _p("terminal", "control", "工作台/终端", "Workbench / Terminal"), "browser": _p("browser", "control", "工作台/浏览器", "Workbench / Browser"), "desktop": _p("desktop", "control", "远程桌面", "Remote Desktop"), + "mobile": _p("mobile", "control", "远程手机", "Remote Phone"), # --- admin: grouped by page, chip = tab title --- "users": _p( "users", diff --git a/src/octop/infra/utils/host_dirs.py b/src/octop/infra/utils/host_dirs.py index 68528b81..32aae08c 100644 --- a/src/octop/infra/utils/host_dirs.py +++ b/src/octop/infra/utils/host_dirs.py @@ -27,12 +27,12 @@ def host_path_text(path: Path) -> str: """Serialize a host path for API/UI (POSIX separators, even on Windows).""" - return path.expanduser().resolve().as_posix() + return Path(os.path.realpath(os.path.expanduser(str(path)))).as_posix() def host_home_dir() -> Path: """Absolute home directory of the OS user running the Octop process.""" - return Path.home().expanduser().resolve() + return Path(os.path.realpath(os.path.expanduser(str(Path.home())))) def host_fs_tree_root(*, allow_outside_home: bool) -> str: @@ -50,30 +50,48 @@ def host_fs_tree_root(*, allow_outside_home: bool) -> str: return host_path_text(Path(home.anchor)) +def _path_within_base(resolved: str, base: str) -> bool: + """True when *resolved* equals *base* or is a subdirectory (normcase-safe). + + Uses ``startswith`` after ``os.path.realpath`` so CodeQL treats this as a + path-injection containment barrier (``Path.resolve`` / ``relative_to`` are + not modeled as sanitizers). + """ + resolved_n = os.path.normcase(resolved) + base_n = os.path.normcase(base) + if resolved_n == base_n: + return True + if base_n.endswith(os.sep): + return resolved_n.startswith(base_n) + return resolved_n.startswith(base_n + os.sep) + + def is_within_host_home(resolved: Path, *, home: Path | None = None) -> bool: """True when *resolved* is the host home directory or a subdirectory.""" - base = (home or host_home_dir()).resolve() - target = resolved.resolve() - try: - target.relative_to(base) - return True - except ValueError: - if os.name != "nt": - return False - # Windows paths are case-insensitive. - base_s = os.path.normcase(str(base)) - target_s = os.path.normcase(str(target)) - return target_s == base_s or target_s.startswith(base_s + os.sep) + base = os.path.realpath(str(home or host_home_dir())) + target = os.path.realpath(str(resolved)) + return _path_within_base(target, base) def normalize_host_path(path: str) -> Path: + """Canonical absolute path via ``os.path.realpath`` (CodeQL-recognized).""" raw = path.strip() or ("/" if os.name == "posix" else str(Path.home().anchor)) - return Path(raw).expanduser().resolve() + return Path(os.path.realpath(os.path.expanduser(raw))) + + +def _browse_tree_base() -> str: + """Containment root for home-jailed picker paths.""" + return os.path.realpath(str(host_home_dir())) def _is_denied_host_path(resolved: Path) -> bool: if os.name != "posix": return False + # Process home may coincide with a denylist prefix (uid 0 → ``/root``). + # The UI defaults ``root_dir`` to home, so home and its subdirs must stay + # selectable; ``/root`` remains denied for non-root process homes. + if is_within_host_home(resolved): + return False text = resolved.as_posix() # macOS resolves /etc → /private/etc (and similar). Strip that prefix so # denied policy still matches the logical system locations. @@ -89,19 +107,32 @@ def assert_safe_host_path(path: str, *, restrict_to_home: bool = False) -> Path: subdirectories are allowed. The HTTP filesystem / expert APIs pass ``False`` so any authenticated user may pick outside home (denylist still applies). + + Normalization uses ``os.path.realpath`` and containment uses ``startswith`` + against the browse-tree base — the pattern CodeQL recognizes for + ``py/path-injection`` (unlike ``Path.resolve`` alone). """ if not path or "\0" in path: raise ValueError("invalid path") try: - resolved = normalize_host_path(path) + # normalize_host_path already realpath's; keep a str for startswith + # so CodeQL sees a containment sanitizer on the FS path we return. + resolved_s = os.fspath(normalize_host_path(path)) except OSError as exc: raise ValueError("invalid path") from exc - if not resolved.is_absolute(): + if not os.path.isabs(resolved_s): raise ValueError("path must be absolute") + resolved = Path(resolved_s) if _is_denied_host_path(resolved): raise ValueError(_NOT_ALLOWED_MSG) - if restrict_to_home and not is_within_host_home(resolved): - raise ValueError(_OUTSIDE_HOME_MSG) + # Home jail only. The host-root case (restrict_to_home=False) allows any + # absolute path: on POSIX everything sits under "/", and on Windows the + # denylist is empty while windows_neutralize_host_root rewrites "/" at + # runtime, so a drive-relative "/" must not be rejected there. + if restrict_to_home: + base = _browse_tree_base() + if not _path_within_base(resolved_s, base): + raise ValueError(_OUTSIDE_HOME_MSG) return resolved @@ -123,7 +154,7 @@ def list_host_subdirs(path: str, *, restrict_to_home: bool = False) -> list[dict if not child.is_dir(): continue try: - resolved = child.resolve() + resolved = Path(os.path.realpath(str(child))) if not resolved.is_dir(): continue if _is_denied_host_path(resolved): diff --git a/tests/integration/test_invites_api.py b/tests/integration/test_invites_api.py index 086efdbd..fe54d7e6 100644 --- a/tests/integration/test_invites_api.py +++ b/tests/integration/test_invites_api.py @@ -16,7 +16,7 @@ async def test_invite_create_list_redeem_and_one_time(env) -> None: assert invite["status"] == "pending" assert invite["note"] == "for bob" assert invite["code"] - assert invite["invite_path"].startswith("/?invite=") + assert invite["invite_path"].startswith("/invite?code=") assert invite["invite_url"].endswith(invite["invite_path"]) code = invite["code"] diff --git a/tests/integration/test_published_experts.py b/tests/integration/test_published_experts.py index e3f0b4b1..8b8347f2 100644 --- a/tests/integration/test_published_experts.py +++ b/tests/integration/test_published_experts.py @@ -2,10 +2,17 @@ from __future__ import annotations +import os from typing import Any +import pytest + from tests.support.auth import create_user +posix_only = pytest.mark.skipif( + os.name != "posix", reason="local_shell backend workspace file ops unsupported on Windows" +) + async def _owner_and_peer( env: tuple[Any, Any, dict[str, str]], @@ -66,6 +73,7 @@ async def test_publish_list_install_and_unpublish_preserves_installed_fork( assert fork.json()["name"] == "Peer installed expert" +@posix_only async def test_install_published_expert_accepts_create_options( env: tuple[Any, Any, dict[str, str]], ) -> None: diff --git a/tests/unit/i18n/test_mobile.py b/tests/unit/i18n/test_mobile.py new file mode 100644 index 00000000..c3c9c25a --- /dev/null +++ b/tests/unit/i18n/test_mobile.py @@ -0,0 +1,13 @@ +"""tests/unit/i18n/test_mobile.py""" + +from __future__ import annotations + +from octop.i18n.loader import all_keys_for_locale + + +def test_mobile_keys_parity() -> None: + en_keys = {k for k in all_keys_for_locale("en") if k.startswith("mobile.")} + zh_keys = {k for k in all_keys_for_locale("zh") if k.startswith("mobile.")} + assert en_keys == zh_keys + assert "mobile.no_device" in en_keys + assert "mobile.handoff_message" in en_keys diff --git a/tests/unit/infra/utils/test_host_dirs.py b/tests/unit/infra/utils/test_host_dirs.py index b30eb128..be79dc9a 100644 --- a/tests/unit/infra/utils/test_host_dirs.py +++ b/tests/unit/infra/utils/test_host_dirs.py @@ -72,11 +72,60 @@ def test_assert_safe_host_path_rejects_private_etc_symlink() -> None: @posix_only -def test_assert_safe_host_path_rejects_root() -> None: +def test_assert_safe_host_path_rejects_root( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + # Deny /root only when it is not the process home (non-root Octop). + home = tmp_path / "os_home" + home.mkdir() + monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home) with pytest.raises(ValueError, match="not allowed"): assert_safe_host_path("/root") +@posix_only +def test_assert_safe_host_path_allows_root_when_home( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Octop running as uid 0 uses /root as home; default root_dir must probe OK.""" + root_home = Path("/root") + if not root_home.is_dir(): + pytest.skip("/root not available") + monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: root_home) + assert assert_safe_host_path("/root") == root_home.resolve() + nested = root_home / ".octop" + # Nested path under home is allowed even if the parent is denylisted for others. + assert assert_safe_host_path(str(nested)) == nested.resolve() + + +@posix_only +def test_list_host_subdirs_includes_root_home_when_denied_prefix( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Process home ``/root`` must appear when listing ``/`` (uid 0 default).""" + root_home = Path("/root") + if not root_home.is_dir() or not os.access(root_home, os.R_OK | os.X_OK): + pytest.skip("/root not readable") + monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: root_home) + + entries = list_host_subdirs("/") + paths = {item["path"] for item in entries} + assert root_home.resolve().as_posix() in paths + + +@posix_only +def test_list_host_subdirs_hides_root_when_not_home( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + home = tmp_path / "os_home" + home.mkdir() + monkeypatch.setattr("octop.infra.utils.host_dirs.Path.home", lambda: home) + + entries = list_host_subdirs("/") + paths = {item["path"] for item in entries} + assert "/root" not in paths + + @posix_only def test_probe_host_root_dir_skips_write_for_slash() -> None: result = probe_host_root_dir("/") diff --git a/tests/unit/knowledge/test_text_documents.py b/tests/unit/knowledge/test_text_documents.py new file mode 100644 index 00000000..a4dc90d9 --- /dev/null +++ b/tests/unit/knowledge/test_text_documents.py @@ -0,0 +1,73 @@ +"""KnowledgeService text create / update helpers.""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from octop.infra.db.migrate import run_migrations +from octop.infra.db.pool import SqlitePool +from octop.infra.db.repos.knowledge import KnowledgeRepo +from octop.infra.db.repos.users import UserRepo +from octop.infra.knowledge.files import document_path +from octop.infra.knowledge.service import KnowledgeService +from octop.infra.utils.paths import PathLayout + + +@pytest.fixture +def services(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> SimpleNamespace: + monkeypatch.setenv("OCTOP_HOME", str(tmp_path / ".octop")) + paths = PathLayout.from_env() + paths.ensure_root() + db = SqlitePool(paths.db) + run_migrations(db) + owner_id = UserRepo(db).create(username="owner", password_hash="h", role="user") + settings = MagicMock() + settings.get.side_effect = lambda key, default=None: { + "knowledge_feature_enabled": "1", + "knowledge_embedding_backend": "onnx", + "knowledge_embedding_model": "tiny", + }.get(key, default) + return SimpleNamespace( + knowledge_repo=KnowledgeRepo(db), + settings_repo=settings, + provider_repo=None, + owner_id=owner_id, + ) + + +def test_create_and_update_text_document( + services: SimpleNamespace, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setattr( + "octop.infra.knowledge.service.assert_knowledge_usable", + lambda *_a, **_k: None, + ) + svc = KnowledgeService(services) + base = svc.create_base(owner_user_id=services.owner_id, name="Docs") + created = svc.create_text_document( + base.id, + actor_user_id=services.owner_id, + name="notes", + format="md", + content="# Hello\n", + ) + assert created.filename == "notes.md" + assert created.content_type == "text/markdown" + assert created.status == "pending" + path = document_path(base.id, created.id, created.filename) + assert path.read_text(encoding="utf-8") == "# Hello\n" + + updated = svc.update_text_document( + base.id, + created.id, + actor_user_id=services.owner_id, + content="# Updated\n", + ) + assert updated.status == "pending" + assert path.read_text(encoding="utf-8") == "# Updated\n" + raw = svc.read_text_document(base.id, created.id, actor_user_id=services.owner_id) + assert raw["text"] == "# Updated\n" diff --git a/tests/unit/mobile/test_agent_control.py b/tests/unit/mobile/test_agent_control.py new file mode 100644 index 00000000..73f0ee2d --- /dev/null +++ b/tests/unit/mobile/test_agent_control.py @@ -0,0 +1,40 @@ +"""Unit tests for mobile agent-control binding.""" + +from __future__ import annotations + +from octop.infra.mobile.agent_control import ( + clear_mobile_agent_control_if_device, + get_mobile_agent_control, + set_mobile_agent_control, +) + + +def setup_function() -> None: + set_mobile_agent_control(enabled=False, device=None) + + +def test_enable_requires_device() -> None: + try: + set_mobile_agent_control(enabled=True, device=None) + raise AssertionError("expected ValueError") + except ValueError: + pass + assert get_mobile_agent_control().enabled is False + + +def test_enable_and_disable() -> None: + st = set_mobile_agent_control(enabled=True, device="3b678f5c") + assert st.enabled is True + assert st.device == "3b678f5c" + st = set_mobile_agent_control(enabled=False, device="3b678f5c") + assert st.enabled is False + assert st.device is None + + +def test_clear_when_bound_device_leaves() -> None: + set_mobile_agent_control(enabled=True, device="phone") + clear_mobile_agent_control_if_device("emulator-5554") + assert get_mobile_agent_control().enabled is True + clear_mobile_agent_control_if_device("phone") + assert get_mobile_agent_control().enabled is False + assert get_mobile_agent_control().device is None diff --git a/tests/unit/mobile/test_config_probe.py b/tests/unit/mobile/test_config_probe.py new file mode 100644 index 00000000..4d0989dd --- /dev/null +++ b/tests/unit/mobile/test_config_probe.py @@ -0,0 +1,22 @@ +"""tests/unit/mobile/test_config_probe.py""" + +from __future__ import annotations + +import json +from pathlib import Path + +from octop.config import load_config +from octop.infra.mobile.config_probe import persist_mobile_probe +from octop.infra.mobile.probe import MobileProbeResult + + +def test_persist_mobile_probe_merges(tmp_path: Path) -> None: + cfg_path = tmp_path / "config.json" + cfg_path.write_text(json.dumps({"port": 9000}), encoding="utf-8") + result = MobileProbeResult(True, "physical", "", "2026-01-01T00:00:00Z") + persist_mobile_probe(cfg_path, result) + data = json.loads(cfg_path.read_text(encoding="utf-8")) + assert data["port"] == 9000 + assert data["capabilities"]["mobile"]["backend"] == "physical" + cfg = load_config(cfg_path) + assert cfg.capabilities.mobile.enabled is True diff --git a/tests/unit/mobile/test_device_info.py b/tests/unit/mobile/test_device_info.py new file mode 100644 index 00000000..d5745e7e --- /dev/null +++ b/tests/unit/mobile/test_device_info.py @@ -0,0 +1,51 @@ +"""Unit tests for adb device-info parsing.""" + +from __future__ import annotations + +from octop.infra.mobile.adb import parse_device_info_payload + +SAMPLE = """ +model=Pixel 6 +market=Pixel 6 +manufacturer=Google +release=14 +sdk=34 +size=Physical size: 1080x2400 +density=Physical density: 420 +mem_kb=5730304 +cores=8 +df=114884608 52428800 58232832 +fps=fps=60.000004 +""".strip() + + +def test_parse_device_info_payload() -> None: + info = parse_device_info_payload("emulator-5554", SAMPLE) + assert info["device"] == "emulator-5554" + assert info["model"] == "Pixel 6" + assert info["manufacturer"] == "Google" + assert info["android_version"] == "14" + assert info["sdk"] == 34 + assert info["width"] == 1080 + assert info["height"] == 2400 + assert info["density_dpi"] == 420 + assert info["refresh_hz"] == 60.0 + assert info["mem_total_mb"] == 5596 + assert info["cpu_cores"] == 8 + assert info["storage_total_gb"] == round(114884608 / (1024 * 1024), 3) + assert info["storage_used_gb"] == round(52428800 / (1024 * 1024), 3) + assert info["storage_avail_gb"] == round(58232832 / (1024 * 1024), 3) + + +def test_parse_prefers_market_name() -> None: + text = "model=M2102J20SG\nmarket=Redmi K40\nmanufacturer=Xiaomi\n" + info = parse_device_info_payload("3b678f5c", text) + assert info["model"] == "Redmi K40" + + +def test_parse_handles_empty() -> None: + info = parse_device_info_payload("x", "") + assert info["device"] == "x" + assert info["model"] is None + assert info["width"] is None + assert info["cpu_cores"] is None diff --git a/tests/unit/mobile/test_find_adb.py b/tests/unit/mobile/test_find_adb.py new file mode 100644 index 00000000..a64e830f --- /dev/null +++ b/tests/unit/mobile/test_find_adb.py @@ -0,0 +1,73 @@ +"""Unit tests for adb discovery (PATH + SDK env only).""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +from octop.infra.mobile.adb import find_adb + + +def test_find_adb_prefers_path() -> None: + with ( + patch("octop.infra.mobile.adb.shutil.which", return_value="/usr/bin/adb"), + patch.dict("os.environ", {"ANDROID_HOME": "/sdk"}, clear=False), + ): + assert find_adb() == "/usr/bin/adb" + + +def test_find_adb_from_android_home(tmp_path: Path) -> None: + tools = tmp_path / "platform-tools" + tools.mkdir() + adb = tools / ("adb.exe" if os.name == "nt" else "adb") + adb.write_text("", encoding="utf-8") + with ( + patch("octop.infra.mobile.adb.shutil.which", return_value=None), + patch.dict( + "os.environ", + {"ANDROID_HOME": str(tmp_path), "ANDROID_SDK_ROOT": ""}, + clear=False, + ), + ): + assert find_adb() == str(adb) + + +def test_find_adb_from_android_sdk_root(tmp_path: Path) -> None: + tools = tmp_path / "platform-tools" + tools.mkdir() + adb = tools / ("adb.exe" if os.name == "nt" else "adb") + adb.write_text("", encoding="utf-8") + with ( + patch("octop.infra.mobile.adb.shutil.which", return_value=None), + patch.dict( + "os.environ", + {"ANDROID_HOME": "", "ANDROID_SDK_ROOT": str(tmp_path)}, + clear=False, + ), + ): + assert find_adb() == str(adb) + + +def test_find_adb_missing_returns_none() -> None: + with ( + patch("octop.infra.mobile.adb.shutil.which", return_value=None), + patch.dict( + "os.environ", + {"ANDROID_HOME": "", "ANDROID_SDK_ROOT": ""}, + clear=False, + ), + ): + assert find_adb() is None + + +def test_find_adb_ignores_empty_env_roots() -> None: + with ( + patch("octop.infra.mobile.adb.shutil.which", return_value=None), + patch.dict( + "os.environ", + {"ANDROID_HOME": " ", "ANDROID_SDK_ROOT": ""}, + clear=False, + ), + ): + assert find_adb() is None diff --git a/tests/unit/mobile/test_h264.py b/tests/unit/mobile/test_h264.py new file mode 100644 index 00000000..908d6d68 --- /dev/null +++ b/tests/unit/mobile/test_h264.py @@ -0,0 +1,86 @@ +"""Unit tests for mobile capture / H.264 helpers.""" + +from __future__ import annotations + +from octop.infra.mobile.adb import extract_png, extract_raw_rgba, screenrecord_h264_args +from octop.infra.mobile.h264 import ( + NAL_IDR, + NAL_PPS, + NAL_SPS, + AnnexBSplitter, + avc_codec_string, + avcc_from_sps_pps, + avcc_sample, + nal_type, +) + + +def test_extract_png_strips_screencap_warning() -> None: + png = b"\x89PNG\r\n\x1a\n" + b"rest" + blob = b"[Warning] Multiple displays were found\n" + png + assert extract_png(blob) == png + assert extract_png(png) == png + assert extract_png(b"[Warning] only") is None + + +def test_extract_raw_rgba_skips_warning_prefix() -> None: + width, height = 32, 16 + header = ( + width.to_bytes(4, "little") + + height.to_bytes(4, "little") + + (1).to_bytes(4, "little") + + (0).to_bytes(4, "little") + ) + body = bytes([i % 256 for i in range(width * height * 4)]) + blob = b"[Warning] Multiple displays\n" + header + body + got = extract_raw_rgba(blob) + assert got == (width, height, body) + + +def test_annexb_splitter_handles_prefix_and_mixed_start_codes() -> None: + sps = bytes([0x67, 0x42, 0xC0, 0x32, 0x0A]) + pps = bytes([0x68, 0xCE, 0x01, 0xA8]) + idr = bytes([0x65, 0x88, 0x80]) + blob = ( + b"[Warning] ignore me" + + b"\x00\x00\x00\x01" + + sps + + b"\x00\x00\x01" + + pps + + b"\x00\x00\x00\x01" + + idr + + b"\x00\x00\x00\x01" + ) + splitter = AnnexBSplitter() + nals = splitter.feed(blob[:20]) + splitter.feed(blob[20:]) + assert nals == [sps, pps, idr] + assert nal_type(sps) == NAL_SPS + assert nal_type(pps) == NAL_PPS + assert nal_type(idr) == NAL_IDR + + +def test_avcc_and_codec_string() -> None: + sps = bytes([0x67, 0x42, 0xC0, 0x32, 0x0A]) + pps = bytes([0x68, 0xCE, 0x01, 0xA8]) + assert avc_codec_string(sps) == "avc1.42C032" + avcc = avcc_from_sps_pps(sps, pps) + assert avcc[0] == 1 + assert avcc[1:4] == sps[1:4] + idr = bytes([0x65, 0x00]) + sample = avcc_sample([idr]) + assert sample[:4] == (2).to_bytes(4, "big") + assert sample[4:] == idr + + +def test_screenrecord_args_include_display_id() -> None: + cmd = screenrecord_h264_args( + "emulator-5554", adb="/usr/bin/adb", display_id="42", bit_rate=1000 + ) + assert cmd[:4] == ["/usr/bin/adb", "-s", "emulator-5554", "exec-out"] + assert "--output-format=h264" in cmd + assert "--time-limit=0" in cmd + assert "--size" in cmd + assert "720x1600" in cmd + assert "--display-id" in cmd + assert "42" in cmd + assert cmd[-1] == "-" diff --git a/tests/unit/mobile/test_mobile_setup.py b/tests/unit/mobile/test_mobile_setup.py new file mode 100644 index 00000000..294ee29a --- /dev/null +++ b/tests/unit/mobile/test_mobile_setup.py @@ -0,0 +1,68 @@ +"""tests/unit/mobile/test_mobile_setup.py""" + +from __future__ import annotations + +from unittest.mock import patch + +from octop.config import CapabilitiesConfig, MobileCapabilities, OctopConfig +from octop.infra.mobile.setup import mobile_status + + +def _cfg(**mobile: object) -> OctopConfig: + return OctopConfig( + capabilities=CapabilitiesConfig( + mobile=MobileCapabilities(**mobile) # type: ignore[arg-type] + ) + ) + + +def test_mobile_status_disabled() -> None: + status = mobile_status(_cfg(enabled=False, backend="none")) + assert status.mobile_supported is False + assert status.setup_state == "unsupported" + + +def test_mobile_status_physical_ready() -> None: + with ( + patch("octop.infra.mobile.setup.find_adb", return_value="/adb"), + patch("octop.infra.mobile.setup.list_devices", return_value=["emulator-5554"]), + ): + status = mobile_status(_cfg(enabled=True, backend="physical")) + assert status.setup_state == "ready" + assert status.selected_device == "emulator-5554" + + +def test_mobile_status_physical_needs_device() -> None: + with ( + patch("octop.infra.mobile.setup.find_adb", return_value="/adb"), + patch("octop.infra.mobile.setup.list_devices", return_value=[]), + ): + status = mobile_status(_cfg(enabled=True, backend="physical")) + assert status.setup_state == "needs_device" + + +def test_mobile_status_container_reconnects_adb() -> None: + with ( + patch("octop.infra.mobile.setup.find_adb", return_value="/adb"), + patch("octop.infra.mobile.setup._container_running", return_value=True), + patch( + "octop.infra.mobile.setup.list_devices", + side_effect=[[], ["127.0.0.1:5555"]], + ), + patch("octop.infra.mobile.setup.adb_connect", return_value=True) as connect, + ): + status = mobile_status(_cfg(enabled=True, backend="redroid")) + connect.assert_called_once_with("127.0.0.1:5555", adb="/adb") + assert status.setup_state == "ready" + assert status.selected_device == "127.0.0.1:5555" + + +def test_mobile_status_container_missing_adb_binary() -> None: + with ( + patch("octop.infra.mobile.setup.find_adb", return_value=None), + patch("octop.infra.mobile.setup._container_running", return_value=True), + patch("octop.infra.mobile.setup.list_devices", return_value=[]), + ): + status = mobile_status(_cfg(enabled=True, backend="redroid"), locale="en") + assert status.setup_state == "needs_device" + assert "adb was not found" in status.reason diff --git a/tests/unit/mobile/test_mobile_tools.py b/tests/unit/mobile/test_mobile_tools.py new file mode 100644 index 00000000..a827dade --- /dev/null +++ b/tests/unit/mobile/test_mobile_tools.py @@ -0,0 +1,115 @@ +"""Unit tests for built-in mobile LangChain tools.""" + +from __future__ import annotations + +import json +from contextlib import contextmanager +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import pytest +from langgraph.config import var_child_runnable_config + +from octop.config import CapabilitiesConfig, MobileCapabilities, OctopConfig +from octop.infra.mobile.tools import build_mobile_tools + + +@contextmanager +def _configurable(**kwargs: object): + token = var_child_runnable_config.set({"configurable": kwargs}) + try: + yield + finally: + var_child_runnable_config.reset(token) + + +def _enabled_config() -> OctopConfig: + return OctopConfig( + capabilities=CapabilitiesConfig( + mobile=MobileCapabilities( + enabled=True, + backend="physical", + probed_at="2026-01-01T00:00:00Z", + ) + ) + ) + + +def _tool_by_name(tools: list, name: str): + for tool in tools: + if tool.name == name: + return tool + raise KeyError(name) + + +def test_build_mobile_tools_empty_when_disabled() -> None: + cfg = OctopConfig() + tools = build_mobile_tools(cfg, user_repo=MagicMock()) + assert tools == [] + + +def test_build_mobile_tools_registers_when_adb_present() -> None: + user_repo = MagicMock() + with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"): + tools = build_mobile_tools(_enabled_config(), user_repo=user_repo) + names = {t.name for t in tools} + assert "mobile_screenshot" in names + assert "mobile_tap" in names + assert "mobile_handoff_to_user" in names + + +@pytest.mark.asyncio +async def test_mobile_tap_requires_permission() -> None: + user_repo = MagicMock() + user_repo.get.return_value = SimpleNamespace(is_admin=False, permissions=["browser"]) + with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"): + tools = build_mobile_tools(_enabled_config(), user_repo=user_repo) + tap_tool = _tool_by_name(tools, "mobile_tap") + with ( + _configurable(user="1", user_is_admin=False, locale="en"), + patch("octop.infra.mobile.tools.mobile_status") as status, + ): + status.return_value = MagicMock(setup_state="ready", ok=True) + out = await tap_tool.ainvoke({"x": 10, "y": 20}) + data = json.loads(out) + assert "error" in data + assert "permission" in data["error"] + + +@pytest.mark.asyncio +async def test_mobile_tap_success() -> None: + from octop.infra.mobile.agent_control import set_mobile_agent_control + + set_mobile_agent_control(enabled=True, device="emulator-5554") + user_repo = MagicMock() + user_repo.get.return_value = SimpleNamespace(is_admin=False, permissions=["mobile"]) + with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"): + tools = build_mobile_tools(_enabled_config(), user_repo=user_repo) + tap_tool = _tool_by_name(tools, "mobile_tap") + try: + with ( + _configurable(user="1", user_is_admin=False, locale="en", agent_id="agent1"), + patch("octop.infra.mobile.tools.mobile_status") as status, + patch("octop.infra.mobile.tools.list_devices", return_value=["emulator-5554"]), + patch("octop.infra.mobile.tools.tap", return_value=True) as tap_fn, + ): + status.return_value = MagicMock(setup_state="ready", ok=True) + out = await tap_tool.ainvoke({"x": 100, "y": 200}) + data = json.loads(out) + assert data["ok"] is True + tap_fn.assert_called_once() + finally: + set_mobile_agent_control(enabled=False, device=None) + + +@pytest.mark.asyncio +async def test_mobile_handoff_admin_bypass() -> None: + user_repo = MagicMock() + with patch("octop.infra.mobile.tools.find_adb", return_value="/adb"): + tools = build_mobile_tools(_enabled_config(), user_repo=user_repo) + handoff = _tool_by_name(tools, "mobile_handoff_to_user") + with _configurable(user="1", user_is_admin=True, locale="en"): + out = await handoff.ainvoke({"reason": "login captcha"}) + data = json.loads(out) + assert data["handoff"] is True + assert "login captcha" in data["message"] diff --git a/tests/unit/mobile/test_probe.py b/tests/unit/mobile/test_probe.py new file mode 100644 index 00000000..d0223d69 --- /dev/null +++ b/tests/unit/mobile/test_probe.py @@ -0,0 +1,37 @@ +"""tests/unit/mobile/test_probe.py""" + +from __future__ import annotations + +from unittest.mock import patch + +import pytest + +from octop.infra.mobile.probe import probe_host_capability + + +@pytest.mark.parametrize( + ("system", "binder", "kvm", "enabled", "backend"), + [ + ("Darwin", False, False, True, "physical"), + ("Windows", False, False, True, "physical"), + ("Linux", True, False, True, "redroid"), + ("Linux", False, True, True, "emulator"), + ("Linux", False, False, False, "none"), + ], +) +def test_probe_host_matrix( + system: str, + binder: bool, + kvm: bool, + enabled: bool, + backend: str, +) -> None: + with ( + patch("octop.infra.mobile.probe.platform.system", return_value=system), + patch("octop.infra.mobile.probe.linux_binder_available", return_value=binder), + patch("octop.infra.mobile.probe.kvm_available", return_value=kvm), + ): + result = probe_host_capability(probed_at="2026-01-01T00:00:00Z") + assert result.enabled is enabled + assert result.backend == backend + assert result.probed_at == "2026-01-01T00:00:00Z" diff --git a/tests/unit/test_config.py b/tests/unit/test_config.py index cb70b158..2e7c5b79 100644 --- a/tests/unit/test_config.py +++ b/tests/unit/test_config.py @@ -318,3 +318,24 @@ def test_new_timezone_env_wins_over_legacy_env(tmp_path: Path, monkeypatch: pyte monkeypatch.setenv("OCTOP_CRON_TIMEZONE", "Asia/Tokyo") cfg = load_config(tmp_path / "config.json") assert cfg.default_timezone == "UTC" + + +def test_loads_mobile_capabilities(tmp_path: Path) -> None: + cfg_path = tmp_path / "config.json" + cfg_path.write_text( + json.dumps( + { + "capabilities": { + "mobile": { + "enabled": True, + "backend": "physical", + "probed_at": "2026-01-01T00:00:00Z", + "reason": "", + } + } + } + ) + ) + cfg = load_config(cfg_path) + assert cfg.capabilities.mobile.enabled is True + assert cfg.capabilities.mobile.backend == "physical" diff --git a/tests/unit/test_user_manager.py b/tests/unit/test_user_manager.py index 7b94cbf8..bf4f2b44 100644 --- a/tests/unit/test_user_manager.py +++ b/tests/unit/test_user_manager.py @@ -58,6 +58,59 @@ async def test_authenticate_success(manager: UserManager): assert user.username == "a" +async def test_authenticate_by_email(manager: UserManager): + await manager.create( + username="alice", + password="TestPass12", + role=Role.USER, + email="Alice@Example.com", + ) + user = await manager.authenticate("alice@example.com", "TestPass12") + assert user is not None + assert user.username == "alice" + + +async def test_authenticate_prefers_username_over_email(manager: UserManager): + await manager.create( + username="bob@x.com", + password="TestPass12", + role=Role.USER, + email="other@x.com", + ) + await manager.create( + username="carol", + password="TestPass34", + role=Role.USER, + email="bob@x.com", + ) + user = await manager.authenticate("bob@x.com", "TestPass12") + assert user is not None + assert user.username == "bob@x.com" + + +async def test_create_duplicate_email_rejected(manager: UserManager): + await manager.create( + username="a", password="TestPass12", role=Role.USER, email="dup@example.com" + ) + with pytest.raises(OctopError) as ei: + await manager.create( + username="b", password="TestPass34", role=Role.USER, email="DUP@example.com" + ) + assert ei.value.code is ErrorCode.EMAIL_TAKEN + + +async def test_set_email_and_clear(manager: UserManager): + user = await manager.create(username="a", password="TestPass12", role=Role.USER) + await manager.set_email("a", "a@example.com") + row = manager.get_row(user.id) + assert row is not None + assert row.email == "a@example.com" + await manager.set_email("a", None) + row = manager.get_row(user.id) + assert row is not None + assert row.email is None + + async def test_authenticate_wrong_password(manager: UserManager): await manager.create(username="a", password="TestPass12", role=Role.USER) assert await manager.authenticate("a", "bad") is None diff --git a/tests/unit/users/test_email.py b/tests/unit/users/test_email.py new file mode 100644 index 00000000..d897d6de --- /dev/null +++ b/tests/unit/users/test_email.py @@ -0,0 +1,31 @@ +"""Tests for email normalize / validate helpers.""" + +from __future__ import annotations + +import pytest + +from octop.infra.errors import ErrorCode, OctopError +from octop.infra.users.email import normalize_email, parse_optional_email, validate_email_format + + +def test_normalize_email() -> None: + assert normalize_email(None) is None + assert normalize_email("") is None + assert normalize_email(" ") is None + assert normalize_email("Alice@Example.COM") == "alice@example.com" + + +def test_validate_email_format() -> None: + validate_email_format("a@b.co") + with pytest.raises(OctopError) as exc: + validate_email_format("not-an-email") + assert exc.value.code is ErrorCode.EMAIL_INVALID + + +def test_parse_optional_email() -> None: + assert parse_optional_email(None) is None + assert parse_optional_email(" ") is None + assert parse_optional_email("Bob@Host.IO") == "bob@host.io" + with pytest.raises(OctopError) as exc: + parse_optional_email("bad") + assert exc.value.code is ErrorCode.EMAIL_INVALID diff --git a/tests/unit/users/test_invites.py b/tests/unit/users/test_invites.py index 16b6e949..5f581784 100644 --- a/tests/unit/users/test_invites.py +++ b/tests/unit/users/test_invites.py @@ -30,7 +30,7 @@ def test_generate_invite_code_shape() -> None: code = generate_invite_code() assert len(code) == 11 assert code.isalnum() - assert invite_path(code) == f"/?invite={code}" + assert invite_path(code) == f"/invite?code={code}" def test_invite_repo_redeem_once(db: SqlitePool) -> None: diff --git a/tests/unit/users/test_permissions.py b/tests/unit/users/test_permissions.py index 347cc5d5..d44c12f4 100644 --- a/tests/unit/users/test_permissions.py +++ b/tests/unit/users/test_permissions.py @@ -65,6 +65,7 @@ def test_categories_match_nav_groups() -> None: "terminal", "browser", "desktop", + "mobile", } assert PERMISSIONS["envs"].page == "advanced" assert PERMISSIONS["knowledge_settings"].page == "advanced" diff --git a/uv.lock b/uv.lock index 902f0d96..818a15f8 100644 --- a/uv.lock +++ b/uv.lock @@ -2465,7 +2465,7 @@ wheels = [ [[package]] name = "octop" -version = "0.9.24" +version = "0.9.26" source = { editable = "." } dependencies = [ { name = "acme" }, @@ -2559,7 +2559,7 @@ requires-dist = [ { name = "mcp", specifier = ">=1.9,<2" }, { name = "mss", marker = "extra == 'desktop'", specifier = ">=9.0" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.10" }, - { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.23" }, + { name = "orcakit-harness-agent", extras = ["all"], specifier = ">=0.9.24" }, { name = "pillow", specifier = ">=10.0" }, { name = "playwright", specifier = ">=1.40" }, { name = "playwright", marker = "extra == 'browser'", specifier = ">=1.40" }, @@ -2731,7 +2731,7 @@ wheels = [ [[package]] name = "orcakit-harness-agent" -version = "0.9.23" +version = "0.9.24" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deepagents" }, @@ -2749,9 +2749,9 @@ dependencies = [ { name = "mcp" }, { name = "pyyaml" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/2c/3c/482ad9fe0cf33e8937105dee13c1d406d105fa4bfe5a7aa36b145d9ace7f/orcakit_harness_agent-0.9.23.tar.gz", hash = "sha256:f8cfbc45def2c032b8ec5360a002f238d92cf6f9f2d6d6512b83166c83a84823", size = 1182937, upload-time = "2026-08-21T07:58:36.019Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/3a/05421451c77788cb4b560bb15489fab67bbe4b51eb4430e765812e755d14/orcakit_harness_agent-0.9.24.tar.gz", hash = "sha256:4638f399678b2989656bf6dfaf38887b802b69f0c9179583725c939b4aafe367", size = 1182523, upload-time = "2026-08-22T12:56:24.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2b/41/fd6e358d007c4f47a16b7895f6158fa6329d569a8d00dbc9c5608c1b917e/orcakit_harness_agent-0.9.23-py3-none-any.whl", hash = "sha256:b774c76d9dfd38a9379db4b7a940f5deae0f55f109295ef15bb000a79591ceda", size = 1406361, upload-time = "2026-08-21T07:58:32.796Z" }, + { url = "https://files.pythonhosted.org/packages/0b/0d/d23d52b4f5175d179f2713647c6df306eefa78de095a5f14a7a3e5ae0a82/orcakit_harness_agent-0.9.24-py3-none-any.whl", hash = "sha256:10d6ad02116dfcd2b2232b3b92bb7a9f5678fe0f98fa0265597cf3e26e1dbcbc", size = 1406596, upload-time = "2026-08-22T12:56:20.497Z" }, ] [package.optional-dependencies]