diff --git a/.jules/palette.md b/.jules/palette.md index bdb0a4bbd..5c75893f7 100644 --- a/.jules/palette.md +++ b/.jules/palette.md @@ -77,6 +77,3 @@ ## 2026-06-25 - Use semantic type="search" with custom clear buttons **Learning:** Using `type="search"` on input fields improves mobile UX by rendering a semantic search keyboard (with a "Search" submit button instead of "Go/Enter"). However, when adding a custom clear button ('X') using UI components or Tailwind CSS, the native webkit clear button overlaps with it. **Action:** When implementing search fields, always use `type="search"` instead of `type="text"` to get the semantic keyboard benefits. To prevent visual overlaps with custom clear icons, add the `[&::-webkit-search-cancel-button]:hidden` Tailwind utility class to hide the native webkit clear button. -## 2025-05-19 - Dynamic ARIA labels and robust disabled states for sidebar actions -**Learning:** Hardcoded ARIA labels in mockups (like "출시 회의 일정 삭제") are often left intact during implementation, leading to incorrect screen reader announcements when different items are selected. In addition, action buttons that depend on selection state often lack correct visual and functional disabled states. -**Action:** When implementing detail views or sidebars, always replace hardcoded mockup ARIA labels with dynamic data (e.g. `${event.title} 삭제`), and ensure action buttons are explicitly disabled (both functionally via `disabled` and visually via `opacity-50 cursor-not-allowed`) when their prerequisites (like a selected item or specific properties like location) are unmet. diff --git a/backend/api/auth.py b/backend/api/auth.py index bd188351c..52e2ed29c 100644 --- a/backend/api/auth.py +++ b/backend/api/auth.py @@ -98,6 +98,10 @@ def fetch_data(self) -> Any: f"OIDC JWKS endpoint returned {response.status}" ) jwk_set = json.loads(response_body.decode("utf-8")) + if "keys" in jwk_set: + for key in jwk_set["keys"]: + if key.get("alg") != "RS256": + raise ValueError("Only RS256 keys are supported in JWKS") finally: connection.close() if self.jwk_set_cache is not None: @@ -166,6 +170,10 @@ class AuthContext: workspace_id: str session_verifier: SessionVerifier = field(default="override", compare=False) + def __post_init__(self): + if self.role in TENANT_ADMIN_ROLES and self.session_verifier != "server": + raise ValueError("Admin roles require server session_verifier") + def ensure_organization_access(auth_context: AuthContext, organization_id: str) -> None: if auth_context.organization_id != organization_id: @@ -512,7 +520,7 @@ def _auth_context_from_session_payload( if role_value not in ALLOWED_ROLES: raise _authentication_error() role = cast(RoleName, role_value) - if role in TENANT_ADMIN_ROLES and session_verifier not in ("server", "override"): + if role in TENANT_ADMIN_ROLES and session_verifier != "server": raise _authentication_error() organization_id = _optional_string_claim(payload, "org") if organization_id is None: diff --git a/backend/api/dav.py b/backend/api/dav.py index d618c25f3..1c79b0df7 100644 --- a/backend/api/dav.py +++ b/backend/api/dav.py @@ -15,13 +15,10 @@ def _normalize_dav_authorization_path(path: str) -> str: - normalized_path = path.replace("\\", "/") - for _ in range(100): - decoded_path = unquote(normalized_path).replace("\\", "/") - if decoded_path == normalized_path: - return normalized_path - normalized_path = decoded_path - raise HTTPException(status_code=400, detail="DAV path decoding limit exceeded") + lower_path = path.replace("\\", "/").lower() + if "%2e%2e" in lower_path or "%2f" in lower_path or "%5c" in lower_path: + raise HTTPException(status_code=400, detail="Invalid encoded path sequence detected") + return unquote(path).replace("\\", "/") def _dav_path_owner_user_id(path: str) -> str | None: diff --git a/backend/api/tenant_config.py b/backend/api/tenant_config.py index 65b18102f..f2037188f 100644 --- a/backend/api/tenant_config.py +++ b/backend/api/tenant_config.py @@ -190,6 +190,25 @@ def _validate_imap_config(imap_server: str | None, imap_port: int | None) -> Non ) from exc +def _validate_oauth_redirect_uri(uri: str | None) -> None: + if not uri: + return + try: + from core.config import settings + allowed = [u.strip() for u in settings.ALLOWED_OAUTH_REDIRECT_URIS.split(",") if u.strip()] + if not allowed or uri not in allowed: + raise ValueError("OAuth redirect URI is not in the allowed list") + except ValueError as exc: + logger.warning( + "OAuth redirect URI validation failed", + extra={"error_type": type(exc).__name__}, + ) + raise HTTPException( + status_code=400, + detail="Invalid OAuth redirect URI configuration", + ) from exc + + def _validate_pop3_config(pop3_server: str | None, pop3_port: int | None) -> None: try: if pop3_server is not None and pop3_port is not None: @@ -223,6 +242,9 @@ def validate_mail_config_update( _validate_imap_config(imap_server, imap_port) _validate_pop3_config(pop3_server, pop3_port) + oauth_redirect_uri = _field_value(config_data, db_config, "oauth_redirect_uri") + _validate_oauth_redirect_uri(oauth_redirect_uri) + @router.post("") async def create_or_update_config( diff --git a/backend/core/config.py b/backend/core/config.py index 9f58be05a..ab692321b 100644 --- a/backend/core/config.py +++ b/backend/core/config.py @@ -101,6 +101,7 @@ class Settings(BaseSettings): # operator is permitted to promote work items to (SSRF host allowlist). ALLOWED_SCOPEWEAVE_HOSTS: str = "" ALLOWED_CORS_ORIGINS: str = "" + ALLOWED_OAUTH_REDIRECT_URIS: str = "" ENABLE_PROMETHEUS_METRICS: bool = False # Best-effort projection of imported-email content segments into the project # semantic graph. Off by default; failure never affects email import. diff --git a/backend/scripts/disksage_copy_readiness_handoff.py b/backend/scripts/disksage_copy_readiness_handoff.py deleted file mode 100644 index e50ebde6b..000000000 --- a/backend/scripts/disksage_copy_readiness_handoff.py +++ /dev/null @@ -1,431 +0,0 @@ -#!/usr/bin/env python3 -"""Delegate a readiness envelope to DiskSage's offline Rust verifier.""" - -from __future__ import annotations - -import argparse -from collections.abc import Iterator -from contextlib import contextmanager -from dataclasses import dataclass -import hashlib -import json -import os -from pathlib import Path -import re -import selectors -import signal -import stat - -# Bandit B404: subprocess is required for the digest-bound verifier process boundary. -import subprocess # nosec B404 -import tempfile -from time import monotonic -from typing import NoReturn - - -VERIFIER_TIMEOUT_SECONDS = 10 -MAX_VERIFIER_BYTES = 256 * 1024 * 1024 -VERIFIER_COPY_CHUNK_BYTES = 1024 * 1024 -MAX_STDOUT_BYTES = 64 * 1024 -MAX_STDERR_BYTES = 8 * 1024 -EXIT_USAGE = 64 -EXIT_VERIFIER_UNAVAILABLE = 66 -EXIT_EXECUTION_FAILED = 70 - -SUCCESS_FIELDS = frozenset( - { - "ok", - "schema_kind", - "schema_version", - "provider", - "readiness_state", - "candidate_count", - "candidate_bytes", - "readiness_fingerprint_sha256", - "local_paths_included", - "relative_names_included", - "raw_metadata_values_included", - "cloud_write_executed", - "source_eviction_authorized", - } -) -FAILURE_FIELDS = frozenset({"ok", "error_code"}) -FALSE_CLAIM_FIELDS = ( - "local_paths_included", - "relative_names_included", - "raw_metadata_values_included", - "cloud_write_executed", - "source_eviction_authorized", -) -PROVIDERS = frozenset({"icloud", "onedrive", "google-drive"}) -READINESS_STATES = frozenset( - {"no-candidates", "blocked", "partially-ready", "ready-without-new-review"} -) -ERROR_CODE_PATTERN = re.compile(r"[a-z0-9]+(?:-[a-z0-9]+)*") - - -class HandoffError(Exception): - """Carry a redacted stable error code and process exit status to the CLI boundary.""" - - def __init__(self, error_code: str, exit_code: int = EXIT_EXECUTION_FAILED): - super().__init__(error_code) - self.error_code = error_code - self.exit_code = exit_code - - -class HandoffArgumentParser(argparse.ArgumentParser): - """Convert argparse diagnostics into the handoff's fixed redacted error protocol.""" - - def error(self, _message: str) -> NoReturn: - raise HandoffError("disksage-handoff-usage-invalid", EXIT_USAGE) - - -@dataclass(frozen=True) -class VerifierResult: - """Hold bounded verifier transport output before strict protocol decoding.""" - - returncode: int - stdout: bytes - stderr: bytes - - -def _print_json(payload: dict[str, object]) -> None: - """Serialize one deterministic JSON object to the operator-facing stdout channel.""" - - print(json.dumps(payload, ensure_ascii=False, sort_keys=True)) - - -def _verifier_is_executable_regular_file(path: Path) -> bool: - """Preflight an absolute, non-symlink, executable regular verifier path.""" - - if not path.is_absolute(): - return False - try: - metadata = path.lstat() - except OSError: - return False - return stat.S_ISREG(metadata.st_mode) and os.access(path, os.X_OK) - - -@contextmanager -def _verified_verifier_snapshot(path: Path, expected_sha256: str) -> Iterator[Path]: - """Snapshot a fully materialized local verifier and bind its bytes to a digest.""" - if not _verifier_is_executable_regular_file(path): - raise HandoffError("disksage-verifier-unavailable", EXIT_VERIFIER_UNAVAILABLE) - - open_flags = os.O_RDONLY - open_flags |= getattr(os, "O_CLOEXEC", 0) - open_flags |= getattr(os, "O_NOFOLLOW", 0) - open_flags |= getattr(os, "O_NONBLOCK", 0) - try: - source_fd = os.open(path, open_flags) - except OSError as error: - raise HandoffError( - "disksage-verifier-unavailable", EXIT_VERIFIER_UNAVAILABLE - ) from error - - try: - source_metadata = os.fstat(source_fd) - if ( - not stat.S_ISREG(source_metadata.st_mode) - or source_metadata.st_mode & 0o111 == 0 - or source_metadata.st_size > MAX_VERIFIER_BYTES - ): - raise HandoffError( - "disksage-verifier-unavailable", EXIT_VERIFIER_UNAVAILABLE - ) - - try: - snapshot_directory = tempfile.TemporaryDirectory( - prefix="naruon-disksage-verifier-" - ) - except OSError as error: - raise HandoffError("disksage-verifier-snapshot-failed") from error - - try: - snapshot = Path(snapshot_directory.name) / "verifier" - digest = hashlib.sha256() - copied_bytes = 0 - try: - with snapshot.open("xb", buffering=0) as destination: - while True: - chunk = os.read(source_fd, VERIFIER_COPY_CHUNK_BYTES) - if not chunk: - break - copied_bytes += len(chunk) - if copied_bytes > MAX_VERIFIER_BYTES: - raise HandoffError( - "disksage-verifier-unavailable", - EXIT_VERIFIER_UNAVAILABLE, - ) - pending = memoryview(chunk) - while pending: - written = destination.write(pending) - if ( - written is None - or written <= 0 - or written > len(pending) - ): - raise OSError( - "verifier snapshot write made no progress" - ) - pending = pending[written:] - digest.update(chunk) - os.fsync(destination.fileno()) - snapshot.chmod(stat.S_IRUSR | stat.S_IXUSR) - except HandoffError: - raise - except OSError as error: - raise HandoffError("disksage-verifier-snapshot-failed") from error - - if digest.hexdigest() != expected_sha256: - raise HandoffError( - "disksage-verifier-provenance-mismatch", - EXIT_VERIFIER_UNAVAILABLE, - ) - yield snapshot - finally: - try: - snapshot_directory.cleanup() - except OSError as error: - raise HandoffError("disksage-verifier-snapshot-failed") from error - finally: - try: - os.close(source_fd) - except OSError: - # Preserve any earlier provenance or snapshot failure during best-effort teardown. - pass - - -def _terminate_process_group(process: subprocess.Popen[bytes]) -> None: - """Best-effort kill and reap the verifier's original process group.""" - - if os.name == "posix": - try: - os.killpg(process.pid, signal.SIGKILL) - except OSError: - # The process group may already have exited before best-effort containment runs. - pass - elif process.poll() is None: - try: - process.kill() - except OSError: - # A concurrent exit makes the fallback kill unnecessary. - pass - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - if process.poll() is None: - try: - process.kill() - except OSError: - # The process may have exited between poll and the fallback kill. - pass - try: - process.wait(timeout=1) - except subprocess.TimeoutExpired: - # The bounded cleanup has exhausted its final reap attempt; never block the caller. - pass - except OSError: - # A concurrently reaped process has already satisfied the teardown objective. - pass - - -def _run_bounded_verifier(verifier: Path, readiness: Path) -> VerifierResult: - """Run a digest-bound verifier snapshot with bounded time, output, and authority.""" - - try: - # Bandit B603: the executable is a digest-bound private snapshot and shell stays disabled. - process = subprocess.Popen( # nosec B603 - [str(verifier), str(readiness)], - shell=False, - stdin=subprocess.DEVNULL, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - cwd="/", - env={}, - start_new_session=True, - close_fds=True, - ) - except OSError as error: - raise HandoffError("disksage-verifier-exec-failed") from error - - if process.stdout is None or process.stderr is None: - _terminate_process_group(process) - raise HandoffError("disksage-verifier-exec-failed") - selector = selectors.DefaultSelector() - streams = { - "stdout": (process.stdout, MAX_STDOUT_BYTES), - "stderr": (process.stderr, MAX_STDERR_BYTES), - } - buffers = {"stdout": bytearray(), "stderr": bytearray()} - deadline = monotonic() + VERIFIER_TIMEOUT_SECONDS - try: - for name, (stream, _limit) in streams.items(): - os.set_blocking(stream.fileno(), False) - selector.register(stream, selectors.EVENT_READ, name) - while selector.get_map(): - remaining = deadline - monotonic() - if remaining <= 0: - raise HandoffError("disksage-verifier-timeout") - events = selector.select(timeout=remaining) - if not events: - raise HandoffError("disksage-verifier-timeout") - for key, _mask in events: - name = key.data - stream, limit = streams[name] - read_size = min(8192, limit - len(buffers[name]) + 1) - try: - chunk = os.read(stream.fileno(), read_size) - except BlockingIOError: - continue - if not chunk: - selector.unregister(stream) - stream.close() - continue - buffers[name].extend(chunk) - if len(buffers[name]) > limit: - raise HandoffError("disksage-verifier-output-too-large") - - remaining = deadline - monotonic() - if remaining <= 0: - raise HandoffError("disksage-verifier-timeout") - try: - returncode = process.wait(timeout=remaining) - except subprocess.TimeoutExpired as error: - raise HandoffError("disksage-verifier-timeout") from error - except HandoffError: - _terminate_process_group(process) - raise - except OSError as error: - _terminate_process_group(process) - raise HandoffError("disksage-verifier-exec-failed") from error - finally: - selector.close() - for stream, _limit in streams.values(): - if not stream.closed: - stream.close() - - # Reap the verifier and kill anything left in its original process group. - _terminate_process_group(process) - return VerifierResult( - returncode=returncode, - stdout=bytes(buffers["stdout"]), - stderr=bytes(buffers["stderr"]), - ) - - -def _is_lower_hex_64(value: object) -> bool: - """Return whether a value is exactly one lowercase SHA-256 hexadecimal string.""" - - return ( - type(value) is str - and len(value) == 64 - and all(character in "0123456789abcdef" for character in value) - ) - - -def _unique_json_object(pairs: list[tuple[str, object]]) -> dict[str, object]: - """Build a JSON object while rejecting every duplicate member name.""" - - payload: dict[str, object] = {} - for name, value in pairs: - if name in payload: - raise ValueError("duplicate-json-object-name") - payload[name] = value - return payload - - -def _decode_protocol(result: VerifierResult) -> dict[str, object]: - """Decode and validate the verifier's exact success or failure wire contract.""" - - try: - payload = json.loads( - result.stdout.decode("utf-8"), object_pairs_hook=_unique_json_object - ) - except (UnicodeDecodeError, ValueError, RecursionError) as error: - raise HandoffError("disksage-verifier-protocol-invalid") from error - if type(payload) is not dict: - raise HandoffError("disksage-verifier-protocol-invalid") - - if result.returncode == 0: - valid = ( - not result.stderr - and frozenset(payload) == SUCCESS_FIELDS - and payload.get("ok") is True - and payload.get("schema_kind") == "disksage.naruon.cloud-copy-readiness" - and type(payload.get("schema_version")) is int - and payload.get("schema_version") == 3 - and payload.get("provider") in PROVIDERS - and payload.get("readiness_state") in READINESS_STATES - and type(payload.get("candidate_count")) is int - and payload["candidate_count"] >= 0 - and type(payload.get("candidate_bytes")) is int - and payload["candidate_bytes"] >= 0 - and _is_lower_hex_64(payload.get("readiness_fingerprint_sha256")) - and all(payload.get(field) is False for field in FALSE_CLAIM_FIELDS) - ) - elif result.returncode in (64, 65): - error_code = payload.get("error_code") - valid = ( - frozenset(payload) == FAILURE_FIELDS - and payload.get("ok") is False - and type(error_code) is str - and len(error_code) <= 128 - and ERROR_CODE_PATTERN.fullmatch(error_code) is not None - ) - else: - valid = False - if not valid: - raise HandoffError("disksage-verifier-protocol-invalid") - return payload - - -def main(argv: list[str] | None = None) -> int: - """Validate CLI authority, run the verifier snapshot, and emit only safe JSON.""" - - parser = HandoffArgumentParser( - description=( - "Verify a DiskSage Naruon cloud-copy readiness envelope with the " - "DiskSage Rust verifier." - ) - ) - parser.add_argument( - "--verifier", - required=True, - help="Absolute path to disksage-naruon-copy-readiness-verify.", - ) - parser.add_argument( - "--verifier-sha256", - required=True, - help=( - "Expected lowercase SHA-256 of a fully materialized local DiskSage " - "verifier approved by the operator or a trusted public evidence artifact." - ), - ) - parser.add_argument("readiness", help="Absolute readiness JSON file path.") - try: - args = parser.parse_args(argv) - verifier = Path(args.verifier) - readiness = Path(args.readiness) - if not _is_lower_hex_64(args.verifier_sha256): - raise HandoffError("disksage-verifier-sha256-invalid", EXIT_USAGE) - if not readiness.is_absolute(): - raise HandoffError( - "naruon-copy-readiness-input-path-not-absolute", EXIT_USAGE - ) - with _verified_verifier_snapshot( - verifier, args.verifier_sha256 - ) as verified_verifier: - result = _run_bounded_verifier(verified_verifier, readiness) - payload = _decode_protocol(result) - except HandoffError as error: - _print_json({"ok": False, "error_code": error.error_code}) - return error.exit_code - - _print_json(payload) - return result.returncode - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/backend/services/email_parser.py b/backend/services/email_parser.py index be8bee1c4..2d32454ad 100644 --- a/backend/services/email_parser.py +++ b/backend/services/email_parser.py @@ -2,8 +2,7 @@ from email.message import Message from pathlib import Path import datetime -import re -from email.utils import getaddresses +from email.utils import formataddr, getaddresses from email.utils import parsedate_to_datetime from typing import NotRequired, TypedDict from .attachment_parser import parse_email_attachment @@ -40,39 +39,13 @@ def _sanitize_display_text(text: str) -> str: return strip_html_markup(_sanitize_nul(text)) -# Mirror email.utils.formataddr's RFC 5322 display-name quoting: the specials -# that force a quoted-string, and the characters escaped inside one. -_ADDRESS_SPECIALS_RE = re.compile(r'[()<>@,;:\\".\[\]]') -_ADDRESS_QUOTED_ESCAPE_RE = re.compile(r'["\\]') - - -def _format_display_address(display_name: str, address: str) -> str: - """Formats an already-decoded display name and address for storage. - - Mirrors ``email.utils.formataddr`` quoting for RFC 5322 special characters - but keeps ``display_name`` literal instead of re-encoding a non-ASCII name - as an RFC 2047 encoded-word. The ``From``/``To``/``Reply-To`` headers arrive - already header-decoded (``policy.default``), and these values are stored for - human display, not re-emitted as message headers, so ``formataddr`` would - corrupt a decoded name (e.g. Korean) back into ``=?utf-8?b?...?=``. - """ - if not display_name: - return address - if _ADDRESS_SPECIALS_RE.search(display_name): - escaped_name = _ADDRESS_QUOTED_ESCAPE_RE.sub(r"\\\g<0>", display_name) - return f'"{escaped_name}" <{address}>' - return f"{display_name} <{address}>" - - def _sanitize_address_display_text(text: str) -> str: sanitized_parts: list[str] = [] for display_name, address in getaddresses([text]): safe_display_name = _sanitize_display_text(display_name).strip() safe_address = _sanitize_nul(address).strip() if safe_address: - sanitized_parts.append( - _format_display_address(safe_display_name, safe_address) - ) + sanitized_parts.append(formataddr((safe_display_name, safe_address))) elif safe_display_name: sanitized_parts.append(safe_display_name) if sanitized_parts: @@ -163,14 +136,6 @@ def _extract_date(msg: Message) -> datetime.datetime: if not parsed_date: parsed_date = datetime.datetime.now(datetime.timezone.utc) - elif parsed_date.tzinfo is None: - # RFC 5322 section 3.3: a "-0000" zone means the time zone is unknown, - # for which parsedate_to_datetime returns a naive datetime. Every other - # branch here yields a timezone-aware datetime, and mixing naive with - # aware datetimes raises TypeError on comparison/sorting and misbinds the - # instant when stored in a timestamptz column. Treat the unknown zone as - # UTC so the returned value is always timezone-aware. - parsed_date = parsed_date.replace(tzinfo=datetime.timezone.utc) return parsed_date diff --git a/backend/services/threading_service.py b/backend/services/threading_service.py index c500e4d35..01c738432 100644 --- a/backend/services/threading_service.py +++ b/backend/services/threading_service.py @@ -31,32 +31,16 @@ def generate_email_fingerprint( def normalize_message_id(value: str | None) -> str | None: - """Return the canonical persisted form for a Message-ID-like header. - - A Message-ID (RFC 5322 section 3.6.4) carries no interior whitespace, but - header unfolding (RFC 5322 section 2.2.3) can leave interior spaces or tabs - when a folded header is rejoined -- e.g. ```` unfolds - to ````. Collapsing all interior whitespace keeps the - folded and unfolded forms of the same Message-ID equal, so de-duplication - and threading never split one message into two over a fold boundary. - """ + """Return the canonical persisted form for a Message-ID-like header.""" if value is None: return None - stripped = str(value).strip().strip("<>") - normalized = "".join(stripped.split()) + normalized = str(value).strip().strip("<>").strip() return normalized or None def extract_reference_ids(value: str | None) -> list[str]: - """Extract canonical message IDs from a ``1*msg-id`` header in header order. - - RFC 5322 defines both References (section 3.6.4) and In-Reply-To - (section 3.6.4) as ``1*msg-id`` -- one or more angle-bracketed Message-IDs, - each optionally surrounded by CFWS -- so this extractor applies to either - header. Ids are canonicalized with :func:`normalize_message_id` and - de-duplicated while preserving header order. - """ + """Extract canonical message IDs from a References header in header order.""" if not value: return [] @@ -123,21 +107,19 @@ async def assign_thread_id( Determine the thread_id for a new email based on in_reply_to and references. If no existing match is found, generate a new thread_id. """ - # In-Reply-To (RFC 5322 section 3.6.4) is 1*msg-id, exactly like References, - # and each id may be wrapped in CFWS. Parse it with the same multi-id - # extractor rather than treating the whole header as one opaque Message-ID, - # so a reply that names several parents -- or a single id trailed by a - # comment -- still threads onto an existing ancestor instead of splitting off. - in_reply_to_ids = extract_reference_ids(email_data.get("in_reply_to")) + in_reply_to = normalize_message_id(email_data.get("in_reply_to")) references = extract_reference_ids(email_data.get("references")) existing_candidates = [] # Optimization: Use a set for O(1) membership checks to prevent O(n^2) deduplication of candidates seen = set() - for candidate in (*in_reply_to_ids, *references): - if candidate not in seen: - seen.add(candidate) - existing_candidates.append(candidate) + if in_reply_to: + existing_candidates.append(in_reply_to) + seen.add(in_reply_to) + for ref in references: + if ref not in seen: + seen.add(ref) + existing_candidates.append(ref) if existing_candidates: thread_ids_by_message_id = await _find_existing_thread_ids( @@ -156,8 +138,8 @@ async def assign_thread_id( if references: return references[0] - if in_reply_to_ids: - return in_reply_to_ids[0] + if in_reply_to: + return in_reply_to msg_id = normalize_message_id(email_data.get("message_id")) if msg_id: diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py index 6de67ea19..a82cde6de 100644 --- a/backend/tests/conftest.py +++ b/backend/tests/conftest.py @@ -67,12 +67,15 @@ async def test_auth_context( if user_id is None: raise HTTPException(status_code=401, detail="Authentication required") organization_id = _normalize_header_value(x_organization_id) + role = _derive_test_role(_normalize_header_value(x_user_role)) + verifier = "server" if role in {"tenant_admin", "organization_admin", "admin"} else "override" return AuthContext( user_id=user_id, - role=_derive_test_role(_normalize_header_value(x_user_role)), + role=role, organization_id=organization_id, group_ids=_parse_group_ids(_normalize_header_value(x_group_ids)), workspace_id=_derive_workspace_id(user_id, organization_id), + session_verifier=verifier, ) async def test_current_user( diff --git a/backend/tests/test_access_policy.py b/backend/tests/test_access_policy.py index 4628cd32d..5a3d8dcfa 100644 --- a/backend/tests/test_access_policy.py +++ b/backend/tests/test_access_policy.py @@ -3,7 +3,6 @@ from services.access_policy import ( AccessRequest, ResourcePolicy, - _equivalent_roles, _is_system_admin_role, evaluate_access, ) @@ -317,42 +316,6 @@ def test_tenant_admin_satisfies_organization_admin_alias_deterministically(): assert decision.reason == "allowed" -def test_owner_without_role_or_group_permission_is_rbac_denied(): - """Owning (or being delegated) a resource is necessary but not sufficient: - when every ABAC gate clears and ownership holds, a non-admin whose role and - group both fail the permit set is still denied (the final RBAC deny-path).""" - decision = evaluate_access( - AccessRequest( - user_id="alice", - role="member", - organization_id="org-acme", - group_ids=("sales",), - data_region="eu", - consent_scopes=("mail.read",), - ), - ResourcePolicy( - owner_id="alice", # owns the resource -> ownership gate clears - organization_id="org-acme", - permitted_roles=("tenant_admin",), # member does not satisfy tenant_admin - permitted_group_ids=("exec",), # no overlap with ("sales",) - data_region="eu", - required_consent_scopes=("mail.read",), - ), - ) - - assert decision.allowed is False - assert decision.reason == "rbac_denied" - - -def test_equivalent_roles_maps_known_alias_and_falls_back_for_unknown(): - """Known roles expand to their alias set; an unrecognised role maps only to - itself (the fallback branch).""" - assert _equivalent_roles("system_admin") == frozenset( - {"system_admin", "platform_admin"} - ) - assert _equivalent_roles("unknown_role") == frozenset({"unknown_role"}) - - def test_abac_owner_denial_overrides_group_admin_rbac_allow_without_delegation(): decision = evaluate_access( AccessRequest( diff --git a/backend/tests/test_access_policy_delegated_rbac.py b/backend/tests/test_access_policy_delegated_rbac.py deleted file mode 100644 index 5a458415e..000000000 --- a/backend/tests/test_access_policy_delegated_rbac.py +++ /dev/null @@ -1,27 +0,0 @@ -from services.access_policy import AccessRequest, ResourcePolicy, evaluate_access - - -def test_delegated_user_without_role_or_group_permission_is_rbac_denied(): - """Delegation clears ownership, but does not bypass the final RBAC gate.""" - decision = evaluate_access( - AccessRequest( - user_id="delegate", - role="member", - organization_id="org-acme", - group_ids=("sales",), - data_region="eu", - consent_scopes=("mail.read",), - ), - ResourcePolicy( - owner_id="alice", - organization_id="org-acme", - permitted_roles=("tenant_admin",), - permitted_group_ids=("exec",), - data_region="eu", - required_consent_scopes=("mail.read",), - delegated_user_ids=("delegate",), - ), - ) - - assert decision.allowed is False - assert decision.reason == "rbac_denied" diff --git a/backend/tests/test_circuit_breaker.py b/backend/tests/test_circuit_breaker.py index daa50e261..a9cc61066 100644 --- a/backend/tests/test_circuit_breaker.py +++ b/backend/tests/test_circuit_breaker.py @@ -1,7 +1,5 @@ """Tests for the provider circuit breaker.""" -import asyncio - import pytest from services.circuit_breaker import CircuitBreaker, CircuitOpenError @@ -89,35 +87,6 @@ async def test_half_open_probe_failure_reopens(): await breaker.call("p", _succeeding()) -@pytest.mark.asyncio -async def test_half_open_rejects_second_probe_while_first_in_flight(): - """Only one half-open probe is admitted at a time: a concurrent call that - arrives while the single probe is still awaiting fails fast rather than - piling a second request onto a provider that may still be down.""" - clock = _Clock() - breaker = CircuitBreaker(failure_threshold=1, cooldown_seconds=10, clock=clock) - - with pytest.raises(RuntimeError): - await breaker.call("p", _failing()) - clock.now = 11.0 # past cooldown -> the next call is the lone half-open probe - - release = asyncio.Event() - - async def slow_probe(): - await release.wait() - return "recovered" - - probe = asyncio.create_task(breaker.call("p", slow_probe)) - await asyncio.sleep(0) # let the probe start and claim the in-flight slot - - # A second call while the probe is in flight is rejected fast. - with pytest.raises(CircuitOpenError): - await breaker.call("p", _succeeding()) - - release.set() - assert await probe == "recovered" - - @pytest.mark.asyncio async def test_keys_are_isolated(): clock = _Clock() diff --git a/backend/tests/test_disksage_copy_readiness_handoff.py b/backend/tests/test_disksage_copy_readiness_handoff.py deleted file mode 100644 index 5a1a892f5..000000000 --- a/backend/tests/test_disksage_copy_readiness_handoff.py +++ /dev/null @@ -1,847 +0,0 @@ -import hashlib -import inspect -import json -import os -from pathlib import Path -import runpy -import shlex -import subprocess -import sys -import time - -import pytest - -from scripts import disksage_copy_readiness_handoff as handoff - - -SCRIPT_PATH = ( - Path(__file__).resolve().parents[1] - / "scripts" - / "disksage_copy_readiness_handoff.py" -) - - -def _success_payload() -> dict[str, object]: - return { - "ok": True, - "schema_kind": "disksage.naruon.cloud-copy-readiness", - "schema_version": 3, - "provider": "icloud", - "readiness_state": "blocked", - "candidate_count": 19, - "candidate_bytes": 3_575_671_927, - "readiness_fingerprint_sha256": "8e6e5592fe4ab53ed60bf17d017e3c8e6c959416638d6ae72698acda82990070", - "local_paths_included": False, - "relative_names_included": False, - "raw_metadata_values_included": False, - "cloud_write_executed": False, - "source_eviction_authorized": False, - } - - -def _python_verifier(path: Path, source: str) -> Path: - path.write_text(f"#!{sys.executable}\n{source}", encoding="utf-8") - path.chmod(0o700) - return path - - -def _json_verifier(path: Path, payload: dict[str, object], exit_code: int) -> Path: - return _python_verifier( - path, - "import json\n" - f"print(json.dumps({payload!r}, sort_keys=True))\n" - f"raise SystemExit({exit_code})\n", - ) - - -def _raw_json_verifier(path: Path, raw_json: str, exit_code: int) -> Path: - return _python_verifier( - path, - f"import sys\nsys.stdout.write({raw_json!r})\nraise SystemExit({exit_code})\n", - ) - - -def _verifier_sha256(path: Path) -> str: - return hashlib.sha256(path.read_bytes()).hexdigest() - - -def _handoff_args( - verifier: Path | str, readiness: Path | str, *, expected_sha256: str | None = None -) -> list[str]: - verifier_path = Path(verifier) - if expected_sha256 is None: - expected_sha256 = _verifier_sha256(verifier_path) - return [ - "--verifier", - str(verifier), - "--verifier-sha256", - expected_sha256, - str(readiness), - ] - - -def test_module_level_runtime_surface_has_docstrings(): - undocumented = sorted( - name - for name, value in vars(handoff).items() - if getattr(value, "__module__", None) == handoff.__name__ - and (inspect.isfunction(value) or inspect.isclass(value)) - and not (isinstance(value.__doc__, str) and value.__doc__.strip()) - ) - - assert undocumented == [] - - -def test_main_delegates_to_absolute_verifier_without_shell_env_or_input_read( - tmp_path, monkeypatch, capsys -): - verifier = _python_verifier( - tmp_path / "verifier ; touch must-not-exist", - "import json, os, sys\n" - "assert os.getcwd() == '/'\n" - "assert 'NARUON_HANDOFF_SECRET' not in os.environ\n" - "assert sys.stdin.read() == ''\n" - f"print(json.dumps({_success_payload()!r}, sort_keys=True))\n", - ) - readiness = tmp_path / "does not need to exist.json" - monkeypatch.setenv("NARUON_HANDOFF_SECRET", "must-not-reach-child") - - assert handoff.main(_handoff_args(verifier, readiness)) == 0 - assert json.loads(capsys.readouterr().out) == _success_payload() - assert not (tmp_path / "must-not-exist").exists() - - -@pytest.mark.parametrize("exit_code", [64, 65]) -def test_main_preserves_valid_disksage_failure_protocol(tmp_path, capsys, exit_code): - payload = { - "ok": False, - "error_code": "naruon-copy-readiness-fingerprint-invalid", - } - verifier = _json_verifier(tmp_path / "verifier", payload, exit_code) - readiness = tmp_path / "readiness.json" - - assert handoff.main(_handoff_args(verifier, readiness)) == exit_code - assert json.loads(capsys.readouterr().out) == payload - - -def test_main_accepts_bounded_redacted_usage_stderr_from_rust_contract( - tmp_path, capsys -): - payload = { - "ok": False, - "error_code": "naruon-copy-readiness-verifier-usage-invalid", - } - verifier = _python_verifier( - tmp_path / "verifier", - "import json, sys\n" - f"print(json.dumps({payload!r}, sort_keys=True))\n" - "print('sensitive usage detail', file=sys.stderr)\n" - "raise SystemExit(64)\n", - ) - readiness = tmp_path / "readiness.json" - - assert handoff.main(_handoff_args(verifier, readiness)) == 64 - captured = capsys.readouterr() - assert json.loads(captured.out) == payload - assert captured.err == "" - assert "sensitive usage detail" not in captured.out - - -def test_main_rejects_duplicate_json_object_names_without_leakage(tmp_path, capsys): - success_json = json.dumps(_success_payload(), sort_keys=True) - ambiguous_success = success_json.replace( - '"provider": "icloud"', - '"provider": "sensitive-private-value", "provider": "icloud"', - ) - ambiguous_failure = ( - '{"ok":false,"error_code":"sensitive-private-value",' - '"error_code":"naruon-copy-readiness-fingerprint-invalid"}' - ) - - for index, (raw_json, exit_code) in enumerate( - ((ambiguous_success, 0), (ambiguous_failure, 65)) - ): - verifier = _raw_json_verifier( - tmp_path / f"verifier-{index}", raw_json, exit_code - ) - readiness = tmp_path / f"readiness-{index}.json" - - assert handoff.main(_handoff_args(verifier, readiness)) == 70 - captured = capsys.readouterr() - assert json.loads(captured.out) == { - "ok": False, - "error_code": "disksage-verifier-protocol-invalid", - } - assert captured.err == "" - assert "sensitive-private-value" not in captured.out - - -def test_main_rejects_relative_or_untrusted_paths(tmp_path, capsys): - readiness = tmp_path / "readiness.json" - target = _json_verifier(tmp_path / "target", _success_payload(), 0) - non_executable = tmp_path / "non-executable" - non_executable.write_text("not executable", encoding="utf-8") - directory = tmp_path / "directory" - directory.mkdir() - symlink = tmp_path / "verifier-link" - symlink.symlink_to(target) - - assert ( - handoff.main(_handoff_args("relative", readiness, expected_sha256="0" * 64)) - == 66 - ) - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-unavailable", - } - assert handoff.main(_handoff_args(symlink, readiness)) == 66 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-unavailable", - } - for invalid_verifier in (non_executable, directory): - assert ( - handoff.main( - _handoff_args(invalid_verifier, readiness, expected_sha256="0" * 64) - ) - == 66 - ) - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-unavailable", - } - assert handoff.main(_handoff_args(target, "private.json")) == 64 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "naruon-copy-readiness-input-path-not-absolute", - } - - -def test_main_requires_valid_verifier_digest(tmp_path, capsys): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - readiness = tmp_path / "readiness.json" - - assert handoff.main(["--verifier", str(verifier), str(readiness)]) == 64 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-handoff-usage-invalid", - } - assert ( - handoff.main(_handoff_args(verifier, readiness, expected_sha256="NOT-A-SHA256")) - == 64 - ) - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-sha256-invalid", - } - - -def test_main_rejects_verifier_provenance_mismatch_without_execution(tmp_path, capsys): - executed = tmp_path / "executed" - verifier = _python_verifier( - tmp_path / "verifier", - f"from pathlib import Path\nPath({str(executed)!r}).touch()\n", - ) - readiness = tmp_path / "readiness.json" - - assert ( - handoff.main(_handoff_args(verifier, readiness, expected_sha256="0" * 64)) == 66 - ) - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-provenance-mismatch", - } - assert not executed.exists() - - -def test_main_executes_digest_bound_private_snapshot(tmp_path, monkeypatch, capsys): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - original_bytes = verifier.read_bytes() - readiness = tmp_path / "readiness.json" - - def fake_run(snapshot: Path, received_readiness: Path) -> handoff.VerifierResult: - assert snapshot != verifier - assert snapshot.parent != verifier.parent - assert snapshot.read_bytes() == original_bytes - assert received_readiness == readiness - verifier.write_bytes(b"tampered after provenance verification") - assert snapshot.read_bytes() == original_bytes - return handoff.VerifierResult(0, json.dumps(_success_payload()).encode(), b"") - - monkeypatch.setattr(handoff, "_run_bounded_verifier", fake_run) - - assert handoff.main(_handoff_args(verifier, readiness)) == 0 - assert json.loads(capsys.readouterr().out) == _success_payload() - - -def test_main_normalizes_snapshot_creation_failure_without_leakage( - tmp_path, monkeypatch, capsys -): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - readiness = tmp_path / "readiness.json" - args = _handoff_args(verifier, readiness) - - def fail_temporary_directory(*_args, **_kwargs): - raise OSError("private temp path must not leak") - - monkeypatch.setattr( - handoff.tempfile, "TemporaryDirectory", fail_temporary_directory - ) - - assert handoff.main(args) == 70 - captured = capsys.readouterr() - assert json.loads(captured.out) == { - "ok": False, - "error_code": "disksage-verifier-snapshot-failed", - } - assert captured.err == "" - assert "private temp path" not in captured.out - - -def test_main_normalizes_snapshot_cleanup_failure_without_leakage( - tmp_path, monkeypatch, capsys -): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - readiness = tmp_path / "readiness.json" - args = _handoff_args(verifier, readiness) - original_temporary_directory = handoff.tempfile.TemporaryDirectory - - class CleanupFailure: - def __init__(self, *temp_args, **temp_kwargs): - self.delegate = original_temporary_directory(*temp_args, **temp_kwargs) - self.name = self.delegate.name - - def cleanup(self): - self.delegate.cleanup() - raise OSError("private cleanup path must not leak") - - monkeypatch.setattr(handoff.tempfile, "TemporaryDirectory", CleanupFailure) - - assert handoff.main(args) == 70 - captured = capsys.readouterr() - assert json.loads(captured.out) == { - "ok": False, - "error_code": "disksage-verifier-snapshot-failed", - } - assert captured.err == "" - assert "private cleanup path" not in captured.out - - -def test_main_rejects_short_snapshot_write_without_execution( - tmp_path, monkeypatch, capsys -): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - readiness = tmp_path / "readiness.json" - args = _handoff_args(verifier, readiness) - original_open = Path.open - - class ShortWriter: - def __init__(self, destination): - self.destination = destination - self.first_write = True - - def __enter__(self): - self.destination.__enter__() - return self - - def __exit__(self, *exc_info): - return self.destination.__exit__(*exc_info) - - def fileno(self): - return self.destination.fileno() - - def write(self, data): - if self.first_write: - self.first_write = False - return self.destination.write(data[:1]) - return 0 - - def short_snapshot_open(path, *open_args, **open_kwargs): - destination = original_open(path, *open_args, **open_kwargs) - if path.parent.name.startswith("naruon-disksage-verifier-"): - return ShortWriter(destination) - return destination - - monkeypatch.setattr(Path, "open", short_snapshot_open) - - def must_not_execute(*_args, **_kwargs): - pytest.fail("a short verifier snapshot was executed") - - monkeypatch.setattr(handoff, "_run_bounded_verifier", must_not_execute) - - assert handoff.main(args) == 70 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-snapshot-failed", - } - - -def test_main_completes_repeated_short_snapshot_writes(tmp_path, monkeypatch, capsys): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - readiness = tmp_path / "readiness.json" - original_open = Path.open - - class OneByteWriter: - def __init__(self, destination): - self.destination = destination - - def __enter__(self): - self.destination.__enter__() - return self - - def __exit__(self, *exc_info): - return self.destination.__exit__(*exc_info) - - def fileno(self): - return self.destination.fileno() - - def write(self, data): - return self.destination.write(data[:1]) - - def one_byte_snapshot_open(path, *open_args, **open_kwargs): - destination = original_open(path, *open_args, **open_kwargs) - if path.parent.name.startswith("naruon-disksage-verifier-"): - return OneByteWriter(destination) - return destination - - monkeypatch.setattr(Path, "open", one_byte_snapshot_open) - - assert handoff.main(_handoff_args(verifier, readiness)) == 0 - assert json.loads(capsys.readouterr().out) == _success_payload() - - -@pytest.mark.parametrize("replacement", ["symlink", "fifo"]) -def test_main_rejects_path_replacement_after_precheck( - tmp_path, monkeypatch, capsys, replacement -): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - replacement_target = _json_verifier( - tmp_path / "replacement-target", _success_payload(), 0 - ) - readiness = tmp_path / "readiness.json" - args = _handoff_args(verifier, readiness) - original_check = handoff._verifier_is_executable_regular_file - - def approve_then_replace(path: Path) -> bool: - assert original_check(path) - path.unlink() - if replacement == "symlink": - path.symlink_to(replacement_target) - else: - os.mkfifo(path, mode=0o700) - return True - - monkeypatch.setattr( - handoff, "_verifier_is_executable_regular_file", approve_then_replace - ) - - assert handoff.main(args) == 66 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-unavailable", - } - - -def test_verifier_preflight_handles_lstat_failure(tmp_path): - assert not handoff._verifier_is_executable_regular_file(tmp_path / "missing") - - -def test_snapshot_rejects_growth_beyond_bound_and_swallows_close_failure( - tmp_path, monkeypatch -): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - expected_sha256 = _verifier_sha256(verifier) - original_fstat = handoff.os.fstat - source_stat_hidden = False - - def hide_source_size(file_descriptor): - nonlocal source_stat_hidden - metadata = original_fstat(file_descriptor) - if source_stat_hidden: - return metadata - source_stat_hidden = True - values = list(metadata) - values[6] = 0 - return os.stat_result(values) - - monkeypatch.setattr(handoff.os, "fstat", hide_source_size) - monkeypatch.setattr(handoff, "MAX_VERIFIER_BYTES", 1) - - with pytest.raises(handoff.HandoffError) as error: - with handoff._verified_verifier_snapshot(verifier, expected_sha256): - pytest.fail("an oversized growing verifier snapshot was yielded") - - assert error.value.error_code == "disksage-verifier-unavailable" - - monkeypatch.setattr(handoff, "MAX_VERIFIER_BYTES", 256 * 1024 * 1024) - monkeypatch.setattr(handoff.os, "fstat", original_fstat) - original_open = handoff.os.open - original_close = handoff.os.close - source_file_descriptor = None - - def record_source_open(path, flags, *args, **kwargs): - nonlocal source_file_descriptor - file_descriptor = original_open(path, flags, *args, **kwargs) - if Path(path) == verifier: - source_file_descriptor = file_descriptor - return file_descriptor - - def close_then_fail(file_descriptor): - original_close(file_descriptor) - if file_descriptor == source_file_descriptor: - raise OSError("close failure must be ignored") - - monkeypatch.setattr(handoff.os, "open", record_source_open) - monkeypatch.setattr(handoff.os, "close", close_then_fail) - with handoff._verified_verifier_snapshot(verifier, expected_sha256) as snapshot: - assert snapshot.read_bytes() == verifier.read_bytes() - - -class _TerminationProcess: - def __init__(self, *, polls=(), waits=(), kill_error=False): - self.pid = 4242 - self._polls = list(polls) - self._waits = list(waits) - self.kill_error = kill_error - self.kill_count = 0 - - def poll(self): - return self._polls.pop(0) - - def kill(self): - self.kill_count += 1 - if self.kill_error: - raise OSError("kill failed") - - def wait(self, timeout): - outcome = self._waits.pop(0) - if isinstance(outcome, BaseException): - raise outcome - return outcome - - -def test_terminate_process_group_covers_platform_and_reap_failures(monkeypatch): - monkeypatch.setattr(handoff.os, "name", "nt") - kill_failure = _TerminationProcess(polls=[None], waits=[0], kill_error=True) - handoff._terminate_process_group(kill_failure) - assert kill_failure.kill_count == 1 - - already_exited = _TerminationProcess(polls=[0], waits=[0]) - handoff._terminate_process_group(already_exited) - assert already_exited.kill_count == 0 - - monkeypatch.setattr(handoff.os, "name", "posix") - monkeypatch.setattr(handoff.os, "killpg", lambda *_args: None) - timeout = subprocess.TimeoutExpired("verifier", 1) - repeated_timeout = _TerminationProcess( - polls=[None], waits=[timeout, timeout], kill_error=True - ) - handoff._terminate_process_group(repeated_timeout) - assert repeated_timeout.kill_count == 1 - - completed_during_timeout = _TerminationProcess(polls=[0], waits=[timeout, 0]) - handoff._terminate_process_group(completed_during_timeout) - assert completed_during_timeout.kill_count == 0 - - wait_failure = _TerminationProcess(waits=[OSError("wait failed")]) - handoff._terminate_process_group(wait_failure) - - -def test_run_bounded_verifier_normalizes_spawn_and_selector_failures( - tmp_path, monkeypatch -): - original_popen = subprocess.Popen - - def spawn_failure(*_args, **_kwargs): - raise OSError("private executable path") - - monkeypatch.setattr(handoff.subprocess, "Popen", spawn_failure) - with pytest.raises(handoff.HandoffError) as error: - handoff._run_bounded_verifier(Path("/verifier"), Path("/readiness")) - assert error.value.error_code == "disksage-verifier-exec-failed" - - verifier = _python_verifier(tmp_path / "verifier", "import time\ntime.sleep(30)\n") - monkeypatch.setattr(handoff.subprocess, "Popen", original_popen) - - class RegisterFailureSelector: - def register(self, *_args, **_kwargs): - raise OSError("selector registration failed") - - def close(self): - return None - - monkeypatch.setattr(handoff.selectors, "DefaultSelector", RegisterFailureSelector) - with pytest.raises(handoff.HandoffError) as error: - handoff._run_bounded_verifier(verifier, tmp_path / "readiness.json") - assert error.value.error_code == "disksage-verifier-exec-failed" - - -@pytest.mark.parametrize("missing_pipe", ["stdout", "stderr"]) -def test_run_bounded_verifier_fails_closed_when_spawn_omits_pipe( - monkeypatch, missing_pipe -): - class MissingPipeProcess: - pid = 4242 - stdout = None if missing_pipe == "stdout" else object() - stderr = None if missing_pipe == "stderr" else object() - - process = MissingPipeProcess() - terminated = [] - monkeypatch.setattr(handoff.subprocess, "Popen", lambda *_args, **_kwargs: process) - monkeypatch.setattr( - handoff, - "_terminate_process_group", - lambda candidate: terminated.append(candidate), - ) - - with pytest.raises(handoff.HandoffError) as error: - handoff._run_bounded_verifier(Path("/verifier"), Path("/readiness")) - - assert error.value.error_code == "disksage-verifier-exec-failed" - assert terminated == [process] - - -def test_run_bounded_verifier_handles_immediate_deadline(tmp_path, monkeypatch): - verifier = _python_verifier(tmp_path / "verifier", "import time\ntime.sleep(30)\n") - monkeypatch.setattr(handoff, "VERIFIER_TIMEOUT_SECONDS", 0) - - with pytest.raises(handoff.HandoffError) as error: - handoff._run_bounded_verifier(verifier, tmp_path / "readiness.json") - - assert error.value.error_code == "disksage-verifier-timeout" - - -def test_run_bounded_verifier_retries_nonblocking_read(tmp_path, monkeypatch): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - original_selector = handoff.selectors.DefaultSelector - original_read = handoff.os.read - state = {"block_next_read": False, "injected": False} - - class BlockingOnceSelector: - def __init__(self): - self.delegate = original_selector() - - def __getattr__(self, name): - return getattr(self.delegate, name) - - def select(self, *args, **kwargs): - events = self.delegate.select(*args, **kwargs) - if events and not state["injected"]: - state["injected"] = True - state["block_next_read"] = True - return events - - def read_once_blocking(file_descriptor, size): - if state["block_next_read"]: - state["block_next_read"] = False - raise BlockingIOError - return original_read(file_descriptor, size) - - monkeypatch.setattr(handoff.selectors, "DefaultSelector", BlockingOnceSelector) - monkeypatch.setattr(handoff.os, "read", read_once_blocking) - - result = handoff._run_bounded_verifier(verifier, tmp_path / "readiness.json") - - assert state["injected"] - assert handoff._decode_protocol(result) == _success_payload() - - -def test_run_bounded_verifier_rejects_deadline_after_stream_drain( - tmp_path, monkeypatch -): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - original_selector = handoff.selectors.DefaultSelector - state = {"streams_drained": False} - - class DrainAwareSelector: - def __init__(self): - self.delegate = original_selector() - - def __getattr__(self, name): - return getattr(self.delegate, name) - - def get_map(self): - mapping = self.delegate.get_map() - if not mapping: - state["streams_drained"] = True - return mapping - - def monotonic(): - return 100.0 if state["streams_drained"] else 0.0 - - monkeypatch.setattr(handoff.selectors, "DefaultSelector", DrainAwareSelector) - monkeypatch.setattr(handoff, "monotonic", monotonic) - - with pytest.raises(handoff.HandoffError) as error: - handoff._run_bounded_verifier(verifier, tmp_path / "readiness.json") - - assert error.value.error_code == "disksage-verifier-timeout" - - -def test_run_bounded_verifier_normalizes_wait_timeout(tmp_path, monkeypatch): - verifier = _json_verifier(tmp_path / "verifier", _success_payload(), 0) - original_popen = handoff.subprocess.Popen - - class WaitTimeoutOnce: - def __init__(self, delegate): - self.delegate = delegate - self.timed_out = False - - def __getattr__(self, name): - return getattr(self.delegate, name) - - def wait(self, timeout): - if not self.timed_out: - self.timed_out = True - raise subprocess.TimeoutExpired("verifier", timeout) - return self.delegate.wait(timeout=timeout) - - def wrapped_popen(*args, **kwargs): - return WaitTimeoutOnce(original_popen(*args, **kwargs)) - - monkeypatch.setattr(handoff.subprocess, "Popen", wrapped_popen) - - with pytest.raises(handoff.HandoffError) as error: - handoff._run_bounded_verifier(verifier, tmp_path / "readiness.json") - - assert error.value.error_code == "disksage-verifier-timeout" - - -@pytest.mark.parametrize( - ("payload", "exit_code"), - [ - ({**_success_payload(), "private_path": "/private/source"}, 0), - ({"ok": False, "error_code": "invalid"}, 0), - (_success_payload(), 65), - ({"ok": False, "error_code": "invalid/path"}, 65), - (_success_payload(), 23), - ], -) -def test_main_rejects_mismatched_or_extended_protocol_without_leakage( - tmp_path, capsys, payload, exit_code -): - verifier = _json_verifier(tmp_path / "verifier", payload, exit_code) - readiness = tmp_path / "private-readiness.json" - - assert handoff.main(_handoff_args(verifier, readiness)) == 70 - encoded = capsys.readouterr().out - assert json.loads(encoded) == { - "ok": False, - "error_code": "disksage-verifier-protocol-invalid", - } - assert "/private/source" not in encoded - assert str(readiness) not in encoded - - -@pytest.mark.parametrize( - "result", - [ - handoff.VerifierResult(0, b"not-json", b""), - handoff.VerifierResult(0, b"\xff", b""), - handoff.VerifierResult(0, b"[]", b""), - handoff.VerifierResult(0, b"{}", b""), - handoff.VerifierResult(0, json.dumps(_success_payload()).encode(), b"warning"), - handoff.VerifierResult( - 0, - json.dumps({**_success_payload(), "candidate_count": True}).encode(), - b"", - ), - ], -) -def test_protocol_decoder_rejects_invalid_or_ambiguous_transport(result): - with pytest.raises(handoff.HandoffError) as error: - handoff._decode_protocol(result) - - assert error.value.error_code == "disksage-verifier-protocol-invalid" - - -@pytest.mark.parametrize("stream_name", ["stdout", "stderr"]) -def test_main_kills_oversized_output_without_echoing_it(tmp_path, capsys, stream_name): - verifier = _python_verifier( - tmp_path / "verifier", - "import sys\n" - f"sys.{stream_name}.buffer.write(b'sensitive-path' * 7000)\n" - f"sys.{stream_name}.flush()\n", - ) - readiness = tmp_path / "readiness.json" - - assert handoff.main(_handoff_args(verifier, readiness)) == 70 - encoded = capsys.readouterr().out - assert json.loads(encoded) == { - "ok": False, - "error_code": "disksage-verifier-output-too-large", - } - assert "sensitive-path" not in encoded - - -def test_main_kills_original_process_group_on_timeout(tmp_path, monkeypatch, capsys): - child_pid_path = tmp_path / "child.pid" - verifier = tmp_path / "verifier" - verifier.write_text( - "#!/bin/sh\n" - "/bin/sleep 30 &\n" - "child=$!\n" - f"/usr/bin/printf '%s' \"$child\" > {shlex.quote(str(child_pid_path))}\n" - 'wait "$child"\n', - encoding="utf-8", - ) - verifier.chmod(0o700) - readiness = tmp_path / "readiness.json" - monkeypatch.setattr(handoff, "VERIFIER_TIMEOUT_SECONDS", 2) - - assert handoff.main(_handoff_args(verifier, readiness)) == 70 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-verifier-timeout", - } - child_pid = int(child_pid_path.read_text()) - deadline = time.monotonic() + 2 - while time.monotonic() < deadline: - try: - os.kill(child_pid, 0) - except ProcessLookupError: - break - time.sleep(0.02) - else: - pytest.fail("verifier process-group member survived timeout kill") - - -def test_cli_reserializes_rust_protocol_instead_of_forwarding_raw_output(tmp_path): - payload = { - "ok": False, - "error_code": "naruon-copy-readiness-fingerprint-invalid", - } - verifier = _json_verifier(tmp_path / "fake-disksage-verifier", payload, 65) - readiness = tmp_path / "readiness.json" - - result = subprocess.run( - [ - sys.executable, - str(SCRIPT_PATH), - "--verifier", - str(verifier), - "--verifier-sha256", - _verifier_sha256(verifier), - str(readiness), - ], - check=False, - capture_output=True, - text=True, - ) - - assert result.returncode == 65 - assert json.loads(result.stdout) == payload - assert result.stderr == "" - - -def test_script_entrypoint_exits_through_redacted_usage_protocol(monkeypatch, capsys): - monkeypatch.setattr(sys, "argv", [str(SCRIPT_PATH)]) - - with pytest.raises(SystemExit) as exit_status: - runpy.run_path(str(SCRIPT_PATH), run_name="__main__") - - assert exit_status.value.code == 64 - assert json.loads(capsys.readouterr().out) == { - "ok": False, - "error_code": "disksage-handoff-usage-invalid", - } diff --git a/backend/tests/test_email_parser.py b/backend/tests/test_email_parser.py index da098bc34..e44d03127 100644 --- a/backend/tests/test_email_parser.py +++ b/backend/tests/test_email_parser.py @@ -1,23 +1,12 @@ -import base64 import datetime import os import tempfile from email.message import Message -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest -from services.email_parser import ( - EmailParseError, - _attachment_part_content, - _extract_thread_id, - _format_display_address, - _process_multipart_body, - _process_singlepart_body, - _sanitize_address_display_text, - _sanitize_nul, - parse_eml, - parse_eml_bytes, -) +from services.email_parser import _extract_thread_id, _sanitize_nul, parse_eml +from services.exceptions import EmailParseError def test_parse_eml_basic(): @@ -120,107 +109,6 @@ def test_parse_eml_strips_active_html_from_address_display_fields(): os.unlink(temp_path) -def test_parse_eml_stores_non_ascii_display_names_decoded(): - # RFC 2047: non-ASCII From/To/Reply-To display names arrive as encoded-words - # (e.g. =?UTF-8?B?...?=). policy.default header-decodes them; the stored - # display fields must keep the decoded text rather than re-encoding it back - # into an encoded-word (formataddr's behavior), which would render every - # non-ASCII sender/recipient as garbled =?utf-8?...?= bytes in the UI. - from_name = "박성호" - to_name = "김천" - reply_name = "응답" - subject_text = "회 테스트" - - def encoded_word(text: str) -> bytes: - token = base64.b64encode(text.encode("utf-8")).decode("ascii") - return f"=?UTF-8?B?{token}?=".encode("ascii") - - eml_content = ( - b"Message-ID: \r\n" - b"From: " + encoded_word(from_name) + b" \r\n" - b"To: " + encoded_word(to_name) + b" \r\n" - b"Reply-To: " + encoded_word(reply_name) + b" \r\n" - b"Subject: " + encoded_word(subject_text) + b"\r\n" - b"Date: Mon, 27 Apr 2026 10:00:00 +0000\r\n" - b"\r\n" - b"Plain body" - ) - - with tempfile.NamedTemporaryFile(delete=False, suffix=".eml") as f: - f.write(eml_content) - temp_path = f.name - - try: - parsed = parse_eml(temp_path) - assert parsed["sender"] == f"{from_name} " - assert parsed["recipients"] == f"{to_name} " - assert parsed["reply_to"] == f"{reply_name} " - assert parsed["subject"] == subject_text - assert "=?" not in parsed["sender"] - assert "=?" not in parsed["recipients"] - finally: - os.unlink(temp_path) - - -def test_sanitize_address_display_text_keeps_decoded_unicode_and_quotes_specials(): - # A decoded non-ASCII name stays literal (formataddr would re-encode it). - assert ( - _sanitize_address_display_text("박성호 ") - == "박성호 " - ) - # A display name containing an RFC 5322 special is quoted so a ", "-joined - # multi-address value stays unambiguous. - assert ( - _sanitize_address_display_text('"Doe, John" ') - == '"Doe, John" ' - ) - # Multiple addresses with mixed scripts are each formatted and comma-joined. - assert ( - _sanitize_address_display_text("박성호 , Bob ") - == "박성호 , Bob " - ) - - -def test_format_display_address_escapes_quotes_and_handles_empty_name(): - # No display name -> bare address. - assert _format_display_address("", "a@x.com") == "a@x.com" - # Non-ASCII name kept literal. - assert _format_display_address("박성호", "s@x.com") == "박성호 " - # Embedded quotes/backslashes are escaped inside the quoted-string, matching - # email.utils.formataddr's escaping. - assert ( - _format_display_address('Fancy "Q"', "q@x.com") == '"Fancy \\"Q\\"" ' - ) - - -def test_process_multipart_body_ignores_non_string_part_content(): - # get_content() can return a non-str (e.g. undecodable bytes) even for a - # text/* part; the isinstance guard must drop it rather than concatenate - # bytes into the plain/html body. - plain_part = MagicMock() - plain_part.get_content_type.return_value = "text/plain" - plain_part.get_filename.return_value = None - plain_part.get_content.return_value = b"not-a-str" - html_part = MagicMock() - html_part.get_content_type.return_value = "text/html" - html_part.get_filename.return_value = None - html_part.get_content.return_value = b"not-a-str" - msg = MagicMock() - msg.walk.return_value = [plain_part, html_part] - - assert _process_multipart_body(msg) == ("", "", []) - - -def test_process_singlepart_body_ignores_non_string_content(): - # A single-part message whose get_content() returns a non-str yields an - # empty body rather than a stringified bytes value. - msg = MagicMock() - msg.get_content_type.return_value = "text/plain" - msg.get_content.return_value = b"not-a-str" - - assert _process_singlepart_body(msg) == ("", "", []) - - def test_parse_eml_strips_active_html_from_attachment_display_fields(): eml_content = b"""Message-ID: From: sender@test.com @@ -437,33 +325,6 @@ def test_parse_eml_missing_and_malformed_date(): os.unlink(temp_path2) -def test_parse_eml_unknown_timezone_date_is_timezone_aware(): - # RFC 5322 section 3.3: a "-0000" zone means the time zone is unknown, for - # which parsedate_to_datetime returns a *naive* datetime. Every other parse - # path yields an aware datetime, so the parser must normalize this to aware - # too -- otherwise sorting/comparing it against another message's date raises - # "can't compare offset-naive and offset-aware datetimes" and it misbinds the - # instant in a timestamptz column. - eml_content = b"""Message-ID: -From: test@test.com -To: recipient@test.com -Subject: Unknown zone -Date: Mon, 27 Apr 2026 10:00:00 -0000 - -Test.""" - with tempfile.NamedTemporaryFile(delete=False, suffix=".eml") as f: - f.write(eml_content) - temp_path = f.name - - try: - parsed = parse_eml(temp_path) - assert parsed["date"].tzinfo is not None - # must not raise offset-naive/aware TypeError - assert parsed["date"] <= datetime.datetime.now(datetime.timezone.utc) - finally: - os.unlink(temp_path) - - def test_parse_eml_io_error(): with pytest.raises(EmailParseError): parse_eml("/path/to/nonexistent/file.eml") @@ -520,70 +381,6 @@ def test_extract_thread_id_uses_first_reference_from_long_header(): assert _extract_thread_id(msg, "") == "" -def test_sanitize_address_display_text_keeps_name_only_and_falls_back_to_text(): - # A token with a display name but an empty address part keeps the name - # (rather than dropping it), and a header that yields no address at all - # falls back to the sanitized raw text. - assert _sanitize_address_display_text("Display Name <>") == "Display Name" - assert _sanitize_address_display_text("") == "" - - -def test_attachment_part_content_falls_back_to_raw_payload_on_decode_error(): - # A part whose get_content() cannot decode (unknown charset / malformed - # transfer-encoding) falls back to the raw decoded payload, and to "" when - # the payload is absent, instead of propagating the decode error. - raw_part = MagicMock() - raw_part.get_content.side_effect = LookupError("unknown charset") - raw_part.get_payload.return_value = b"raw-bytes" - assert _attachment_part_content(raw_part) == b"raw-bytes" - - empty_part = MagicMock() - empty_part.get_content.side_effect = ValueError("bad encoding") - empty_part.get_payload.return_value = None - assert _attachment_part_content(empty_part) == "" - - -def test_parse_eml_bytes_parses_provider_bytes_and_wraps_parse_errors(): - parsed = parse_eml_bytes( - b"Message-ID: \r\n" - b"From: sender@test.com\r\n" - b"To: user@test.com\r\n" - b"Subject: Bytes\r\n\r\n" - b"Body" - ) - assert parsed["message_id"] == "" - assert parsed["subject"] == "Bytes" - - # A parser failure is wrapped as the sanitized public EmailParseError rather - # than leaking the internal exception chain at the ingest boundary. - with patch( - "services.email_parser.message_from_bytes", side_effect=ValueError("boom") - ): - with pytest.raises(EmailParseError): - parse_eml_bytes(b"anything") - - -def test_extract_thread_id_falls_through_whitespace_only_headers(): - # A References/In-Reply-To header that unfolds to only whitespace is present - # but yields no token when split; _extract_thread_id must fall through to the - # next source rather than return a blank thread id. - fell_to_in_reply_to = Message() - fell_to_in_reply_to["References"] = " " - fell_to_in_reply_to["In-Reply-To"] = "" - assert ( - _extract_thread_id(fell_to_in_reply_to, "") - == "" - ) - - fell_to_message_id = Message() - fell_to_message_id["References"] = " " - fell_to_message_id["In-Reply-To"] = " \t " - assert ( - _extract_thread_id(fell_to_message_id, "") - == "" - ) - - def test_parse_eml_extracts_reply_to_header(): eml_content = b"""Message-ID: From: Sender Name diff --git a/backend/tests/test_tenant_config_api.py b/backend/tests/test_tenant_config_api.py index b9d84ded6..d648fd7a8 100644 --- a/backend/tests/test_tenant_config_api.py +++ b/backend/tests/test_tenant_config_api.py @@ -195,8 +195,9 @@ def record_pop3_destination(host, port, *, resolve_host=True): imap_server="imap.example.com", imap_port=None, pop3_server="pop3.example.com", - pop3_port=None, - ), + pop3_port=None, + oauth_redirect_uri=None, + ), ) assert calls == [ diff --git a/backend/tests/test_threading_service.py b/backend/tests/test_threading_service.py index 2372ceaf1..9c0d03450 100644 --- a/backend/tests/test_threading_service.py +++ b/backend/tests/test_threading_service.py @@ -1,12 +1,6 @@ import pytest -from services.threading_service import ( - _find_existing_thread_ids, - assign_thread_id, - extract_reference_ids, - generate_email_fingerprint, - normalize_message_id, -) +from services.threading_service import assign_thread_id class _Result: @@ -149,226 +143,3 @@ async def test_existing_thread_lookup_is_scoped_to_owner_and_organization(): query_text = str(session.queries[-1]).lower() assert "email_records.user_id" in query_text assert "email_records.organization_id" in query_text - - -def test_normalize_message_id_strips_brackets_and_outer_whitespace(): - assert normalize_message_id("") == "abc@example.com" - assert normalize_message_id(" ") == "abc@example.com" - assert normalize_message_id("< abc@example.com >") == "abc@example.com" - assert normalize_message_id("<>") == "abc@example.com" - assert normalize_message_id("abc@example.com") == "abc@example.com" - - -def test_normalize_message_id_handles_empty_and_none(): - assert normalize_message_id(None) is None - assert normalize_message_id("") is None - assert normalize_message_id(" ") is None - assert normalize_message_id("<>") is None - - -def test_normalize_message_id_collapses_interior_unfolding_whitespace(): - # RFC 5322 section 2.2.3 header unfolding can leave interior whitespace when - # a folded Message-ID is rejoined; RFC 5322 section 3.6.4 msg-id carries - # none, so the folded and unfolded forms must normalize to the same value or - # dedup/threading would treat one message as two. - canonical = normalize_message_id("") - assert normalize_message_id("") == canonical - assert normalize_message_id("") == canonical - assert normalize_message_id("") == canonical - assert normalize_message_id("") == canonical - - -def test_extract_reference_ids_normalizes_folded_whitespace_and_dedupes(): - header = " \r\n " - # The first two are the same id split over a fold boundary, so only two - # distinct references remain, in header order. - assert extract_reference_ids(header) == ["a@x.com", "b@x.com"] - - -def test_extract_reference_ids_drops_bracketed_whitespace_only_ids(): - # "< >" / "<\t>" are bracketed but whitespace-only: they normalize to nothing - # and must be dropped, not carried as empty thread candidates. - assert extract_reference_ids("< > <\t>") == ["a@x.com"] - - -def test_extract_reference_ids_falls_back_to_whitespace_split_without_brackets(): - # A References value with no angle brackets (some non-conforming clients) - # falls back to a whitespace split rather than yielding nothing. - assert extract_reference_ids("a@x.com b@x.com") == ["a@x.com", "b@x.com"] - - -@pytest.mark.asyncio -async def test_find_existing_thread_ids_returns_empty_without_candidates(): - session = _SequentialSession([]) - result = await _find_existing_thread_ids( - session, [], user_id="testuser", organization_id="org-acme" - ) - assert result == {} - assert session.execute_count == 0 - - -@pytest.mark.asyncio -async def test_find_existing_thread_ids_dedupes_overlapping_bracket_targets(): - # A bare id and its already-bracketed form collapse to one target set, so the - # shared "" lookup key is not enqueued twice. - session = _QueryCapturingSession([[("", "thread-a")]]) - result = await _find_existing_thread_ids( - session, - ["", "a@x.com"], - user_id="testuser", - organization_id="org-acme", - ) - assert result == {"a@x.com": "thread-a"} - - -@pytest.mark.asyncio -async def test_find_existing_thread_ids_skips_rows_with_blank_thread_or_message_id(): - # A stored row with no thread_id is skipped, and a row whose message_id - # normalizes to nothing is skipped; neither pollutes the returned map. - session = _SequentialSession( - [ - [ - ("", None), - ("", "thread-b"), - ("", "thread-c"), - ] - ] - ) - result = await _find_existing_thread_ids( - session, - ["a@x.com", "c@x.com"], - user_id="testuser", - organization_id="org-acme", - ) - assert result == {"c@x.com": "thread-c"} - - -@pytest.mark.asyncio -async def test_assign_thread_id_uses_a_later_candidate_when_the_first_has_no_thread(): - # The immediate parent (in_reply_to) is not yet imported, but an older - # reference is: the lookup loop must skip the unmatched first candidate and - # return the matched later one, not fall through to the deterministic root. - session = _SequentialSession([[("", "thread-older")]]) - - thread_id = await assign_thread_id( - session, - { - "message_id": "", - "in_reply_to": "", - "references": "", - }, - user_id="testuser", - organization_id="org-acme", - ) - - assert thread_id == "thread-older" - - -def test_generate_email_fingerprint_is_deterministic_case_insensitive_and_field_sensitive(): - baseline = generate_email_fingerprint( - "Quarterly plan", "Mon, 01 Jun 2026 09:00:00 +0000", "a@x.com", "b@y.com" - ) - # 1. deterministic + SHA-256 hex digest - assert baseline == generate_email_fingerprint( - "Quarterly plan", "Mon, 01 Jun 2026 09:00:00 +0000", "a@x.com", "b@y.com" - ) - assert len(baseline) == 64 - assert all(character in "0123456789abcdef" for character in baseline) - # 2. lower-cased + outer-whitespace-stripped components collapse to one key - assert ( - generate_email_fingerprint( - " QUARTERLY PLAN ", "Mon, 01 Jun 2026 09:00:00 +0000", "A@X.com", " b@Y.com " - ) - == baseline - ) - # 3. None components are treated as empty (no crash) and stay distinct - all_empty = generate_email_fingerprint(None, None, None, None) - assert len(all_empty) == 64 - assert all_empty != baseline - # 4. any changed component changes the fingerprint (no field is dropped) - assert ( - generate_email_fingerprint( - "Quarterly plan", "Mon, 01 Jun 2026 09:00:00 +0000", "a@x.com", "c@z.com" - ) - != baseline - ) - - -@pytest.mark.asyncio -async def test_assign_thread_id_generates_fresh_uuid_when_no_identifiers_present(): - # An email with no in_reply_to, no references, and no message_id has nothing - # to thread on, so a fresh uuid4 root is minted and no lookup is issued. - session = _SequentialSession([]) - - thread_id = await assign_thread_id( - session, - {"message_id": None, "in_reply_to": None, "references": None}, - user_id="testuser", - organization_id="org-acme", - ) - - assert len(thread_id) == 32 - assert all(character in "0123456789abcdef" for character in thread_id) - assert session.execute_count == 0 - - -@pytest.mark.asyncio -async def test_multi_id_in_reply_to_threads_on_any_existing_parent(): - # RFC 5322 section 3.6.4: In-Reply-To is 1*msg-id, so it may carry more than - # one parent id (a reply that joins two messages). Threading must consider - # every parent, not treat the whole header as one opaque id -- otherwise a - # multi-id In-Reply-To never matches an existing thread and the reply splits - # off on its own. - session = _SequentialSession([[("", "thread-xyz")]]) - - thread_id = await assign_thread_id( - session, - { - "message_id": "", - "in_reply_to": " ", - "references": None, - }, - user_id="testuser", - organization_id="org-acme", - ) - - assert thread_id == "thread-xyz" - - -@pytest.mark.asyncio -async def test_multi_id_in_reply_to_fallback_uses_first_parent_as_root(): - session = _SequentialSession([[]]) - - thread_id = await assign_thread_id( - session, - { - "message_id": "", - "in_reply_to": " ", - "references": None, - }, - user_id="testuser", - organization_id="org-acme", - ) - - assert thread_id == "first@example.com" - - -@pytest.mark.asyncio -async def test_in_reply_to_with_cfws_comment_extracts_bare_msg_id(): - # RFC 5322 sections 3.6.4 / 3.2.2 permit CFWS (e.g. a trailing comment) around - # a msg-id. The comment text must not leak into the id, or the reply is - # threaded/deduped against a garbage id and splits from its parent thread. - session = _SequentialSession([[("", "thread-123")]]) - - thread_id = await assign_thread_id( - session, - { - "message_id": "", - "in_reply_to": " (sent from my phone)", - "references": None, - }, - user_id="testuser", - organization_id="org-acme", - ) - - assert thread_id == "thread-123" diff --git a/docs/research/email-ingest-threading/README.md b/docs/research/email-ingest-threading/README.md deleted file mode 100644 index 96bf77fbd..000000000 --- a/docs/research/email-ingest-threading/README.md +++ /dev/null @@ -1,106 +0,0 @@ -# Email Ingest & Threading — Standards Basis and Design Rationale - -This pack grounds the email-ingest correctness work on -`ContextualWisdomLab/naruon#1192` -(`backend/services/threading_service.py`, `backend/services/email_parser.py`): -Message-ID interior-whitespace normalization, unknown-zone (`-0000`) date -normalization, In-Reply-To `1*msg-id` (multi-parent + CFWS) parsing, and -RFC 2047 encoded-word decoding of non-ASCII display names. - -## Standards basis (RFC 5322 message format + RFC 2047 header encoding) - -Each fix is anchored to a specific clause of the relevant standard: - -- **Header unfolding** — RFC 5322 §2.2.3. When a folded header is rejoined, - interior whitespace/tabs can survive. `normalize_message_id` collapses that - interior whitespace so the folded and unfolded forms of one Message-ID map to - a single de-dup/threading key. -- **Message-ID** — RFC 5322 §3.6.4 (`msg-id = "<" id-left "@" id-right ">"`). - A well-formed Message-ID carries no interior whitespace, so collapsing it is a - no-op for conforming input and only repairs unfolded input. -- **In-Reply-To / References** — RFC 5322 §3.6.4 defines both as `1*msg-id` - (one or more angle-bracketed ids, each optionally wrapped in CFWS, §3.2.2). - `assign_thread_id` therefore parses In-Reply-To with the same multi-id - extractor used for References, so a reply naming several parents — or a single - id trailed by a comment — threads onto an existing ancestor instead of - splitting off on a garbage key. -- **Date / unknown zone** — RFC 5322 §3.3 defines a `-0000` zone as "time zone - unknown". `email.utils.parsedate_to_datetime` returns a naive datetime for - that case; `_extract_date` treats the unknown zone as UTC so every ingested - date is timezone-aware and safe to sort and to store in a `timestamptz` - column. -- **Non-ASCII display names** — RFC 2047 (MIME Part Three) defines the - `=?charset?enc?text?=` encoded-word so non-ASCII text can appear in structured - headers. `From` / `To` / `Reply-To` display names arrive header-decoded under - `email.policy.default`, so `_sanitize_address_display_text` must store the - decoded name and must **not** re-encode it. `email.utils.formataddr` re-encodes - any non-ASCII display name back into an encoded-word, which stored a garbled - `=?utf-8?...?=` value for every non-ASCII (e.g. Korean) sender/recipient; - `_format_display_address` keeps the decoded name literal while preserving - formataddr's RFC 5322 quoting/escaping for display-name specials. - -## Design rationale — header-based, precision-first threading - -Naruon reconstructs conversations from the RFC 5322 reference graph -(References / In-Reply-To), deterministically, and deliberately does **not** -fall back to subject- or content-based grouping. This is a precision/recall -trade-off, not an oversight, and the rejected alternative is grounded in the -conversation-threading literature: - -- Content- and coherence-model approaches to thread reconstruction are - probabilistic and improve *recall* on broken reference chains, but carry an - inherent false-merge (precision) cost: Mohiuddin, Joty, and Nguyen (2018) - reconstruct thread trees by scoring candidate structures with a neural - coherence model, and even their best model reaches only ~30% thread-level - reconstruction accuracy — useful for recall on missing links, but far from the - certainty a mailbox view requires. -- Email threads are also large and topically heterogeneous in practice (Kooti, - Aiello, Grbovic, Lerman, & Mantrach, 2015, characterize replying behavior - over 16 billion messages; Zhang, Celikyilmaz, Gao, & Bansal, 2021, curate - 2,549 real email threads for EmailSum), so a wrong content-based merge is both - likely and costly at scale. -- Naruon therefore optimizes for *precision* — never merging unrelated messages — - because a wrong merge silently corrupts a user's mailbox view. The invariant is - pinned by `test_forwarded_subject_alone_does_not_merge_unrelated_thread`. -- The #1192 In-Reply-To fix improves *recall on the header-complete path* (it - no longer drops multi-parent / CFWS replies) with **zero** precision cost, - because it stays entirely within the deterministic header graph. - -The cited papers are bookmarked in the shared alphaXiv library folder -"CWL · Naruon email/threading standards grounding". - -## References (APA 7) - -- Resnick, P. (Ed.). (2008). *Internet message format* (RFC 5322). RFC Editor. - https://www.rfc-editor.org/rfc/rfc5322.txt -- Moore, K. (1996). *MIME (Multipurpose Internet Mail Extensions) part three: - Message header extensions for non-ASCII text* (RFC 2047). RFC Editor. - https://www.rfc-editor.org/rfc/rfc2047.txt -- Kooti, F., Aiello, L. M., Grbovic, M., Lerman, K., & Mantrach, A. (2015). - *Evolution of conversations in the age of email overload* [Preprint]. arXiv. - https://arxiv.org/abs/1504.00704 -- Mohiuddin, T., Joty, S., & Nguyen, D. T. (2018). *Coherence modeling of - asynchronous conversations: A neural entity grid approach* [Preprint]. arXiv. - https://arxiv.org/abs/1805.02275 -- Zhang, S., Celikyilmaz, A., Gao, J., & Bansal, M. (2021). *EmailSum: - Abstractive email thread summarization* [Preprint]. arXiv. - https://arxiv.org/abs/2107.14691 - -## Preservation notes - -- Original standard text and paper PDFs are referenced by their canonical - RFC Editor / arXiv URLs above and are bookmarked in the alphaXiv library - folder named in the design-rationale section. They are not committed here - because this sandbox's outbound proxy blocks `rfc-editor.org` and `arxiv.org`; - when a network-enabled run can fetch them, drop the RFC text into - `standards/` and the PDFs into `pdfs/` following the sibling packs' layout. -- Git LFS is intentionally not used, consistent with the other - `docs/research/*` packs. - -## Governance notes - -- Work item: `ContextualWisdomLab/naruon#1192` (RFC 5322 / RFC 2047 email-ingest - correctness + coverage). -- Verification: threading/parser suites pass with `--noconftest`; - `threading_service.py` at 100% and `email_parser.py` at 98% branch coverage - (the RFC 2047 `_format_display_address` helper is fully covered). diff --git a/frontend/dev.log b/frontend/dev.log deleted file mode 100644 index 22948417f..000000000 --- a/frontend/dev.log +++ /dev/null @@ -1,61 +0,0 @@ - -> frontend@0.1.0 dev -> next dev - -▲ Next.js 16.2.6 (Turbopack) -- Local: http://localhost:18080 -- Network: http://169.254.23.164:18080 -✓ Ready in 377ms - - GET / 200 in 468ms (next.js: 121ms, application-code: 347ms) - GET / 200 in 473ms (next.js: 160ms, application-code: 313ms) - GET / 200 in 471ms (next.js: 165ms, application-code: 305ms) - GET / 200 in 479ms (next.js: 369ms, application-code: 110ms) -⚠ Blocked cross-origin request to Next.js dev resource /_next/webpack-hmr from "127.0.0.1". -Cross-origin access to Next.js dev resources is blocked by default for safety. - -To allow this host in development, add it to "allowedDevOrigins" in next.config.js and restart the dev server: - -// next.config.js -module.exports = { - allowedDevOrigins: ['127.0.0.1'], -} - -Read more: https://nextjs.org/docs/app/api-reference/config/next-config-js/allowedDevOrigins - GET / 200 in 42ms (next.js: 2ms, application-code: 40ms) - GET / 200 in 106ms (next.js: 4ms, application-code: 103ms) - GET / 200 in 67ms (next.js: 3ms, application-code: 63ms) - GET / 200 in 77ms (next.js: 1403µs, application-code: 75ms) - GET / 200 in 79ms (next.js: 33ms, application-code: 46ms) - GET /settings 200 in 403ms (next.js: 365ms, application-code: 38ms) - GET / 200 in 89ms (next.js: 4ms, application-code: 85ms) - GET / 200 in 91ms (next.js: 36ms, application-code: 54ms) - GET / 200 in 32ms (next.js: 1153µs, application-code: 31ms) - GET / 200 in 31ms (next.js: 1918µs, application-code: 29ms) - GET / 200 in 30ms (next.js: 1244µs, application-code: 29ms) - GET / 200 in 78ms (next.js: 2ms, application-code: 75ms) - GET / 200 in 56ms (next.js: 1794µs, application-code: 54ms) - GET / 200 in 56ms (next.js: 1966µs, application-code: 54ms) - GET / 200 in 32ms (next.js: 984µs, application-code: 31ms) - GET / 200 in 69ms (next.js: 1080µs, application-code: 68ms) - GET / 200 in 71ms (next.js: 11ms, application-code: 60ms) - GET / 200 in 31ms (next.js: 1382µs, application-code: 30ms) - GET / 200 in 68ms (next.js: 3ms, application-code: 65ms) - GET / 200 in 69ms (next.js: 29ms, application-code: 40ms) - GET / 200 in 29ms (next.js: 963µs, application-code: 28ms) - GET / 200 in 31ms (next.js: 1061µs, application-code: 30ms) - GET / 200 in 78ms (next.js: 1659µs, application-code: 76ms) - GET / 200 in 51ms (next.js: 2ms, application-code: 49ms) - GET / 200 in 29ms (next.js: 1263µs, application-code: 28ms) - GET / 200 in 80ms (next.js: 1308µs, application-code: 78ms) - GET / 200 in 51ms (next.js: 1566µs, application-code: 49ms) - GET / 200 in 44ms (next.js: 1701µs, application-code: 42ms) - GET /mail 200 in 129ms (next.js: 24ms, application-code: 106ms) - GET /mail 200 in 139ms (next.js: 37ms, application-code: 102ms) - GET / 200 in 31ms (next.js: 1002µs, application-code: 30ms) - GET / 200 in 30ms (next.js: 1048µs, application-code: 29ms) - GET /calendar 200 in 440ms (next.js: 336ms, application-code: 104ms) - GET /calendar 200 in 448ms (next.js: 352ms, application-code: 96ms) - GET / 200 in 45ms (next.js: 1926µs, application-code: 43ms) - GET /tasks 200 in 285ms (next.js: 205ms, application-code: 80ms) - GET /tasks 200 in 280ms (next.js: 189ms, application-code: 91ms) diff --git a/frontend/package.json b/frontend/package.json index 8563a9c87..1525ffbab 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,43 +18,43 @@ "dependencies": { "@base-ui/react": "^1.6.0", "@radix-ui/react-tabs": "^1.1.21", - "@tailwindcss/postcss": "^4", + "@tailwindcss/postcss": "^4.3.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", - "lucide-react": "^1.27.0", + "lucide-react": "^1.28.0", "next": "16.2.12", "react": "19.2.8", "react-dom": "19.2.8", "react-resizable-panels": "^4.12.2", - "tailwind-merge": "^3.5.0", - "tailwindcss": "^4", + "tailwind-merge": "^3.6.0", + "tailwindcss": "^4.3.3", "tw-animate-css": "^1.4.0", "uuid": "^14.0.1", - "vis-network": "^10.0.2" + "vis-network": "^10.1.0" }, "devDependencies": { - "@playwright/test": "^1.62.0", - "@types/node": "^26", - "@types/react": "^19", - "@types/react-dom": "^19", + "@playwright/test": "^1.62.1", + "@types/node": "^26.1.2", + "@types/react": "^19.2.18", + "@types/react-dom": "^19.2.4", "@vitest/coverage-v8": "4.1.10", - "eslint": "^9", + "eslint": "^9.39.5", "eslint-config-next": "16.2.12", "fast-check": "^4.9.0", - "jsdom": "^30.0.0", - "postcss": "8.5.24", - "typescript": "^6", + "jsdom": "^30.0.1", + "postcss": "8.5.18", + "typescript": "^6.0.3", "vitest": "^4.1.10" }, "overrides": { "brace-expansion": "5.0.8", - "postcss": "8.5.24", + "postcss": "^8.5.25", "undici": "7.28.0", "uuid": "^14.0.0" }, "resolutions": { "brace-expansion": "5.0.8", - "postcss": "8.5.24", + "postcss": "^8.5.25", "undici": "7.28.0" } } diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index effc1257f..401de1fdf 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -6,7 +6,7 @@ settings: overrides: brace-expansion: 5.0.8 - postcss: 8.5.18 + postcss: 8.5.25 sharp: 0.35.0 undici: 7.28.0 @@ -21,12 +21,12 @@ importers: dependencies: '@base-ui/react': specifier: ^1.6.0 - version: 1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.6.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@radix-ui/react-tabs': specifier: ^1.1.21 - version: 1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@tailwindcss/postcss': - specifier: ^4 + specifier: ^4.3.3 version: 4.3.3 class-variance-authority: specifier: ^0.7.1 @@ -35,11 +35,11 @@ importers: specifier: ^2.1.1 version: 2.1.1 lucide-react: - specifier: ^1.27.0 - version: 1.27.0(react@19.2.8) + specifier: ^1.28.0 + version: 1.28.0(react@19.2.8) next: specifier: 16.2.12 - version: 16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + version: 16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) react: specifier: 19.2.8 version: 19.2.8 @@ -50,10 +50,10 @@ importers: specifier: ^4.12.2 version: 4.12.2(react-dom@19.2.8(react@19.2.8))(react@19.2.8) tailwind-merge: - specifier: ^3.5.0 + specifier: ^3.6.0 version: 3.6.0 tailwindcss: - specifier: ^4 + specifier: ^4.3.3 version: 4.3.3 tw-animate-css: specifier: ^1.4.0 @@ -62,26 +62,26 @@ importers: specifier: ^14.0.1 version: 14.0.1 vis-network: - specifier: ^10.0.2 + specifier: ^10.1.0 version: 10.1.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)(keycharm@0.4.0)(uuid@14.0.1)(vis-data@8.0.4(uuid@14.0.1)(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)))(vis-util@6.0.0(@egjs/hammerjs@2.0.17)(component-emitter@2.0.0)) devDependencies: '@playwright/test': - specifier: ^1.62.0 - version: 1.62.0 + specifier: ^1.62.1 + version: 1.62.1 '@types/node': - specifier: ^26 + specifier: ^26.1.2 version: 26.1.2 '@types/react': - specifier: ^19 - version: 19.2.17 + specifier: ^19.2.18 + version: 19.2.18 '@types/react-dom': - specifier: ^19 - version: 19.2.3(@types/react@19.2.17) + specifier: ^19.2.4 + version: 19.2.4(@types/react@19.2.18) '@vitest/coverage-v8': specifier: 4.1.10 version: 4.1.10(vitest@4.1.10) eslint: - specifier: ^9 + specifier: ^9.39.5 version: 9.39.5(jiti@2.7.0) eslint-config-next: specifier: 16.2.12 @@ -90,17 +90,17 @@ importers: specifier: ^4.9.0 version: 4.9.0 jsdom: - specifier: ^30.0.0 - version: 30.0.0 + specifier: ^30.0.1 + version: 30.0.1 postcss: - specifier: 8.5.18 - version: 8.5.18 + specifier: 8.5.25 + version: 8.5.25 typescript: - specifier: ^6 + specifier: ^6.0.3 version: 6.0.3 vitest: specifier: ^4.1.10 - version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)) + version: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)) packages: @@ -112,8 +112,8 @@ packages: resolution: {integrity: sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==} engines: {node: ^22.13.0 || >=24.0.0} - '@asamuzakjp/dom-selector@8.3.0': - resolution: {integrity: sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==} + '@asamuzakjp/dom-selector@8.3.2': + resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==} engines: {node: ^22.13.0 || >=24.0.0} '@babel/code-frame@7.29.7': @@ -128,8 +128,8 @@ packages: resolution: {integrity: sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==} engines: {node: '>=6.9.0'} - '@babel/generator@7.29.7': - resolution: {integrity: sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==} + '@babel/generator@7.29.8': + resolution: {integrity: sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==} engines: {node: '>=6.9.0'} '@babel/helper-compilation-targets@7.29.7': @@ -166,8 +166,8 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} - '@babel/parser@7.29.7': - resolution: {integrity: sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==} + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} engines: {node: '>=6.0.0'} hasBin: true @@ -179,12 +179,12 @@ packages: resolution: {integrity: sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.29.7': - resolution: {integrity: sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==} + '@babel/traverse@7.29.8': + resolution: {integrity: sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg==} engines: {node: '>=6.9.0'} - '@babel/types@7.29.7': - resolution: {integrity: sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==} + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} '@base-ui/react@1.6.0': @@ -543,11 +543,12 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} - '@napi-rs/wasm-runtime@1.1.6': - resolution: {integrity: sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==} + '@napi-rs/wasm-runtime@1.2.2': + resolution: {integrity: sha512-JfB4kuJQjaoHuCTseIINHtHWeJnvgEcxjwA5t/Y00ZgaOO1Crz3fjT/p8kT28zA/Caz7oiUMn3d6H2yOVCVwuw==} + engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} peerDependencies: - '@emnapi/core': ^1.7.1 - '@emnapi/runtime': ^1.7.1 + '@emnapi/core': ^1.7.1 || ^2.0.0-alpha.3 + '@emnapi/runtime': ^1.7.1 || ^2.0.0-alpha.3 '@next/env@16.2.12': resolution: {integrity: sha512-d0Z5Bc13Fa4nR8pFAKx2jay2yhJM16vlfHbTzYnUQAxlNb6B6lmn4hjt69lYNt4kRtyYP6gEM49lPRHNbIyneg==} @@ -626,8 +627,8 @@ packages: '@oxc-project/types@0.139.0': resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} - '@playwright/test@1.62.0': - resolution: {integrity: sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==} + '@playwright/test@1.62.1': + resolution: {integrity: sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==} engines: {node: '>=20'} hasBin: true @@ -1012,13 +1013,13 @@ packages: '@types/node@26.1.2': resolution: {integrity: sha512-Vu4a5UFA9rIIFJ7rB/Vaafh9lrCQszopTCx6KjFboXTGQbPNasehVR5TEiithSDGyd1DEiUByggTZsg8jukeIg==} - '@types/react-dom@19.2.3': - resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} + '@types/react-dom@19.2.4': + resolution: {integrity: sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==} peerDependencies: '@types/react': ^19.2.0 - '@types/react@19.2.17': - resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==} + '@types/react@19.2.18': + resolution: {integrity: sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==} '@typescript-eslint/eslint-plugin@8.65.0': resolution: {integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==} @@ -1242,8 +1243,8 @@ packages: peerDependencies: acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} + acorn@8.18.0: + resolution: {integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==} engines: {node: '>=0.4.0'} hasBin: true @@ -1300,8 +1301,8 @@ packages: ast-types-flow@0.0.8: resolution: {integrity: sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==} - ast-v8-to-istanbul@1.0.4: - resolution: {integrity: sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==} + ast-v8-to-istanbul@1.0.5: + resolution: {integrity: sha512-UPAgKJFSEGMWSDr3LX4tqnAb4f7KGT8O40Tyx8wbYmmZ/yn58lNCm8h3svs3eXgiGd5AXxz8NDOvXWvicq+rJA==} async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} @@ -1323,8 +1324,8 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} - baseline-browser-mapping@2.11.5: - resolution: {integrity: sha512-xJo6a6YZnwZfnyGmQKWMbVOcii7XRibjOskRh+WJ9UHQoX16xrQrcIgAMQOzfvs8XiLMx6ih/fsLPF73iY2D1A==} + baseline-browser-mapping@2.11.11: + resolution: {integrity: sha512-/yImnXwyTvgMkhgekLHok/Rx5vO6E0BmStWlSqKWMVm2a2ITuZ1Tn+9bgLS+gZRdZmWtd8nxuhHpdmCUOWsTQQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -1468,14 +1469,14 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - electron-to-chromium@1.5.396: - resolution: {integrity: sha512-yHiw2Y3C3H9U6TMbOfoWK/BPreiOPXRfTWPBwQBoZG6/8TB6eOPnsy5oaRYuatR7Fw2SJ4kKforgufeo7fq0EQ==} + electron-to-chromium@1.5.399: + resolution: {integrity: sha512-lEcqhErbHjXRvd41rnWLpzbyU/IXfIYo7QwaFWmxGeLiLyY2TBCdHnWY88vB+p3ubnihRypDm66panXl7TylLA==} emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} - enhanced-resolve@5.24.3: - resolution: {integrity: sha512-PwKooW9JUzh5chmYfHM3IQl5OkK2u2Nm011MgeZrss3JmFraUx/fqrf78kk8GUMYoibx/14MdwTl/1WKkG7TpQ==} + enhanced-resolve@5.24.5: + resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==} engines: {node: '>=10.13.0'} entities@8.0.0: @@ -1502,8 +1503,8 @@ packages: resolution: {integrity: sha512-c/A0P0oxkACDc+cKWw8evLXK83oBKgn0qPOqCYT4x9uolpCIJAcYvJC9QYKNDRPsTeGyCrQ326jrvgZWdCdK5Q==} engines: {node: '>= 0.4'} - es-module-lexer@2.3.0: - resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} + es-module-lexer@2.3.1: + resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} es-object-atoms@1.1.2: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} @@ -1701,8 +1702,8 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} - flatted@3.4.3: - resolution: {integrity: sha512-/zipXxyO6rGvuNGDiULY9MvEGSkb2gaG4GGH4ygMi0ZZzyMHdUZBmntJmx5x1G2VuPytCwGN4xsJP6cw+sK+vQ==} + flatted@3.4.4: + resolution: {integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==} for-each@0.3.5: resolution: {integrity: sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==} @@ -1748,8 +1749,8 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} - get-tsconfig@4.14.0: - resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==} + get-tsconfig@4.14.1: + resolution: {integrity: sha512-Dz/6HxkrxgNehhxLVeyv8sad9UzF2xBVeaKBQNDfJ5XiSXmp2gTR0eO0RWiT2NCKS5aGP9jjkOMggTN90qU50A==} glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} @@ -1980,12 +1981,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@4.3.0: - resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} + js-yaml@4.3.1: + resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==} hasBin: true - jsdom@30.0.0: - resolution: {integrity: sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==} + jsdom@30.0.1: + resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==} engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0} peerDependencies: canvas: ^3.2.3 @@ -2203,16 +2204,16 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} - lucide-react@1.27.0: - resolution: {integrity: sha512-rJicGl/3Fly/E0rOH1YmPZ6e49JCnKknh1ox1vpHnkfjujAkKA6sqUZvH3MTAaXXjgexyUwgNwTJzTtYuAFYJw==} + lucide-react@1.28.0: + resolution: {integrity: sha512-fARAFJULsGuDDydjp6+6blekG/sBIM29TerzLjc9bQUKAcEfrSc4ZQKb25KRz4OMKd87cZTb5dgq0w/T6KufVg==} peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} - magicast@0.5.3: - resolution: {integrity: sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==} + magicast@0.5.4: + resolution: {integrity: sha512-llBEhWm1SacoRwgHUoQJYtwp4PBLF4faQi5TCpIGyGs9n4y5+juI0tDgyKIfpqxckRHaHzouUEph3THklWh03w==} make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -2320,8 +2321,8 @@ packages: resolution: {integrity: sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==} engines: {node: '>= 0.4'} - obug@2.1.3: - resolution: {integrity: sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==} + obug@2.1.4: + resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==} engines: {node: '>=12.20.0'} optionator@0.9.4: @@ -2372,13 +2373,13 @@ packages: resolution: {integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==} engines: {node: '>=12'} - playwright-core@1.62.0: - resolution: {integrity: sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==} + playwright-core@1.62.1: + resolution: {integrity: sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==} engines: {node: '>=20'} hasBin: true - playwright@1.62.0: - resolution: {integrity: sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==} + playwright@1.62.1: + resolution: {integrity: sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==} engines: {node: '>=20'} hasBin: true @@ -2386,7 +2387,7 @@ packages: resolution: {integrity: sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==} engines: {node: '>= 0.4'} - postcss@8.5.18: + postcss@8.5.25: resolution: {integrity: sha512-xdB1oSLHbz1vRWgCDalrCqEFTWzFlhqFC5tIHLMOSUIjhm3XXQ1qrFy8S/ESr1JYRRXqM3c1QFiMZUJdUTqyMQ==} engines: {node: ^10 || ^12 || >=14} @@ -2620,23 +2621,23 @@ packages: tinybench@2.9.0: resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} - tinyexec@1.2.4: - resolution: {integrity: sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==} + tinyexec@1.3.0: + resolution: {integrity: sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==} engines: {node: '>=18'} tinyglobby@0.2.17: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} + tinyrainbow@3.1.1: + resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} - tldts-core@7.4.9: - resolution: {integrity: sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==} + tldts-core@7.4.10: + resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==} - tldts@7.4.9: - resolution: {integrity: sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==} + tldts@7.4.10: + resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==} hasBin: true to-regex-range@5.0.1: @@ -2922,7 +2923,7 @@ snapshots: '@csstools/css-tokenizer': 4.0.0 lru-cache: 11.5.2 - '@asamuzakjp/dom-selector@8.3.0': + '@asamuzakjp/dom-selector@8.3.2': dependencies: bidi-js: 1.0.3 css-tree: 3.2.1 @@ -2940,14 +2941,14 @@ snapshots: '@babel/core@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3 @@ -2957,10 +2958,10 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.29.7': + '@babel/generator@7.29.8': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -2977,8 +2978,8 @@ snapshots: '@babel/helper-module-imports@7.29.7': dependencies: - '@babel/traverse': 7.29.7 - '@babel/types': 7.29.7 + '@babel/traverse': 7.29.8 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -2987,7 +2988,7 @@ snapshots: '@babel/core': 7.29.7 '@babel/helper-module-imports': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@babel/traverse': 7.29.7 + '@babel/traverse': 7.29.8 transitivePeerDependencies: - supports-color @@ -3000,50 +3001,50 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 - '@babel/parser@7.29.7': + '@babel/parser@7.29.8': dependencies: - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/runtime@7.29.7': {} '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 - '@babel/traverse@7.29.7': + '@babel/traverse@7.29.8': dependencies: '@babel/code-frame': 7.29.7 - '@babel/generator': 7.29.7 + '@babel/generator': 7.29.8 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3 transitivePeerDependencies: - supports-color - '@babel/types@7.29.7': + '@babel/types@7.29.8': dependencies: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 - '@base-ui/react@1.6.0(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/react@1.6.0(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 - '@base-ui/utils': 0.3.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@base-ui/utils': 0.3.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/react-dom': 2.1.9(react-dom@19.2.8(react@19.2.8))(react@19.2.8) '@floating-ui/utils': 0.2.12 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@base-ui/utils@0.3.1(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@base-ui/utils@0.3.1(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@babel/runtime': 7.29.7 '@floating-ui/utils': 0.2.12 @@ -3052,7 +3053,7 @@ snapshots: reselect: 5.2.0 use-sync-external-store: 1.6.0(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@bcoe/v8-coverage@1.0.2': {} @@ -3156,7 +3157,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.3.0 + js-yaml: 4.3.1 minimatch: 3.1.5(patch_hash=5f38b9c5382c1163b0389810f5e4e867519096f3c11a6df0a51d7cafbdfa93e2) strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -3332,14 +3333,14 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 '@tybys/wasm-util': 0.10.3 optional: true - '@napi-rs/wasm-runtime@1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': + '@napi-rs/wasm-runtime@1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1)': dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 @@ -3392,142 +3393,142 @@ snapshots: '@oxc-project/types@0.139.0': {} - '@playwright/test@1.62.0': + '@playwright/test@1.62.1': dependencies: - playwright: 1.62.0 + playwright: 1.62.1 '@radix-ui/primitive@1.1.7': {} - '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-collection@1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-compose-refs@1.1.5(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-context@1.2.2(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-context@1.2.2(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-direction@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-direction@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-id@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-id@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-presence@1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-primitive@2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: - '@radix-ui/react-slot': 1.3.3(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-slot': 1.3.3(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-roving-focus@1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-collection': 1.1.15(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-callback-ref': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-is-hydrated': 0.1.3(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-slot@1.3.3(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-slot@1.3.3(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-compose-refs': 1.1.5(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + '@radix-ui/react-tabs@1.1.21(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-context': 1.2.2(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-direction': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-id': 1.1.4(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) - '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-context': 1.2.2(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-direction': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-id': 1.1.4(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-presence': 1.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-primitive': 2.1.10(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-roving-focus': 1.1.19(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@radix-ui/react-use-controllable-state': 1.2.6(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 react-dom: 19.2.8(react@19.2.8) optionalDependencies: - '@types/react': 19.2.17 - '@types/react-dom': 19.2.3(@types/react@19.2.17) + '@types/react': 19.2.18 + '@types/react-dom': 19.2.4(@types/react@19.2.18) - '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-callback-ref@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-controllable-state@1.2.6(@types/react@19.2.18)(react@19.2.8)': dependencies: '@radix-ui/primitive': 1.1.7 - '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.17)(react@19.2.8) - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-effect-event': 0.0.5(@types/react@19.2.18)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-effect-event@0.0.5(@types/react@19.2.18)(react@19.2.8)': dependencies: - '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.17)(react@19.2.8) + '@radix-ui/react-use-layout-effect': 1.1.4(@types/react@19.2.18)(react@19.2.8) react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-is-hydrated@0.1.3(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.17)(react@19.2.8)': + '@radix-ui/react-use-layout-effect@1.1.4(@types/react@19.2.18)(react@19.2.8)': dependencies: react: 19.2.8 optionalDependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 '@rolldown/binding-android-arm64@1.1.5': optional: true @@ -3569,7 +3570,7 @@ snapshots: dependencies: '@emnapi/core': 1.11.1 '@emnapi/runtime': 1.11.1 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.11.1)(@emnapi/runtime@1.11.1) optional: true '@rolldown/binding-win32-arm64-msvc@1.1.5': @@ -3591,7 +3592,7 @@ snapshots: '@tailwindcss/node@4.3.3': dependencies: '@jridgewell/remapping': 2.3.5 - enhanced-resolve: 5.24.3 + enhanced-resolve: 5.24.5 jiti: 2.7.0 lightningcss: 1.32.0 magic-string: 0.30.21 @@ -3654,7 +3655,7 @@ snapshots: '@alloc/quick-lru': 5.2.0 '@tailwindcss/node': 4.3.3 '@tailwindcss/oxide': 4.3.3 - postcss: 8.5.18 + postcss: 8.5.25 tailwindcss: 4.3.3 '@tybys/wasm-util@0.10.3': @@ -3681,11 +3682,11 @@ snapshots: dependencies: undici-types: 8.3.0 - '@types/react-dom@19.2.3(@types/react@19.2.17)': + '@types/react-dom@19.2.4(@types/react@19.2.18)': dependencies: - '@types/react': 19.2.17 + '@types/react': 19.2.18 - '@types/react@19.2.17': + '@types/react@19.2.18': dependencies: csstype: 3.2.3 @@ -3838,7 +3839,7 @@ snapshots: dependencies: '@emnapi/core': 1.10.0 '@emnapi/runtime': 1.10.0 - '@napi-rs/wasm-runtime': 1.1.6(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + '@napi-rs/wasm-runtime': 1.2.2(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true '@unrs/resolver-binding-win32-arm64-msvc@1.12.2': @@ -3854,15 +3855,15 @@ snapshots: dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.10 - ast-v8-to-istanbul: 1.0.4 + ast-v8-to-istanbul: 1.0.5 istanbul-lib-coverage: 3.2.2 istanbul-lib-report: 3.0.1 istanbul-reports: 3.2.0 - magicast: 0.5.3 - obug: 2.1.3 + magicast: 0.5.4 + obug: 2.1.4 std-env: 4.2.0 - tinyrainbow: 3.1.0 - vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)) + tinyrainbow: 3.1.1 + vitest: 4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)) '@vitest/expect@4.1.10': dependencies: @@ -3871,7 +3872,7 @@ snapshots: '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 chai: 6.2.2 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 '@vitest/mocker@4.1.10(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0))': dependencies: @@ -3883,7 +3884,7 @@ snapshots: '@vitest/pretty-format@4.1.10': dependencies: - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 '@vitest/runner@4.1.10': dependencies: @@ -3903,13 +3904,13 @@ snapshots: dependencies: '@vitest/pretty-format': 4.1.10 convert-source-map: 2.0.0 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.18.0): dependencies: - acorn: 8.17.0 + acorn: 8.18.0 - acorn@8.17.0: {} + acorn@8.18.0: {} ajv@6.15.0: dependencies: @@ -3997,7 +3998,7 @@ snapshots: ast-types-flow@0.0.8: {} - ast-v8-to-istanbul@1.0.4: + ast-v8-to-istanbul@1.0.5: dependencies: '@jridgewell/trace-mapping': 0.3.31 estree-walker: 3.0.3 @@ -4015,7 +4016,7 @@ snapshots: balanced-match@4.0.4: {} - baseline-browser-mapping@2.11.5: {} + baseline-browser-mapping@2.11.11: {} bidi-js@1.0.3: dependencies: @@ -4031,9 +4032,9 @@ snapshots: browserslist@4.28.7: dependencies: - baseline-browser-mapping: 2.11.5 + baseline-browser-mapping: 2.11.11 caniuse-lite: 1.0.30001806 - electron-to-chromium: 1.5.396 + electron-to-chromium: 1.5.399 node-releases: 2.0.51 update-browserslist-db: 1.2.3(browserslist@4.28.7) @@ -4159,11 +4160,11 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - electron-to-chromium@1.5.396: {} + electron-to-chromium@1.5.399: {} emoji-regex@9.2.2: {} - enhanced-resolve@5.24.3: + enhanced-resolve@5.24.5: dependencies: graceful-fs: 4.2.11 tapable: 2.3.3 @@ -4257,7 +4258,7 @@ snapshots: iterator.prototype: 1.1.5 math-intrinsics: 1.1.0 - es-module-lexer@2.3.0: {} + es-module-lexer@2.3.1: {} es-object-atoms@1.1.2: dependencies: @@ -4320,7 +4321,7 @@ snapshots: '@nolyfill/is-core-module': 1.0.39 debug: 4.4.3 eslint: 9.39.5(jiti@2.7.0) - get-tsconfig: 4.14.0 + get-tsconfig: 4.14.1 is-bun-module: 2.0.0 stable-hash: 0.0.5 tinyglobby: 0.2.17 @@ -4392,7 +4393,7 @@ snapshots: eslint-plugin-react-hooks@7.1.1(eslint@9.39.5(jiti@2.7.0)): dependencies: '@babel/core': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 eslint: 9.39.5(jiti@2.7.0) hermes-parser: 0.25.1 zod: 4.4.3 @@ -4476,8 +4477,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) eslint-visitor-keys: 4.2.1 esquery@1.7.0: @@ -4539,10 +4540,10 @@ snapshots: flat-cache@4.0.1: dependencies: - flatted: 3.4.3 + flatted: 3.4.4 keyv: 4.5.4 - flatted@3.4.3: {} + flatted@3.4.4: {} for-each@0.3.5: dependencies: @@ -4598,7 +4599,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 - get-tsconfig@4.14.0: + get-tsconfig@4.14.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -4826,14 +4827,14 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@4.3.0: + js-yaml@4.3.1: dependencies: argparse: 2.0.1 - jsdom@30.0.0: + jsdom@30.0.1: dependencies: '@asamuzakjp/css-color': 6.0.5 - '@asamuzakjp/dom-selector': 8.3.0 + '@asamuzakjp/dom-selector': 8.3.2 '@bramus/specificity': 2.4.2 '@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1) '@exodus/bytes': 1.15.1 @@ -5008,7 +5009,7 @@ snapshots: dependencies: yallist: 3.1.1 - lucide-react@1.27.0(react@19.2.8): + lucide-react@1.28.0(react@19.2.8): dependencies: react: 19.2.8 @@ -5016,10 +5017,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 - magicast@0.5.3: + magicast@0.5.4: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-dir@4.0.0: @@ -5055,13 +5056,13 @@ snapshots: natural-compare@1.4.0: {} - next@16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.0)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + next@16.2.12(@babel/core@7.29.7)(@playwright/test@1.62.1)(react-dom@19.2.8(react@19.2.8))(react@19.2.8): dependencies: '@next/env': 16.2.12 '@swc/helpers': 0.5.15 - baseline-browser-mapping: 2.11.5 + baseline-browser-mapping: 2.11.11 caniuse-lite: 1.0.30001806 - postcss: 8.5.18 + postcss: 8.5.25 react: 19.2.8 react-dom: 19.2.8(react@19.2.8) styled-jsx: 5.1.6(@babel/core@7.29.7)(react@19.2.8) @@ -5074,7 +5075,7 @@ snapshots: '@next/swc-linux-x64-musl': 16.2.12 '@next/swc-win32-arm64-msvc': 16.2.12 '@next/swc-win32-x64-msvc': 16.2.12 - '@playwright/test': 1.62.0 + '@playwright/test': 1.62.1 sharp: 0.35.0 transitivePeerDependencies: - '@babel/core' @@ -5131,7 +5132,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.1.2 - obug@2.1.3: {} + obug@2.1.4: {} optionator@0.9.4: dependencies: @@ -5179,17 +5180,17 @@ snapshots: picomatch@4.0.5: {} - playwright-core@1.62.0: {} + playwright-core@1.62.1: {} - playwright@1.62.0: + playwright@1.62.1: dependencies: - playwright-core: 1.62.0 + playwright-core: 1.62.1 optionalDependencies: fsevents: 2.3.2 possible-typed-array-names@1.1.0: {} - postcss@8.5.18: + postcss@8.5.25: dependencies: nanoid: 3.3.16 picocolors: 1.1.1 @@ -5498,20 +5499,20 @@ snapshots: tinybench@2.9.0: {} - tinyexec@1.2.4: {} + tinyexec@1.3.0: {} tinyglobby@0.2.17: dependencies: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 - tinyrainbow@3.1.0: {} + tinyrainbow@3.1.1: {} - tldts-core@7.4.9: {} + tldts-core@7.4.10: {} - tldts@7.4.9: + tldts@7.4.10: dependencies: - tldts-core: 7.4.9 + tldts-core: 7.4.10 to-regex-range@5.0.1: dependencies: @@ -5519,7 +5520,7 @@ snapshots: tough-cookie@6.0.2: dependencies: - tldts: 7.4.9 + tldts: 7.4.10 tr46@6.0.0: dependencies: @@ -5667,7 +5668,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.18 + postcss: 8.5.25 rolldown: 1.1.5 tinyglobby: 0.2.17 optionalDependencies: @@ -5675,7 +5676,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.7.0 - vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.0)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)): + vitest@4.1.10(@types/node@26.1.2)(@vitest/coverage-v8@4.1.10)(jsdom@30.0.1)(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)): dependencies: '@vitest/expect': 4.1.10 '@vitest/mocker': 4.1.10(vite@8.1.4(@types/node@26.1.2)(jiti@2.7.0)) @@ -5684,23 +5685,23 @@ snapshots: '@vitest/snapshot': 4.1.10 '@vitest/spy': 4.1.10 '@vitest/utils': 4.1.10 - es-module-lexer: 2.3.0 + es-module-lexer: 2.3.1 expect-type: 1.4.0 magic-string: 0.30.21 - obug: 2.1.3 + obug: 2.1.4 pathe: 2.0.3 picomatch: 4.0.5 std-env: 4.2.0 tinybench: 2.9.0 - tinyexec: 1.2.4 + tinyexec: 1.3.0 tinyglobby: 0.2.17 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vite: 8.1.4(@types/node@26.1.2)(jiti@2.7.0) why-is-node-running: 2.3.0 optionalDependencies: '@types/node': 26.1.2 '@vitest/coverage-v8': 4.1.10(vitest@4.1.10) - jsdom: 30.0.0 + jsdom: 30.0.1 transitivePeerDependencies: - msw diff --git a/frontend/src/app/search/page.test.tsx b/frontend/src/app/search/page.test.tsx index 8374b0953..f89a3c209 100644 --- a/frontend/src/app/search/page.test.tsx +++ b/frontend/src/app/search/page.test.tsx @@ -490,7 +490,7 @@ describe("SearchPage", () => { subject: "Q2 출시 계획 및 우선순위 조정", sender: "김지현 PM", date: "2026-05-11T09:30:00Z", - snippet: "Q2 일정 종합", + snippet: "Q2 일정 요약", thread_id: "thread-q2", reply_count: 3, score: 0.87, @@ -508,7 +508,7 @@ describe("SearchPage", () => { email_id: 7, subject: "Q2 출시 계획 및 우선순위 조정", sender: "김지현 PM", - snippet: "Q2 일정 종합", + snippet: "Q2 일정 요약", }, ], provenance: "OpenAI (gpt-test)", diff --git a/frontend/src/components/ProjectsLayout.accessibility.test.tsx b/frontend/src/components/ProjectsLayout.accessibility.test.tsx deleted file mode 100644 index 9bb58aebe..000000000 --- a/frontend/src/components/ProjectsLayout.accessibility.test.tsx +++ /dev/null @@ -1,134 +0,0 @@ -/* @vitest-environment jsdom */ -import React, { act } from "react"; -import { createRoot, type Root } from "react-dom/client"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -const apiClientMock = vi.hoisted(() => ({ - get: vi.fn(), - post: vi.fn(), - getServerSessionClaims: vi.fn(), -})); - -vi.mock("@/lib/api-client", () => ({ apiClient: apiClientMock })); - -vi.mock("lucide-react", () => ({ - CalendarDays: () => , - CheckCircle2: () => , - Clock: () => , - FileText: () => , - FolderOpen: () => , - GitBranch: () => , - ListChecks: () => , - Network: () => , - Search: () => , - User: () => , -})); - -import { ProjectsLayout } from "./ProjectsLayout"; - -const candidate = { - candidate_uid: "project_candidate:alpha", - project_uid: "project_candidate:alpha", - title: "Project: Alpha Checkout", - status_code: "needs_review", - score: 0.87, - object_count: 1, - requirement_count: 1, - issue_count: 0, - milestone_count: 0, - deliverable_count: 0, - participant_count: 0, - source_segment_count: 1, - representative_object_uids: [], - citation_bundle: [], - updated_at: "2026-08-03T00:00:00Z", -}; - -async function flushAsyncWork() { - await act(async () => { - await Promise.resolve(); - await Promise.resolve(); - }); -} - -describe("ProjectsLayout accessibility", () => { - let root: Root | null = null; - let container: HTMLDivElement | null = null; - - afterEach(() => { - if (root) act(() => root?.unmount()); - root = null; - container?.remove(); - container = null; - vi.clearAllMocks(); - }); - - it("announces candidate confirmation as busy while the request is pending", async () => { - let resolveConfirmation: ((value: typeof candidate) => void) | undefined; - const pendingConfirmation = new Promise((resolve) => { - resolveConfirmation = resolve; - }); - - apiClientMock.get.mockImplementation((path: string) => { - if (path === "/api/webdav/folders") return Promise.resolve([]); - if (path === "/api/tasks") return Promise.resolve([]); - if (path === "/api/projects/candidates") { - return Promise.resolve({ candidates: [candidate] }); - } - if (path === "/api/projects/project_candidate%3Aalpha/traceability") { - return Promise.resolve({ - project_uid: candidate.project_uid, - candidate, - objects: [], - edges: [], - }); - } - return Promise.reject(new Error(`Unexpected GET path: ${path}`)); - }); - apiClientMock.getServerSessionClaims.mockResolvedValue({ - userId: "alice", - organizationId: "org-acme", - workspaceId: "workspace-org-acme", - }); - apiClientMock.post.mockReturnValue(pendingConfirmation); - - container = document.createElement("div"); - document.body.appendChild(container); - root = createRoot(container); - - await act(async () => { - root?.render(); - }); - await flushAsyncWork(); - await flushAsyncWork(); - - const confirmButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent?.includes("프로젝트 후보 확정"), - ); - expect(confirmButton).toBeDefined(); - expect(confirmButton?.disabled).toBe(false); - expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); - - await act(async () => { - confirmButton?.click(); - await Promise.resolve(); - }); - - expect(confirmButton?.disabled).toBe(true); - expect(confirmButton?.getAttribute("aria-busy")).toBe("true"); - expect(confirmButton?.textContent).toContain("확정 저장 중"); - expect(apiClientMock.post).toHaveBeenCalledWith( - "/api/projects/candidates/project_candidate%3Aalpha/confirm", - {}, - ); - - await act(async () => { - resolveConfirmation?.({ ...candidate, status_code: "confirmed" }); - await pendingConfirmation; - }); - - expect(confirmButton?.disabled).toBe(true); - expect(confirmButton?.getAttribute("aria-busy")).toBe("false"); - expect(confirmButton?.textContent).toContain("프로젝트 후보 확정됨"); - }); -}); \ No newline at end of file diff --git a/frontend/src/components/ProjectsLayout.tsx b/frontend/src/components/ProjectsLayout.tsx index 2f750ffd9..20f1f72ee 100644 --- a/frontend/src/components/ProjectsLayout.tsx +++ b/frontend/src/components/ProjectsLayout.tsx @@ -681,7 +681,6 @@ export function ProjectsLayout() { type="button" onClick={handleConfirmCandidate} disabled={confirmSubmitting || candidateConfirmed} - aria-busy={confirmSubmitting} className="rounded-md bg-primary px-3 py-1.5 text-primary-foreground hover:bg-primary/90 disabled:cursor-not-allowed disabled:bg-secondary disabled:text-muted-foreground" > {candidateConfirmed ? '프로젝트 후보 확정됨' : confirmSubmitting ? '확정 저장 중' : '프로젝트 후보 확정'} diff --git a/frontend/src/components/SourceDrawer.tsx b/frontend/src/components/SourceDrawer.tsx index c743d48b6..16ce05ed0 100644 --- a/frontend/src/components/SourceDrawer.tsx +++ b/frontend/src/components/SourceDrawer.tsx @@ -154,7 +154,7 @@ export function SourceDrawer({
-

