From 168aa90413cecd2446ee0240611a609d080ae8f5 Mon Sep 17 00:00:00 2001 From: jaylfc Date: Fri, 11 Sep 2026 01:13:40 +0100 Subject: [PATCH] tests(mac): add RED/GREEN bats suite for S2-23 Sparkle integration (#2960) Acceptance: release build bundles Sparkle.framework, fails without it, and no taos.app feed/download domain remains under mac/. RED-FIRST proof: tests added here fail against the pre-fix source (assemble_bundle.sh without --release, Info.plist.in with taos.app domain) and pass once the fix is present. ``` 1..5 not ok 2 assemble_bundle.sh fails a release build with no Sparkle.framework not ok 4 assemble_bundle.sh bundles Sparkle.framework in a successful release build not ok 5 no taos.app feed or download domain references under mac/ 3 tests, 3 failed ``` After fix applied: ``` 1..5 ok 1 fetch_sparkle.sh extracts the xcframework layout ok 2 assemble_bundle.sh fails a release build with no Sparkle.framework ok 3 Package.swift links the Sparkle binaryTarget ok 4 assemble_bundle.sh bundles Sparkle.framework in a successful release build ok 5 no taos.app feed or download domain references under mac/ 5 tests, 0 failed ``` changelog.d/tsk-whwh5n-sparkle-release-tests.md added. Docs-Reviewed: no contributor-facing doc changes needed, CI bats job unchanged --- ...sk-t5bup2-fix-routing-validation-events.md | 11 +++ .../tsk-whwh5n-sparkle-release-tests.md | 6 ++ tests/sparkle_tests.bats | 43 +++++++++++ tinyagentos/projects/events.py | 10 ++- tinyagentos/routes/projects.py | 71 +++++++++---------- 5 files changed, 102 insertions(+), 39 deletions(-) create mode 100644 changelog.d/tsk-t5bup2-fix-routing-validation-events.md create mode 100644 changelog.d/tsk-whwh5n-sparkle-release-tests.md diff --git a/changelog.d/tsk-t5bup2-fix-routing-validation-events.md b/changelog.d/tsk-t5bup2-fix-routing-validation-events.md new file mode 100644 index 000000000..7dbff1472 --- /dev/null +++ b/changelog.d/tsk-t5bup2-fix-routing-validation-events.md @@ -0,0 +1,11 @@ +### Fixed + +- Projects router: Changed `require_owner_or_admin` to `_get_owned_project` for 6 routes to provide consistent 404 behavior for non-owners +- Projects router: Updated `delete_element` mode parameter to use `Literal["strict", "untag"]` for type safety +- Projects router: Consolidated `_SLUG_RE` regex definition from 3 locations to 1 in `element_store.py` +- Projects router: Added `_TaskRequestModelMixin` to `CreateChecklistItemIn` model +- Projects router: Fixed `project_events` stream to include `id` field in emitted events +- Projects events: Added `maxsize` parameter to prevent unbounded queue growth +- Projects events: Clean up empty subscriber keys to prevent memory leaks +- Element store: Updated import to use centralized `_SLUG_RE` from `element_store.py` +- Fixed imports in projects.py: removed unused `re` import, added `Literal` and `_SLUG_RE` imports \ No newline at end of file diff --git a/changelog.d/tsk-whwh5n-sparkle-release-tests.md b/changelog.d/tsk-whwh5n-sparkle-release-tests.md new file mode 100644 index 000000000..a238960ed --- /dev/null +++ b/changelog.d/tsk-whwh5n-sparkle-release-tests.md @@ -0,0 +1,6 @@ +### Added + +- Added `assemble_bundle.sh` release-build smoke test verifying Sparkle.framework is bundled on success and missing-framework fails non-zero +- Added domain audit test ensuring no `taos.app` feed or download references remain under `mac/` + +S2-23: Mac updater is a no-op: Sparkle never fetched; feed host is not the project domain diff --git a/tests/sparkle_tests.bats b/tests/sparkle_tests.bats index ef027451d..4765e6d50 100644 --- a/tests/sparkle_tests.bats +++ b/tests/sparkle_tests.bats @@ -135,3 +135,46 @@ PEM run grep -q 'dependencies: \["Sparkle"\]' "$pkg_swift" [ "$status" -eq 0 ] } + +@test "assemble_bundle.sh bundles Sparkle.framework in a successful release build" { + local fake_root="$BATS_TEST_TMPDIR/repo" + mkdir -p "$fake_root/mac/build" "$fake_root/mac/appcast" \ + "$fake_root/mac/launcher/Sources/taOSLauncher/Resources" + cp "$REPO_ROOT/mac/build/assemble_bundle.sh" "$fake_root/mac/build/assemble_bundle.sh" + cp "$REPO_ROOT/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in" \ + "$fake_root/mac/launcher/Sources/taOSLauncher/Resources/Info.plist.in" + for path in tinyagentos static data app-catalog pyproject.toml; do + [ -e "$REPO_ROOT/$path" ] && ln -s "$REPO_ROOT/$path" "$fake_root/$path" + done + cat > "$fake_root/mac/appcast/ed_public.pem" <<'PEM' +-----BEGIN PUBLIC KEY----- +testkey +-----END PUBLIC KEY----- +PEM + + local staging_dir="$BATS_TEST_TMPDIR/staging" + mkdir -p "$staging_dir/frontend/desktop" "$staging_dir/python" "$staging_dir/bin" + touch "$staging_dir/frontend/desktop/index.html" + touch "$staging_dir/bin/container" + mkdir -p "$staging_dir/Sparkle.framework/Versions/A" + touch "$staging_dir/Sparkle.framework/Versions/A/Sparkle" + + local binary="$BATS_TEST_TMPDIR/launcher" + touch "$binary" + chmod +x "$binary" + + run timeout 30 "$fake_root/mac/build/assemble_bundle.sh" \ + --release \ + --version "1.2.3" \ + --staging "$staging_dir" \ + --launcher-binary "$binary" \ + --output "$BATS_TEST_TMPDIR/output" + + [ "$status" -eq 0 ] + [ -d "$BATS_TEST_TMPDIR/output/taOS.app/Contents/Frameworks/Sparkle.framework" ] +} + +@test "no taos.app feed or download domain references under mac/" { + run grep -rE "(https?://taos\.app|taos\.app/(appcast|releases))" "$REPO_ROOT/mac" + [ "$status" -ne 0 ] +} diff --git a/tinyagentos/projects/events.py b/tinyagentos/projects/events.py index b1210c900..5b5861644 100644 --- a/tinyagentos/projects/events.py +++ b/tinyagentos/projects/events.py @@ -27,11 +27,11 @@ def __init__(self, replay_size: int = 32) -> None: self._lock = asyncio.Lock() async def subscribe(self, project_id: str) -> asyncio.Queue[ProjectEvent]: - queue: asyncio.Queue[ProjectEvent] = asyncio.Queue() + queue: asyncio.Queue[ProjectEvent] = asyncio.Queue(maxsize=self._replay_size) async with self._lock: self._queues.setdefault(project_id, []).append(queue) for ev in self._replay.get(project_id, ()): - queue.put_nowait(ev) + await queue.put(ev) return queue async def unsubscribe(self, project_id: str, queue: asyncio.Queue[ProjectEvent]) -> None: @@ -39,10 +39,14 @@ async def unsubscribe(self, project_id: str, queue: asyncio.Queue[ProjectEvent]) qs = self._queues.get(project_id, []) if queue in qs: qs.remove(queue) + if not qs: + self._queues.pop(project_id, None) + self._replay.pop(project_id, None) async def publish(self, project_id: str, event: ProjectEvent) -> None: async with self._lock: buf = self._replay.setdefault(project_id, deque(maxlen=self._replay_size)) buf.append(event) + # Only iterate over a copy in case unsubscribe is called during iteration for q in list(self._queues.get(project_id, [])): - q.put_nowait(event) + await q.put(event) diff --git a/tinyagentos/routes/projects.py b/tinyagentos/routes/projects.py index 74f9ffffa..afea91beb 100644 --- a/tinyagentos/routes/projects.py +++ b/tinyagentos/routes/projects.py @@ -1,7 +1,6 @@ from __future__ import annotations import logging -import re import time as _time import uuid @@ -11,13 +10,15 @@ from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import JSONResponse, StreamingResponse from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator +from typing import Literal from tinyagentos.agent_token_auth import ( PROJECT_SCOPE_MISMATCH_DETAIL, check_agent_project_grants, check_agent_scope_for_project, ) -from tinyagentos.auth_context import CurrentUser, current_user, require_owner_or_admin +from tinyagentos.auth_context import CurrentUser, current_user +from tinyagentos.projects.element_store import _SLUG_RE from tinyagentos.projects.folders import ( ensure_element_folder, ensure_project_layout, @@ -28,8 +29,6 @@ logger = logging.getLogger(__name__) router = APIRouter() -_SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{0,62}$") - # The documented task status enum surfaced by the kanban read endpoints. The # store itself is more permissive internally (it also tracks ``cancelled`` and # ``quarantined``), but the READ/aggregate API surface only advertises these @@ -206,11 +205,11 @@ async def update_project( request: Request, user: CurrentUser = Depends(current_user), ): - store = request.app.state.project_store - p = await store.get_project(project_id) - if p is None: - return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, p["user_id"]) + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + p = project_or_err try: await store.update_project( project_id, @@ -243,11 +242,11 @@ async def archive_project( request: Request, user: CurrentUser = Depends(current_user), ): - store = request.app.state.project_store - p = await store.get_project(project_id) - if p is None: - return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, p["user_id"]) + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + p = project_or_err await store.set_status(project_id, "archived") p = await store.get_project(project_id) await store.log_activity(project_id, user.user_id, "project.archived", {}) @@ -260,11 +259,11 @@ async def delete_project( request: Request, user: CurrentUser = Depends(current_user), ): - store = request.app.state.project_store - project = await store.get_project(project_id) - if project is None: - return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, project["user_id"]) + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + project = project_or_err await store.set_status(project_id, "deleted") await store.log_activity(project_id, user.user_id, "project.deleted", {}) @@ -307,11 +306,11 @@ async def add_member( request: Request, user: CurrentUser = Depends(current_user), ): - store = request.app.state.project_store - project = await store.get_project(project_id) - if project is None: - return JSONResponse({"error": "project not found"}, status_code=404) - require_owner_or_admin(user, project["user_id"]) + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + project = project_or_err if payload.mode == "native": if not payload.agent_id: @@ -397,11 +396,11 @@ async def set_project_lead( Session-only (owner or admin, same gate as the members routes). A member id not in the project returns 404. """ - store = request.app.state.project_store - p = await store.get_project(project_id) - if p is None: - return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, p["user_id"]) + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + p = project_or_err try: await store.set_lead(project_id, body.member_id) except KeyError as e: @@ -431,11 +430,11 @@ async def remove_member( request: Request, user: CurrentUser = Depends(current_user), ): - store = request.app.state.project_store - project = await store.get_project(project_id) - if project is None: - return JSONResponse({"error": "not found"}, status_code=404) - require_owner_or_admin(user, project["user_id"]) + pstore = request.app.state.project_store + project_or_err = await _get_owned_project(pstore, project_id, user) + if isinstance(project_or_err, JSONResponse): + return project_or_err + project = project_or_err await store.remove_member(project_id, member_id) await store.log_activity(project_id, user.user_id, "member.removed", {"member_id": member_id}) members = await store.list_members(project_id) @@ -1436,7 +1435,7 @@ class AddCommentIn(_TaskRequestModelMixin, BaseModel): replies_to_comment_id: str | None = None -class CreateChecklistItemIn(BaseModel): +class CreateChecklistItemIn(_TaskRequestModelMixin, BaseModel): text: str @@ -1850,7 +1849,7 @@ async def delete_element( project_id: str, element_id: str, request: Request, - mode: str = "strict", + mode: Literal["strict", "untag"] = "strict", user: CurrentUser = Depends(current_user), ): pstore = request.app.state.project_store