test_musehub_coord.py
python
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a
feat(intel): standardize headers, gauge icon, velocity card…
Sonnet 4.6
minor
⚠ breaking
143 days ago
| 1 | """Tests for the MuseHub coordination bus. |
| 2 | |
| 3 | Covers all acceptance criteria: |
| 4 | |
| 5 | Unit: |
| 6 | - CoordRecordIn validation (kind, record_uuid, run_id) |
| 7 | - CoordPollRequest validation (since_id, kinds, limit) |
| 8 | - CoordPushResponse and CoordPullResponse structure |
| 9 | |
| 10 | Integration (service layer): |
| 11 | - coord_push: insert, idempotent skip, heartbeat upsert |
| 12 | - coord_pull: cursor, kind filter, limit |
| 13 | - Push then pull round-trip |
| 14 | |
| 15 | E2E (HTTP endpoints via AsyncClient): |
| 16 | - POST /{owner}/{slug}/coord/push — 200 OK, 401 unauth, 403 wrong owner, |
| 17 | 404 unknown repo, 400 bad kind, 400 bad uuid |
| 18 | - POST /{owner}/{slug}/coord/pull — 200 OK, cursor pagination |
| 19 | - GET /{owner}/{slug}/coord/watch — SSE stream response headers |
| 20 | |
| 21 | Security: |
| 22 | - Path traversal in owner/slug blocked by 404 |
| 23 | - record_uuid path traversal rejected by Pydantic (400) |
| 24 | - Unknown kind rejected (400) |
| 25 | - Private repo invisible to wrong user (404) |
| 26 | - Push requires auth (401) |
| 27 | |
| 28 | Stress: |
| 29 | - Push 500 records in one batch |
| 30 | - Pull 1000 records with cursor pagination |
| 31 | - 200 push + pull round-trips with correct cursor tracking |
| 32 | """ |
| 33 | |
| 34 | from __future__ import annotations |
| 35 | |
| 36 | import json |
| 37 | import uuid |
| 38 | from collections.abc import AsyncIterator |
| 39 | from datetime import datetime, timezone |
| 40 | from unittest.mock import patch |
| 41 | |
| 42 | import pytest |
| 43 | import pytest_asyncio |
| 44 | from httpx import AsyncClient |
| 45 | from sqlalchemy.ext.asyncio import AsyncSession |
| 46 | |
| 47 | from musehub.db import coord_models as coord_db |
| 48 | from musehub.db.musehub_models import MusehubIdentity, MusehubRepo |
| 49 | from musehub.types.json_types import JSONObject, StrDict |
| 50 | from musehub.models.coord import ( |
| 51 | CoordPollRequest, |
| 52 | CoordPushRequest, |
| 53 | CoordRecordIn, |
| 54 | _VALID_KINDS, |
| 55 | ) |
| 56 | from musehub.services.musehub_coord import coord_pull, coord_push |
| 57 | |
| 58 | |
| 59 | # ── Fixtures ─────────────────────────────────────────────────────────────────── |
| 60 | |
| 61 | |
| 62 | def _uuid4() -> str: |
| 63 | return str(uuid.uuid4()) |
| 64 | |
| 65 | |
| 66 | def _make_record( |
| 67 | kind: str = "reservation", |
| 68 | record_uuid: str | None = None, |
| 69 | run_id: str = "agent-1", |
| 70 | payload: JSONObject | None = None, |
| 71 | expires_at: datetime | None = None, |
| 72 | ) -> JSONObject: |
| 73 | return { |
| 74 | "kind": kind, |
| 75 | "record_uuid": record_uuid or _uuid4(), |
| 76 | "run_id": run_id, |
| 77 | "payload": payload or {"note": "test"}, |
| 78 | "expires_at": expires_at, |
| 79 | } |
| 80 | |
| 81 | |
| 82 | @pytest_asyncio.fixture |
| 83 | async def repo(db_session: AsyncSession, test_user: MusehubIdentity) -> MusehubRepo: |
| 84 | """Create a private test repo with a unique slug to prevent cross-test conflicts.""" |
| 85 | suffix = _uuid4()[:8] |
| 86 | r = MusehubRepo( |
| 87 | repo_id=_uuid4(), |
| 88 | name="coord-test", |
| 89 | owner="gabriel", |
| 90 | slug=f"coord-test-{suffix}", |
| 91 | visibility="private", |
| 92 | owner_user_id=test_user.identity_id, |
| 93 | ) |
| 94 | db_session.add(r) |
| 95 | await db_session.commit() |
| 96 | await db_session.refresh(r) |
| 97 | return r |
| 98 | |
| 99 | |
| 100 | @pytest_asyncio.fixture |
| 101 | async def public_repo(db_session: AsyncSession, test_user: MusehubIdentity) -> MusehubRepo: |
| 102 | """Create a public test repo with a unique slug to prevent cross-test conflicts.""" |
| 103 | suffix = _uuid4()[:8] |
| 104 | r = MusehubRepo( |
| 105 | repo_id=_uuid4(), |
| 106 | name="coord-public", |
| 107 | owner="gabriel", |
| 108 | slug=f"coord-public-{suffix}", |
| 109 | visibility="public", |
| 110 | owner_user_id=test_user.identity_id, |
| 111 | ) |
| 112 | db_session.add(r) |
| 113 | await db_session.commit() |
| 114 | await db_session.refresh(r) |
| 115 | return r |
| 116 | |
| 117 | |
| 118 | # ── Unit: Pydantic model validation ─────────────────────────────────────────── |
| 119 | |
| 120 | |
| 121 | class TestCoordRecordInValidation: |
| 122 | def test_valid_record(self) -> None: |
| 123 | rec = CoordRecordIn( |
| 124 | kind="reservation", |
| 125 | record_uuid=_uuid4(), |
| 126 | run_id="agent-1", |
| 127 | payload={"x": 1}, |
| 128 | ) |
| 129 | assert rec.kind == "reservation" |
| 130 | |
| 131 | def test_unknown_kind_rejected(self) -> None: |
| 132 | with pytest.raises(Exception, match="kind must be one of"): |
| 133 | CoordRecordIn(kind="unknown_kind", record_uuid=_uuid4(), payload={}) |
| 134 | |
| 135 | def test_all_valid_kinds_accepted(self) -> None: |
| 136 | for kind in _VALID_KINDS: |
| 137 | rec = CoordRecordIn(kind=kind, record_uuid=_uuid4(), payload={}) |
| 138 | assert rec.kind == kind |
| 139 | |
| 140 | def test_non_uuid4_rejected(self) -> None: |
| 141 | with pytest.raises(Exception, match="record_uuid must be a valid UUID4"): |
| 142 | CoordRecordIn(kind="reservation", record_uuid="not-a-uuid", payload={}) |
| 143 | |
| 144 | def test_path_traversal_in_uuid_rejected(self) -> None: |
| 145 | with pytest.raises(Exception): |
| 146 | CoordRecordIn(kind="reservation", record_uuid="../../../etc/passwd", payload={}) |
| 147 | |
| 148 | def test_null_byte_in_uuid_rejected(self) -> None: |
| 149 | with pytest.raises(Exception): |
| 150 | CoordRecordIn(kind="reservation", record_uuid="\x00" + _uuid4()[1:], payload={}) |
| 151 | |
| 152 | def test_run_id_defaults_to_empty(self) -> None: |
| 153 | rec = CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}) |
| 154 | assert rec.run_id == "" |
| 155 | |
| 156 | def test_run_id_max_length(self) -> None: |
| 157 | with pytest.raises(Exception): |
| 158 | CoordRecordIn( |
| 159 | kind="reservation", |
| 160 | record_uuid=_uuid4(), |
| 161 | run_id="x" * 256, |
| 162 | payload={}, |
| 163 | ) |
| 164 | |
| 165 | def test_expires_at_optional(self) -> None: |
| 166 | rec = CoordRecordIn(kind="heartbeat", record_uuid=_uuid4(), payload={}) |
| 167 | assert rec.expires_at is None |
| 168 | |
| 169 | def test_uuid4_normalized_to_lowercase(self) -> None: |
| 170 | upper = _uuid4().upper() |
| 171 | rec = CoordRecordIn(kind="reservation", record_uuid=upper, payload={}) |
| 172 | assert rec.record_uuid == upper.lower() |
| 173 | |
| 174 | |
| 175 | class TestCoordPollRequestValidation: |
| 176 | def test_defaults(self) -> None: |
| 177 | req = CoordPollRequest() |
| 178 | assert req.since_id == 0 |
| 179 | assert req.kinds == [] |
| 180 | assert req.limit == 500 |
| 181 | |
| 182 | def test_since_id_must_be_non_negative(self) -> None: |
| 183 | with pytest.raises(Exception): |
| 184 | CoordPollRequest(since_id=-1) |
| 185 | |
| 186 | def test_limit_bounds(self) -> None: |
| 187 | with pytest.raises(Exception): |
| 188 | CoordPollRequest(limit=0) |
| 189 | with pytest.raises(Exception): |
| 190 | CoordPollRequest(limit=1001) |
| 191 | |
| 192 | def test_unknown_kind_in_filter_rejected(self) -> None: |
| 193 | with pytest.raises(Exception, match="kind must be one of"): |
| 194 | CoordPollRequest(kinds=["bad_kind"]) |
| 195 | |
| 196 | def test_valid_kinds_filter(self) -> None: |
| 197 | req = CoordPollRequest(kinds=["reservation", "heartbeat"]) |
| 198 | assert "reservation" in req.kinds |
| 199 | |
| 200 | |
| 201 | # ── Integration: service layer ───────────────────────────────────────────────── |
| 202 | |
| 203 | |
| 204 | class TestCoordPush: |
| 205 | async def test_push_inserts_records( |
| 206 | self, db_session: AsyncSession, repo: MusehubRepo |
| 207 | ) -> None: |
| 208 | req = CoordPushRequest(records=[ |
| 209 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={"addr": "x"}), |
| 210 | CoordRecordIn(kind="heartbeat", record_uuid=_uuid4(), payload={"ping": 1}), |
| 211 | ]) |
| 212 | resp = await coord_push(db_session, repo.repo_id, req) |
| 213 | assert resp.inserted == 2 |
| 214 | assert resp.skipped == 0 |
| 215 | |
| 216 | async def test_push_same_record_twice_is_skipped( |
| 217 | self, db_session: AsyncSession, repo: MusehubRepo |
| 218 | ) -> None: |
| 219 | uid = _uuid4() |
| 220 | rec = CoordRecordIn(kind="reservation", record_uuid=uid, payload={"x": 1}) |
| 221 | req = CoordPushRequest(records=[rec]) |
| 222 | |
| 223 | resp1 = await coord_push(db_session, repo.repo_id, req) |
| 224 | assert resp1.inserted == 1 |
| 225 | |
| 226 | # Re-push the identical record. |
| 227 | resp2 = await coord_push(db_session, repo.repo_id, req) |
| 228 | assert resp2.inserted == 0 |
| 229 | assert resp2.skipped == 1 |
| 230 | |
| 231 | async def test_heartbeat_upserted( |
| 232 | self, db_session: AsyncSession, repo: MusehubRepo |
| 233 | ) -> None: |
| 234 | uid = _uuid4() |
| 235 | req1 = CoordPushRequest(records=[ |
| 236 | CoordRecordIn(kind="heartbeat", record_uuid=uid, payload={"ts": "t1"}), |
| 237 | ]) |
| 238 | resp1 = await coord_push(db_session, repo.repo_id, req1) |
| 239 | assert resp1.inserted == 1 |
| 240 | |
| 241 | req2 = CoordPushRequest(records=[ |
| 242 | CoordRecordIn(kind="heartbeat", record_uuid=uid, payload={"ts": "t2"}), |
| 243 | ]) |
| 244 | resp2 = await coord_push(db_session, repo.repo_id, req2) |
| 245 | # Heartbeat upsert counts as skipped (same row, payload updated). |
| 246 | assert resp2.skipped == 1 |
| 247 | assert resp2.inserted == 0 |
| 248 | |
| 249 | async def test_push_mixed_batch( |
| 250 | self, db_session: AsyncSession, repo: MusehubRepo |
| 251 | ) -> None: |
| 252 | uid_dup = _uuid4() |
| 253 | req = CoordPushRequest(records=[ |
| 254 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}), |
| 255 | CoordRecordIn(kind="intent", record_uuid=_uuid4(), payload={}), |
| 256 | CoordRecordIn(kind="dependency", record_uuid=_uuid4(), payload={}), |
| 257 | ]) |
| 258 | resp = await coord_push(db_session, repo.repo_id, req) |
| 259 | assert resp.inserted == 3 |
| 260 | |
| 261 | async def test_push_does_not_cross_repos( |
| 262 | self, db_session: AsyncSession, repo: MusehubRepo, public_repo: MusehubRepo |
| 263 | ) -> None: |
| 264 | uid = _uuid4() |
| 265 | req = CoordPushRequest(records=[ |
| 266 | CoordRecordIn(kind="reservation", record_uuid=uid, payload={}), |
| 267 | ]) |
| 268 | await coord_push(db_session, repo.repo_id, req) |
| 269 | # Same uuid but different repo_id → should insert, not skip. |
| 270 | resp2 = await coord_push(db_session, public_repo.repo_id, req) |
| 271 | assert resp2.inserted == 1 |
| 272 | |
| 273 | |
| 274 | class TestCoordPull: |
| 275 | async def test_pull_returns_inserted_records( |
| 276 | self, db_session: AsyncSession, repo: MusehubRepo |
| 277 | ) -> None: |
| 278 | uid1, uid2 = _uuid4(), _uuid4() |
| 279 | push_req = CoordPushRequest(records=[ |
| 280 | CoordRecordIn(kind="reservation", record_uuid=uid1, payload={"a": 1}), |
| 281 | CoordRecordIn(kind="heartbeat", record_uuid=uid2, payload={"b": 2}), |
| 282 | ]) |
| 283 | await coord_push(db_session, repo.repo_id, push_req) |
| 284 | |
| 285 | poll_req = CoordPollRequest() |
| 286 | resp = await coord_pull(db_session, repo.repo_id, poll_req) |
| 287 | uuids = {r.record_uuid for r in resp.records} |
| 288 | assert uid1 in uuids |
| 289 | assert uid2 in uuids |
| 290 | |
| 291 | async def test_pull_cursor_advances( |
| 292 | self, db_session: AsyncSession, repo: MusehubRepo |
| 293 | ) -> None: |
| 294 | push_req = CoordPushRequest(records=[ |
| 295 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}), |
| 296 | ]) |
| 297 | await coord_push(db_session, repo.repo_id, push_req) |
| 298 | resp1 = await coord_pull(db_session, repo.repo_id, CoordPollRequest()) |
| 299 | cursor = resp1.cursor |
| 300 | |
| 301 | # Push a second record. |
| 302 | push_req2 = CoordPushRequest(records=[ |
| 303 | CoordRecordIn(kind="intent", record_uuid=_uuid4(), payload={}), |
| 304 | ]) |
| 305 | await coord_push(db_session, repo.repo_id, push_req2) |
| 306 | |
| 307 | # Pull since cursor — should only return the second record. |
| 308 | resp2 = await coord_pull( |
| 309 | db_session, repo.repo_id, CoordPollRequest(since_id=cursor) |
| 310 | ) |
| 311 | assert len(resp2.records) == 1 |
| 312 | assert resp2.records[0].kind == "intent" |
| 313 | |
| 314 | async def test_pull_empty_when_nothing_pushed( |
| 315 | self, db_session: AsyncSession, repo: MusehubRepo |
| 316 | ) -> None: |
| 317 | resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest()) |
| 318 | assert resp.records == [] |
| 319 | assert resp.cursor == 0 |
| 320 | |
| 321 | async def test_pull_kind_filter( |
| 322 | self, db_session: AsyncSession, repo: MusehubRepo |
| 323 | ) -> None: |
| 324 | push_req = CoordPushRequest(records=[ |
| 325 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}), |
| 326 | CoordRecordIn(kind="heartbeat", record_uuid=_uuid4(), payload={}), |
| 327 | CoordRecordIn(kind="intent", record_uuid=_uuid4(), payload={}), |
| 328 | ]) |
| 329 | await coord_push(db_session, repo.repo_id, push_req) |
| 330 | |
| 331 | resp = await coord_pull( |
| 332 | db_session, repo.repo_id, CoordPollRequest(kinds=["reservation"]) |
| 333 | ) |
| 334 | assert all(r.kind == "reservation" for r in resp.records) |
| 335 | assert len(resp.records) == 1 |
| 336 | |
| 337 | async def test_pull_limit( |
| 338 | self, db_session: AsyncSession, repo: MusehubRepo |
| 339 | ) -> None: |
| 340 | push_req = CoordPushRequest(records=[ |
| 341 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}) |
| 342 | for _ in range(10) |
| 343 | ]) |
| 344 | await coord_push(db_session, repo.repo_id, push_req) |
| 345 | |
| 346 | resp = await coord_pull( |
| 347 | db_session, repo.repo_id, CoordPollRequest(limit=3) |
| 348 | ) |
| 349 | assert len(resp.records) == 3 |
| 350 | |
| 351 | async def test_pull_returns_oldest_first( |
| 352 | self, db_session: AsyncSession, repo: MusehubRepo |
| 353 | ) -> None: |
| 354 | uids = [_uuid4() for _ in range(5)] |
| 355 | push_req = CoordPushRequest(records=[ |
| 356 | CoordRecordIn(kind="reservation", record_uuid=uid, payload={}) |
| 357 | for uid in uids |
| 358 | ]) |
| 359 | await coord_push(db_session, repo.repo_id, push_req) |
| 360 | |
| 361 | resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest()) |
| 362 | ids = [r.id for r in resp.records] |
| 363 | assert ids == sorted(ids) # oldest first = ascending IDs |
| 364 | |
| 365 | |
| 366 | # ── E2E: HTTP endpoints ──────────────────────────────────────────────────────── |
| 367 | |
| 368 | |
| 369 | class TestPushEndpoint: |
| 370 | async def test_push_success( |
| 371 | self, |
| 372 | client: AsyncClient, |
| 373 | auth_headers: StrDict, |
| 374 | repo: MusehubRepo, |
| 375 | ) -> None: |
| 376 | resp = await client.post( |
| 377 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 378 | json={"records": [_make_record()]}, |
| 379 | headers=auth_headers, |
| 380 | ) |
| 381 | assert resp.status_code == 200 |
| 382 | body = resp.json() |
| 383 | assert body["inserted"] == 1 |
| 384 | assert body["skipped"] == 0 |
| 385 | |
| 386 | async def test_push_requires_auth( |
| 387 | self, client: AsyncClient, repo: MusehubRepo |
| 388 | ) -> None: |
| 389 | resp = await client.post( |
| 390 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 391 | json={"records": [_make_record()]}, |
| 392 | ) |
| 393 | assert resp.status_code == 401 |
| 394 | |
| 395 | async def test_push_unknown_repo_returns_404( |
| 396 | self, client: AsyncClient, auth_headers: StrDict |
| 397 | ) -> None: |
| 398 | resp = await client.post( |
| 399 | "/gabriel/no-such-repo/coord/push", |
| 400 | json={"records": [_make_record()]}, |
| 401 | headers=auth_headers, |
| 402 | ) |
| 403 | assert resp.status_code == 404 |
| 404 | |
| 405 | async def test_push_bad_kind_returns_400( |
| 406 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 407 | ) -> None: |
| 408 | resp = await client.post( |
| 409 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 410 | json={"records": [_make_record(kind="bad_kind")]}, |
| 411 | headers=auth_headers, |
| 412 | ) |
| 413 | assert resp.status_code == 422 # Pydantic validation error |
| 414 | |
| 415 | async def test_push_bad_uuid_returns_422( |
| 416 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 417 | ) -> None: |
| 418 | resp = await client.post( |
| 419 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 420 | json={"records": [{ |
| 421 | "kind": "reservation", |
| 422 | "record_uuid": "not-a-uuid", |
| 423 | "run_id": "x", |
| 424 | "payload": {}, |
| 425 | }]}, |
| 426 | headers=auth_headers, |
| 427 | ) |
| 428 | assert resp.status_code == 422 |
| 429 | |
| 430 | async def test_push_idempotent( |
| 431 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 432 | ) -> None: |
| 433 | rec = _make_record() |
| 434 | payload = {"records": [rec]} |
| 435 | |
| 436 | r1 = await client.post(f"/{repo.owner}/{repo.slug}/coord/push", json=payload, headers=auth_headers) |
| 437 | assert r1.status_code == 200 |
| 438 | assert r1.json()["inserted"] == 1 |
| 439 | |
| 440 | r2 = await client.post(f"/{repo.owner}/{repo.slug}/coord/push", json=payload, headers=auth_headers) |
| 441 | assert r2.status_code == 200 |
| 442 | assert r2.json()["skipped"] == 1 |
| 443 | assert r2.json()["inserted"] == 0 |
| 444 | |
| 445 | async def test_push_empty_records_rejected( |
| 446 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 447 | ) -> None: |
| 448 | resp = await client.post( |
| 449 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 450 | json={"records": []}, |
| 451 | headers=auth_headers, |
| 452 | ) |
| 453 | assert resp.status_code == 422 |
| 454 | |
| 455 | async def test_push_multiple_kinds( |
| 456 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 457 | ) -> None: |
| 458 | records = [_make_record(kind=k) for k in ("reservation", "heartbeat", "intent")] |
| 459 | resp = await client.post( |
| 460 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 461 | json={"records": records}, |
| 462 | headers=auth_headers, |
| 463 | ) |
| 464 | assert resp.status_code == 200 |
| 465 | assert resp.json()["inserted"] == 3 |
| 466 | |
| 467 | |
| 468 | class TestPullEndpoint: |
| 469 | async def test_pull_empty_initially( |
| 470 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 471 | ) -> None: |
| 472 | resp = await client.post( |
| 473 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 474 | json={}, |
| 475 | headers=auth_headers, |
| 476 | ) |
| 477 | assert resp.status_code == 200 |
| 478 | body = resp.json() |
| 479 | assert body["records"] == [] |
| 480 | assert body["cursor"] == 0 |
| 481 | |
| 482 | async def test_pull_after_push( |
| 483 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 484 | ) -> None: |
| 485 | rec = _make_record() |
| 486 | push_resp = await client.post( |
| 487 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 488 | json={"records": [rec]}, |
| 489 | headers=auth_headers, |
| 490 | ) |
| 491 | assert push_resp.status_code == 200 |
| 492 | |
| 493 | pull_resp = await client.post( |
| 494 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 495 | json={}, |
| 496 | headers=auth_headers, |
| 497 | ) |
| 498 | assert pull_resp.status_code == 200 |
| 499 | body = pull_resp.json() |
| 500 | assert len(body["records"]) == 1 |
| 501 | assert body["records"][0]["record_uuid"] == rec["record_uuid"] |
| 502 | assert body["cursor"] > 0 |
| 503 | |
| 504 | async def test_pull_cursor_pagination( |
| 505 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 506 | ) -> None: |
| 507 | # Push 5 records. |
| 508 | for _ in range(5): |
| 509 | await client.post( |
| 510 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 511 | json={"records": [_make_record()]}, |
| 512 | headers=auth_headers, |
| 513 | ) |
| 514 | |
| 515 | # Pull 2 at a time. |
| 516 | resp1 = await client.post( |
| 517 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 518 | json={"limit": 2}, |
| 519 | headers=auth_headers, |
| 520 | ) |
| 521 | assert len(resp1.json()["records"]) == 2 |
| 522 | cursor1 = resp1.json()["cursor"] |
| 523 | |
| 524 | resp2 = await client.post( |
| 525 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 526 | json={"since_id": cursor1, "limit": 2}, |
| 527 | headers=auth_headers, |
| 528 | ) |
| 529 | assert len(resp2.json()["records"]) == 2 |
| 530 | cursor2 = resp2.json()["cursor"] |
| 531 | |
| 532 | resp3 = await client.post( |
| 533 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 534 | json={"since_id": cursor2, "limit": 2}, |
| 535 | headers=auth_headers, |
| 536 | ) |
| 537 | assert len(resp3.json()["records"]) == 1 # last one |
| 538 | |
| 539 | async def test_pull_kind_filter_via_http( |
| 540 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 541 | ) -> None: |
| 542 | records = [_make_record(kind="reservation"), _make_record(kind="heartbeat")] |
| 543 | await client.post( |
| 544 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 545 | json={"records": records}, |
| 546 | headers=auth_headers, |
| 547 | ) |
| 548 | resp = await client.post( |
| 549 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 550 | json={"kinds": ["heartbeat"]}, |
| 551 | headers=auth_headers, |
| 552 | ) |
| 553 | body = resp.json() |
| 554 | assert all(r["kind"] == "heartbeat" for r in body["records"]) |
| 555 | |
| 556 | async def test_pull_private_repo_requires_auth( |
| 557 | self, client: AsyncClient, repo: MusehubRepo |
| 558 | ) -> None: |
| 559 | resp = await client.post( |
| 560 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 561 | json={}, |
| 562 | ) |
| 563 | assert resp.status_code == 404 # private repo → 404 not 401 |
| 564 | |
| 565 | async def test_pull_public_repo_no_auth_required( |
| 566 | self, client: AsyncClient, public_repo: MusehubRepo |
| 567 | ) -> None: |
| 568 | resp = await client.post( |
| 569 | f"/{public_repo.owner}/{public_repo.slug}/coord/pull", |
| 570 | json={}, |
| 571 | ) |
| 572 | assert resp.status_code == 200 |
| 573 | |
| 574 | |
| 575 | class TestWatchEndpoint: |
| 576 | """Watch endpoint tests. |
| 577 | |
| 578 | The SSE stream is infinite by design (it polls forever). All tests that |
| 579 | hit the streaming path mock ``coord_watch_stream`` with a finite generator |
| 580 | so the test completes without blocking. Tests that exercise pre-stream |
| 581 | guard logic (auth, repo resolution, kind validation) send a regular GET |
| 582 | request and assert the HTTP status code — those code paths return before |
| 583 | the stream generator is entered. |
| 584 | """ |
| 585 | |
| 586 | @staticmethod |
| 587 | async def _one_heartbeat(*args: str, **kwargs: str) -> AsyncIterator[str]: |
| 588 | """Finite mock stream — yields one heartbeat then stops.""" |
| 589 | yield ": heartbeat\n\n" |
| 590 | |
| 591 | async def test_watch_returns_sse_content_type( |
| 592 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 593 | ) -> None: |
| 594 | with patch( |
| 595 | "musehub.api.routes.coord.coord_watch_stream", |
| 596 | side_effect=self._one_heartbeat, |
| 597 | ): |
| 598 | resp = await client.get( |
| 599 | f"/{repo.owner}/{repo.slug}/coord/watch", |
| 600 | headers=auth_headers, |
| 601 | ) |
| 602 | assert resp.status_code == 200 |
| 603 | assert "text/event-stream" in resp.headers["content-type"] |
| 604 | |
| 605 | async def test_watch_no_cache_header( |
| 606 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 607 | ) -> None: |
| 608 | with patch( |
| 609 | "musehub.api.routes.coord.coord_watch_stream", |
| 610 | side_effect=self._one_heartbeat, |
| 611 | ): |
| 612 | resp = await client.get( |
| 613 | f"/{repo.owner}/{repo.slug}/coord/watch", |
| 614 | headers=auth_headers, |
| 615 | ) |
| 616 | assert resp.headers.get("cache-control") == "no-cache" |
| 617 | |
| 618 | async def test_watch_yields_heartbeat_event( |
| 619 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 620 | ) -> None: |
| 621 | with patch( |
| 622 | "musehub.api.routes.coord.coord_watch_stream", |
| 623 | side_effect=self._one_heartbeat, |
| 624 | ): |
| 625 | resp = await client.get( |
| 626 | f"/{repo.owner}/{repo.slug}/coord/watch", |
| 627 | headers=auth_headers, |
| 628 | ) |
| 629 | assert ": heartbeat" in resp.text |
| 630 | |
| 631 | async def test_watch_yields_coord_record_event( |
| 632 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 633 | ) -> None: |
| 634 | uid = _uuid4() |
| 635 | |
| 636 | async def _one_record(*args: str, **kwargs: str) -> AsyncIterator[str]: |
| 637 | yield f'id: 1\nevent: coord_record\ndata: {{"id":1,"kind":"reservation","record_uuid":"{uid}"}}\n\n' |
| 638 | |
| 639 | with patch( |
| 640 | "musehub.api.routes.coord.coord_watch_stream", |
| 641 | side_effect=_one_record, |
| 642 | ): |
| 643 | resp = await client.get( |
| 644 | f"/{repo.owner}/{repo.slug}/coord/watch", |
| 645 | headers=auth_headers, |
| 646 | ) |
| 647 | assert "coord_record" in resp.text |
| 648 | assert uid in resp.text |
| 649 | |
| 650 | async def test_watch_private_repo_no_auth_returns_404( |
| 651 | self, client: AsyncClient, repo: MusehubRepo |
| 652 | ) -> None: |
| 653 | # No auth → private repo is invisible (404 before stream starts). |
| 654 | resp = await client.get(f"/{repo.owner}/{repo.slug}/coord/watch") |
| 655 | assert resp.status_code == 404 |
| 656 | |
| 657 | async def test_watch_invalid_kind_param_returns_400( |
| 658 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 659 | ) -> None: |
| 660 | # Bad kind → 400 before stream starts. |
| 661 | resp = await client.get( |
| 662 | f"/{repo.owner}/{repo.slug}/coord/watch?kinds=bad_kind", |
| 663 | headers=auth_headers, |
| 664 | ) |
| 665 | assert resp.status_code == 400 |
| 666 | |
| 667 | async def test_watch_unknown_repo_returns_404( |
| 668 | self, client: AsyncClient, auth_headers: StrDict |
| 669 | ) -> None: |
| 670 | resp = await client.get( |
| 671 | "/gabriel/no-such-repo/coord/watch", |
| 672 | headers=auth_headers, |
| 673 | ) |
| 674 | assert resp.status_code == 404 |
| 675 | |
| 676 | async def test_watch_since_id_param_passed_to_stream( |
| 677 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 678 | ) -> None: |
| 679 | """since_id query param is forwarded to coord_watch_stream.""" |
| 680 | captured = {} |
| 681 | |
| 682 | async def _capture(repo_id: str, since_id: int | None, kinds: list[str] | None, get_session: type) -> AsyncIterator[str]: |
| 683 | captured["since_id"] = since_id |
| 684 | yield ": heartbeat\n\n" |
| 685 | |
| 686 | with patch( |
| 687 | "musehub.api.routes.coord.coord_watch_stream", |
| 688 | side_effect=_capture, |
| 689 | ): |
| 690 | await client.get( |
| 691 | f"/{repo.owner}/{repo.slug}/coord/watch?since_id=99", |
| 692 | headers=auth_headers, |
| 693 | ) |
| 694 | assert captured.get("since_id") == 99 |
| 695 | |
| 696 | async def test_watch_kind_filter_param_passed_to_stream( |
| 697 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 698 | ) -> None: |
| 699 | captured = {} |
| 700 | |
| 701 | async def _capture(repo_id: str, since_id: int | None, kinds: list[str] | None, get_session: type) -> AsyncIterator[str]: |
| 702 | captured["kinds"] = kinds |
| 703 | yield ": heartbeat\n\n" |
| 704 | |
| 705 | with patch( |
| 706 | "musehub.api.routes.coord.coord_watch_stream", |
| 707 | side_effect=_capture, |
| 708 | ): |
| 709 | await client.get( |
| 710 | f"/{repo.owner}/{repo.slug}/coord/watch?kinds=reservation&kinds=heartbeat", |
| 711 | headers=auth_headers, |
| 712 | ) |
| 713 | assert set(captured.get("kinds", [])) == {"reservation", "heartbeat"} |
| 714 | |
| 715 | |
| 716 | # ── Security tests ───────────────────────────────────────────────────────────── |
| 717 | |
| 718 | |
| 719 | class TestCoordSecurity: |
| 720 | async def test_path_traversal_in_owner_blocked( |
| 721 | self, client: AsyncClient, auth_headers: StrDict |
| 722 | ) -> None: |
| 723 | resp = await client.post( |
| 724 | "/../../../etc/passwd/coord-test/coord/push", |
| 725 | json={"records": [_make_record()]}, |
| 726 | headers=auth_headers, |
| 727 | ) |
| 728 | # FastAPI/Starlette normalizes the path, resulting in 404 or 400. |
| 729 | assert resp.status_code in (400, 404, 422) |
| 730 | |
| 731 | async def test_path_traversal_in_uuid_rejected( |
| 732 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 733 | ) -> None: |
| 734 | resp = await client.post( |
| 735 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 736 | json={"records": [{ |
| 737 | "kind": "reservation", |
| 738 | "record_uuid": "../../etc/passwd", |
| 739 | "payload": {}, |
| 740 | }]}, |
| 741 | headers=auth_headers, |
| 742 | ) |
| 743 | assert resp.status_code == 422 |
| 744 | |
| 745 | async def test_null_byte_in_uuid_rejected( |
| 746 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 747 | ) -> None: |
| 748 | resp = await client.post( |
| 749 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 750 | json={"records": [{ |
| 751 | "kind": "reservation", |
| 752 | "record_uuid": "\x00" + _uuid4()[1:], |
| 753 | "payload": {}, |
| 754 | }]}, |
| 755 | headers=auth_headers, |
| 756 | ) |
| 757 | assert resp.status_code == 422 |
| 758 | |
| 759 | async def test_oversized_batch_rejected( |
| 760 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 761 | ) -> None: |
| 762 | records = [_make_record() for _ in range(501)] |
| 763 | resp = await client.post( |
| 764 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 765 | json={"records": records}, |
| 766 | headers=auth_headers, |
| 767 | ) |
| 768 | assert resp.status_code == 422 |
| 769 | |
| 770 | async def test_different_user_cannot_push_to_private_repo( |
| 771 | self, client: AsyncClient, db_session: AsyncSession, repo: MusehubRepo |
| 772 | ) -> None: |
| 773 | from musehub.db.musehub_models import MusehubIdentity |
| 774 | from musehub.auth.request_signing import MSignContext, require_signed_request, optional_signed_request |
| 775 | from musehub.main import app as _app |
| 776 | |
| 777 | other_id = str(uuid.uuid4()) |
| 778 | other_identity = MusehubIdentity(identity_id=other_id, handle="othercoorduser", identity_type="human") |
| 779 | db_session.add(other_identity) |
| 780 | await db_session.commit() |
| 781 | _other_ctx = MSignContext(handle="othercoorduser", identity_id=other_id, is_agent=False, is_admin=False) |
| 782 | _app.dependency_overrides[require_signed_request] = lambda: _other_ctx |
| 783 | _app.dependency_overrides[optional_signed_request] = lambda: _other_ctx |
| 784 | |
| 785 | resp = await client.post( |
| 786 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 787 | json={"records": [_make_record()]}, |
| 788 | ) |
| 789 | # Repo is invisible to other user (404) or forbidden (403). |
| 790 | assert resp.status_code in (403, 404) |
| 791 | |
| 792 | async def test_unknown_kind_in_pull_filter_rejected( |
| 793 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 794 | ) -> None: |
| 795 | resp = await client.post( |
| 796 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 797 | json={"kinds": ["injection_kind']); DROP TABLE--"]}, |
| 798 | headers=auth_headers, |
| 799 | ) |
| 800 | assert resp.status_code == 422 |
| 801 | |
| 802 | async def test_negative_since_id_rejected( |
| 803 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 804 | ) -> None: |
| 805 | resp = await client.post( |
| 806 | f"/{repo.owner}/{repo.slug}/coord/pull", |
| 807 | json={"since_id": -1}, |
| 808 | headers=auth_headers, |
| 809 | ) |
| 810 | assert resp.status_code == 422 |
| 811 | |
| 812 | |
| 813 | # ── Stress tests ─────────────────────────────────────────────────────────────── |
| 814 | |
| 815 | |
| 816 | class TestCoordStress: |
| 817 | async def test_push_500_records_single_batch( |
| 818 | self, client: AsyncClient, auth_headers: StrDict, repo: MusehubRepo |
| 819 | ) -> None: |
| 820 | records = [_make_record() for _ in range(500)] |
| 821 | resp = await client.post( |
| 822 | f"/{repo.owner}/{repo.slug}/coord/push", |
| 823 | json={"records": records}, |
| 824 | headers=auth_headers, |
| 825 | ) |
| 826 | assert resp.status_code == 200 |
| 827 | body = resp.json() |
| 828 | assert body["inserted"] == 500 |
| 829 | assert body["skipped"] == 0 |
| 830 | |
| 831 | async def test_cursor_pagination_full_1000_records( |
| 832 | self, db_session: AsyncSession, repo: MusehubRepo |
| 833 | ) -> None: |
| 834 | """Push 1000 records in two batches and paginate through all with cursor.""" |
| 835 | inserted_total = 0 |
| 836 | for _ in range(2): # two batches of 500 |
| 837 | records = [ |
| 838 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}) |
| 839 | for _ in range(500) |
| 840 | ] |
| 841 | push_req = CoordPushRequest(records=records) |
| 842 | resp = await coord_push(db_session, repo.repo_id, push_req) |
| 843 | inserted_total += resp.inserted |
| 844 | assert inserted_total == 1000 |
| 845 | |
| 846 | # Paginate with limit=100. |
| 847 | cursor = 0 |
| 848 | total_pulled = 0 |
| 849 | pages = 0 |
| 850 | while True: |
| 851 | pull_resp = await coord_pull( |
| 852 | db_session, repo.repo_id, |
| 853 | CoordPollRequest(since_id=cursor, limit=100) |
| 854 | ) |
| 855 | if not pull_resp.records: |
| 856 | break |
| 857 | total_pulled += len(pull_resp.records) |
| 858 | cursor = pull_resp.cursor |
| 859 | pages += 1 |
| 860 | |
| 861 | assert total_pulled == 1000 |
| 862 | assert pages == 10 |
| 863 | |
| 864 | async def test_all_kinds_push_and_pull( |
| 865 | self, db_session: AsyncSession, repo: MusehubRepo |
| 866 | ) -> None: |
| 867 | """Push one record per kind, pull all, assert each kind present.""" |
| 868 | records = [ |
| 869 | CoordRecordIn(kind=k, record_uuid=_uuid4(), payload={"kind": k}) |
| 870 | for k in sorted(_VALID_KINDS) |
| 871 | ] |
| 872 | push_req = CoordPushRequest(records=records) |
| 873 | push_resp = await coord_push(db_session, repo.repo_id, push_req) |
| 874 | assert push_resp.inserted == len(_VALID_KINDS) |
| 875 | |
| 876 | pull_resp = await coord_pull(db_session, repo.repo_id, CoordPollRequest()) |
| 877 | pulled_kinds = {r.kind for r in pull_resp.records} |
| 878 | assert pulled_kinds == _VALID_KINDS |
| 879 | |
| 880 | async def test_idempotent_push_500_records_twice( |
| 881 | self, db_session: AsyncSession, repo: MusehubRepo |
| 882 | ) -> None: |
| 883 | """Pushing the same 500 records twice: first all inserted, then all skipped.""" |
| 884 | records = [ |
| 885 | CoordRecordIn(kind="reservation", record_uuid=_uuid4(), payload={}) |
| 886 | for _ in range(500) |
| 887 | ] |
| 888 | req = CoordPushRequest(records=records) |
| 889 | |
| 890 | resp1 = await coord_push(db_session, repo.repo_id, req) |
| 891 | assert resp1.inserted == 500 |
| 892 | |
| 893 | resp2 = await coord_push(db_session, repo.repo_id, req) |
| 894 | assert resp2.inserted == 0 |
| 895 | assert resp2.skipped == 500 |
File History
1 commit
sha256:bb2baaabdd19320bde50cb69d447fd1c1e571467df729be1a23b5e93064b046a
feat(intel): standardize headers, gauge icon, velocity card…
Sonnet 4.6
minor
⚠
143 days ago