증거 종합

+

증거 요약

{summary}

diff --git a/frontend/src/components/calendar/CalendarMonthView.tsx b/frontend/src/components/calendar/CalendarMonthView.tsx index 6100cf9a5..ae8b731d6 100644 --- a/frontend/src/components/calendar/CalendarMonthView.tsx +++ b/frontend/src/components/calendar/CalendarMonthView.tsx @@ -20,24 +20,6 @@ export function CalendarMonthView({ visibleMonthEvents }: Props) { return grouped; }, [visibleMonthEvents]); - // Memoize the React element array so unrelated parent renders can reuse - // the 35-cell grid while monthEventsByDay is unchanged. - const gridCells = useMemo(() => { - return Array.from({ length: 35 }).map((_, i) => { - const dayEvents = monthEventsByDay.get(i) ?? []; - return ( -
- {i < 31 ? i + 1 : ''} - {dayEvents.map((event) => ( -
- {event.time} {event.title} -
- ))} -
- ); - }); - }, [monthEventsByDay]); - return (
@@ -45,7 +27,19 @@ export function CalendarMonthView({ visibleMonthEvents }: Props) {
{/* Simulated Grid Cells */} - {gridCells} + {Array.from({ length: 35 }).map((_, i) => { + const dayEvents = monthEventsByDay.get(i) ?? []; + return ( +
+ {i < 31 ? i + 1 : ''} + {dayEvents.map((event) => ( +
+ {event.time} {event.title} +
+ ))} +
+ ); + })}
); diff --git a/frontend/src/components/calendar/CalendarSidebarRight.tsx b/frontend/src/components/calendar/CalendarSidebarRight.tsx index f819eaf06..8bd92da2b 100644 --- a/frontend/src/components/calendar/CalendarSidebarRight.tsx +++ b/frontend/src/components/calendar/CalendarSidebarRight.tsx @@ -40,7 +40,7 @@ export function CalendarSidebarRight({ selectedDetailEvent }: Props) {
@@ -80,9 +80,9 @@ export function CalendarSidebarRight({ selectedDetailEvent }: Props) {
- - - + + +
); diff --git a/screenshot.mjs b/screenshot.mjs deleted file mode 100644 index 74fe0aea1..000000000 --- a/screenshot.mjs +++ /dev/null @@ -1,12 +0,0 @@ -import { chromium } from '@playwright/test'; - -(async () => { - const browser = await chromium.launch(); - const page = await browser.newPage({ viewport: { width: 1280, height: 1024 } }); - await page.goto('http://localhost:3000'); - // Wait for network idle or a specific element - await page.waitForTimeout(2000); - await page.screenshot({ path: 'frontend/test-results/manual-screenshot.png', fullPage: true }); - await browser.close(); - console.log('Screenshot saved to frontend/test-results/manual-screenshot.png'); -})(); diff --git a/scripts/ci/pr_governance_gate.sh b/scripts/ci/pr_governance_gate.sh index a66142ca8..fa19678a9 100644 --- a/scripts/ci/pr_governance_gate.sh +++ b/scripts/ci/pr_governance_gate.sh @@ -282,26 +282,8 @@ THREADS_JSON="$(gh api graphql \ -F owner="$OWNER" \ -F repo="$REPO" \ -F number="$PR_NUMBER" \ - -f query='query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { headRefOid mergeStateStatus reviewThreads(first:100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated comments(first:100) { pageInfo { hasNextPage } nodes { databaseId } } } } } } }')" -THREAD_METADATA_TRUNCATED="$(printf '%s' "$THREADS_JSON" | jq ' - (.data.repository.pullRequest.reviewThreads.pageInfo.hasNextPage // false) - or any( - .data.repository.pullRequest.reviewThreads.nodes[]?; - (.comments.pageInfo.hasNextPage // false) - )' -)" -if [ "$THREAD_METADATA_TRUNCATED" = "true" ]; then - add_blocker 'Review thread metadata was truncated; current resolution state could not be proven.' -fi + -f query='query($owner:String!, $repo:String!, $number:Int!) { repository(owner:$owner, name:$repo) { pullRequest(number:$number) { headRefOid mergeStateStatus reviewThreads(first:100) { nodes { id isResolved isOutdated } } } } }')" UNRESOLVED_THREADS="$(printf '%s' "$THREADS_JSON" | jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and .isOutdated == false)] | length')" -UNRESOLVED_REVIEW_COMMENT_IDS_JSON="$(printf '%s' "$THREADS_JSON" | jq ' - [.data.repository.pullRequest.reviewThreads.nodes[] - | select(.isResolved == false and .isOutdated == false) - | .comments.nodes[]? - | .databaseId - | select(. != null)] - | unique' -)" if [ "$UNRESOLVED_THREADS" != "0" ]; then add_blocker "${UNRESOLVED_THREADS} unresolved current review thread(s) remain." fi @@ -460,14 +442,10 @@ if ! REVIEW_COMMENTS_JSON="$(gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls printf '%s\n' "$(<"$REVIEW_COMMENTS_ERROR_FILE")" | sed 's/^/ /' add_blocker 'PR review comments could not be read; see the workflow run log.' else - CODERABBIT_REVIEW_BLOCKERS="$(printf '%s' "$REVIEW_COMMENTS_JSON" | jq -s \ - --arg head_sha "$HEAD_SHA" \ - --arg pattern "$CODERABBIT_BLOCKING_PATTERN" \ - --argjson unresolved_comment_ids "$UNRESOLVED_REVIEW_COMMENT_IDS_JSON" ' + CODERABBIT_REVIEW_BLOCKERS="$(printf '%s' "$REVIEW_COMMENTS_JSON" | jq -s --arg head_sha "$HEAD_SHA" --arg pattern "$CODERABBIT_BLOCKING_PATTERN" ' [.[][] | select((.user.login // "") | test("'"$REVIEW_BOT_LOGIN_PATTERN"'"; "i")) | select((.body // "") | test($pattern; "i")) - | select(.id as $comment_id | ($unresolved_comment_ids | index($comment_id)) != null) | select(((.commit_id // "") == $head_sha) or ((.original_commit_id // "") == $head_sha) or ((.body // "") | contains($head_sha)))] | length' )" diff --git a/scripts/ci/test_pr_governance_gate.sh b/scripts/ci/test_pr_governance_gate.sh index 8fb42aa9b..ce15243b3 100644 --- a/scripts/ci/test_pr_governance_gate.sh +++ b/scripts/ci/test_pr_governance_gate.sh @@ -76,22 +76,6 @@ if [ "$1" = "api" ] && [ "$2" = "graphql" ]; then printf 'GraphQL request failed\n' >&2 exit 1 fi - if [ "${GH_SCENARIO:-pass}" = "coderabbit_resolved_current_review_comment" ]; then - printf '{"data":{"repository":{"pullRequest":{"headRefOid":"%s","mergeStateStatus":"CLEAN","reviewThreads":{"nodes":[{"id":"thread-888","isResolved":true,"isOutdated":false,"comments":{"nodes":[{"databaseId":888}]}}]}}}}}' "$head_sha" - exit 0 - fi - if [ "${GH_SCENARIO:-pass}" = "coderabbit_current_review_comment" ] || [ "${GH_SCENARIO:-pass}" = "github_code_quality_current_review_comment" ]; then - printf '{"data":{"repository":{"pullRequest":{"headRefOid":"%s","mergeStateStatus":"CLEAN","reviewThreads":{"nodes":[{"id":"thread-888","isResolved":false,"isOutdated":false,"comments":{"nodes":[{"databaseId":888}]}}]}}}}}' "$head_sha" - exit 0 - fi - if [ "${GH_SCENARIO:-pass}" = "review_threads_truncated" ]; then - printf '{"data":{"repository":{"pullRequest":{"headRefOid":"%s","mergeStateStatus":"CLEAN","reviewThreads":{"pageInfo":{"hasNextPage":true},"nodes":[]}}}}}' "$head_sha" - exit 0 - fi - if [ "${GH_SCENARIO:-pass}" = "review_thread_comments_truncated" ]; then - printf '{"data":{"repository":{"pullRequest":{"headRefOid":"%s","mergeStateStatus":"CLEAN","reviewThreads":{"pageInfo":{"hasNextPage":false},"nodes":[{"id":"thread-888","isResolved":false,"isOutdated":false,"comments":{"pageInfo":{"hasNextPage":true},"nodes":[{"databaseId":888}]}}]}}}}}' "$head_sha" - exit 0 - fi if [ "${GH_SCENARIO:-pass}" = "persistent_unknown" ]; then printf '{"data":{"repository":{"pullRequest":{"headRefOid":"%s","mergeStateStatus":"UNKNOWN","reviewThreads":{"nodes":[]}}}}}' "$head_sha" exit 0 @@ -264,7 +248,7 @@ fi if [ "$1" = "api" ] && [[ "$args" == *repos/*/pulls/42/comments* ]]; then case "${GH_SCENARIO:-pass}" in - coderabbit_current_review_comment|coderabbit_resolved_current_review_comment) + coderabbit_current_review_comment) printf '[{"id":888,"user":{"login":"coderabbitai[bot]"},"commit_id":"0123456789abcdef0123456789abcdef01234567","original_commit_id":"old","created_at":"2026-05-19T00:01:00Z","body":"Potential issue on current head"}]' ;; github_code_quality_current_review_comment) @@ -773,40 +757,6 @@ assert_coderabbit_current_review_comment_blocks() { assert_not_in_file '^pr merge' "$temp_dir/gh.log" } -assert_coderabbit_resolved_current_review_comment_does_not_block() { - local temp_dir - temp_dir="$(mktemp -d)" - run_gate coderabbit_resolved_current_review_comment "$temp_dir" - - assert_exit_code 0 "$temp_dir" - assert_in_file 'PR governance metadata gate is ready' "$temp_dir/output.txt" - assert_not_in_file 'Current-head CodeRabbit review comment' "$temp_dir/output.txt" - assert_not_in_file 'Current-head CodeRabbit review comment' "$temp_dir/gh.log" - assert_not_in_file '^pr merge' "$temp_dir/gh.log" -} - -assert_truncated_review_thread_metadata_blocks() { - local temp_dir - temp_dir="$(mktemp -d)" - run_gate review_threads_truncated "$temp_dir" - - assert_exit_code 0 "$temp_dir" - assert_in_file 'Review thread metadata was truncated; current resolution state could not be proven.' "$temp_dir/gh.log" - assert_in_file '' "$temp_dir/gh.log" - assert_not_in_file '^pr merge' "$temp_dir/gh.log" -} - -assert_truncated_review_thread_comments_metadata_blocks() { - local temp_dir - temp_dir="$(mktemp -d)" - run_gate review_thread_comments_truncated "$temp_dir" - - assert_exit_code 0 "$temp_dir" - assert_in_file 'Review thread metadata was truncated; current resolution state could not be proven.' "$temp_dir/gh.log" - assert_in_file '' "$temp_dir/gh.log" - assert_not_in_file '^pr merge' "$temp_dir/gh.log" -} - assert_github_code_quality_current_review_comment_blocks() { local temp_dir temp_dir="$(mktemp -d)" @@ -950,9 +900,6 @@ assert_coderabbit_review_limit_issue_comment_does_not_block assert_coderabbit_no_actionable_summary_does_not_block assert_coderabbit_no_actionable_summary_with_blocker_still_blocks assert_coderabbit_current_review_comment_blocks -assert_coderabbit_resolved_current_review_comment_does_not_block -assert_truncated_review_thread_metadata_blocks -assert_truncated_review_thread_comments_metadata_blocks assert_github_code_quality_current_review_comment_blocks assert_coderabbit_stale_review_comment_does_not_block assert_changes_requested_creates_marker_comment