"""Adversarial tests for the setup scaffold and onboarding preference endpoints. Phase 2 acceptance criteria (NOT what the code happens to do): - Rollback when the FINAL step (output target create) fails leaves ZERO orphans AND emits ZERO "created" events. - Reused capture template is NOT deleted on rollback. - display_index > 63 → 422. - Missing device_id → 422 (Pydantic validation before handler runs). - Unknown device_id → 404. - PUT onboarding false clears completed_at to null. - Corrupt stored onboarding value falls back to default (onboarded=false). These fill the gaps in the existing 22 happy-path tests. """ from __future__ import annotations import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from unittest.mock import MagicMock, patch # --------------------------------------------------------------------------- # Shared fixtures (mirrors test_setup_routes.py exactly) # --------------------------------------------------------------------------- @pytest.fixture def tmp_db(tmp_path): from ledgrab.storage.database import Database db = Database(tmp_path / "adv_setup.db") yield db db.close() @pytest.fixture def device_store(tmp_db): from ledgrab.storage import DeviceStore return DeviceStore(tmp_db) @pytest.fixture def template_store(tmp_db): from ledgrab.storage.template_store import TemplateStore return TemplateStore(tmp_db) @pytest.fixture def picture_source_store(tmp_db): from ledgrab.storage.picture_source_store import PictureSourceStore return PictureSourceStore(tmp_db) @pytest.fixture def css_store(tmp_db): from ledgrab.storage.color_strip_store import ColorStripStore return ColorStripStore(tmp_db) @pytest.fixture def output_target_store(tmp_db): from ledgrab.storage.output_target_store import OutputTargetStore return OutputTargetStore(tmp_db) @pytest.fixture def sample_device(device_store): return device_store.create_device( name="Test LED Strip", url="http://192.168.1.10", led_count=60, ) @pytest.fixture def event_log(): log = [] return log @pytest.fixture def mock_manager(): """A MagicMock ProcessorManager that silently accepts all calls.""" mgr = MagicMock() mgr.remove_target = MagicMock() return mgr @pytest.fixture def setup_client( tmp_db, device_store, template_store, picture_source_store, css_store, output_target_store, event_log, mock_manager, ): from ledgrab.api.routes.setup import router from ledgrab.api.auth import verify_api_key from ledgrab.api import dependencies as deps app = FastAPI() app.include_router(router) app.dependency_overrides[verify_api_key] = lambda: "test" app.dependency_overrides[deps.get_device_store] = lambda: device_store app.dependency_overrides[deps.get_template_store] = lambda: template_store app.dependency_overrides[deps.get_picture_source_store] = lambda: picture_source_store app.dependency_overrides[deps.get_color_strip_store] = lambda: css_store app.dependency_overrides[deps.get_output_target_store] = lambda: output_target_store app.dependency_overrides[deps.get_processor_manager] = lambda: mock_manager def _fire(entity_type, action, entity_id): event_log.append((entity_type, action, entity_id)) with patch("ledgrab.api.routes.setup.fire_entity_event", side_effect=_fire): yield TestClient(app, raise_server_exceptions=False) def _scaffold(client, **overrides): body = {"display_index": 0, **overrides} return client.post("/api/v1/setup/scaffold", json=body) # --------------------------------------------------------------------------- # Rollback when the FINAL step (output target) fails # Criteria: "zero orphans AND zero 'created' events" # --------------------------------------------------------------------------- class TestFinalStepRollback: def test_final_step_failure_leaves_zero_orphans( self, setup_client, sample_device, picture_source_store, css_store, output_target_store, ): """output_target_store.create_wled_target failing leaves NO orphaned entities.""" original_create = output_target_store.create_wled_target def _fail(*args, **kwargs): raise ValueError("Injected final step failure") output_target_store.create_wled_target = _fail try: resp = _scaffold(setup_client, device_id=sample_device.id) assert resp.status_code in ( 400, 500, ), f"Expected 4xx/5xx on final-step failure, got {resp.status_code}: {resp.text}" # Zero picture sources remaining remaining_ps = picture_source_store.get_all_streams() assert len(remaining_ps) == 0, f"Picture sources not rolled back: {remaining_ps}" # Zero color-strip sources remaining remaining_css = css_store.get_all_sources() assert len(remaining_css) == 0, f"Color-strip sources not rolled back: {remaining_css}" # Zero output targets (trivially true since creation was never reached, # but included for completeness) remaining_ot = output_target_store.get_all_targets() assert len(remaining_ot) == 0, f"Output targets not rolled back: {remaining_ot}" finally: output_target_store.create_wled_target = original_create def test_final_step_failure_emits_zero_created_events( self, setup_client, sample_device, output_target_store, event_log, ): """No 'created' events must be emitted when the final step fails (deferred-event contract).""" original_create = output_target_store.create_wled_target def _fail(*args, **kwargs): raise ValueError("Final step injected failure") output_target_store.create_wled_target = _fail try: resp = _scaffold(setup_client, device_id=sample_device.id) assert resp.status_code in (400, 500) created_events = [(et, act) for et, act, _ in event_log if act == "created"] assert ( created_events == [] ), f"'created' events leaked on final-step rollback: {created_events}" finally: output_target_store.create_wled_target = original_create def test_final_step_failure_reused_template_survives( self, setup_client, sample_device, template_store, output_target_store, ): """A reused capture template must NOT be deleted when the final step fails.""" templates_before = {t.id for t in template_store.get_all_templates()} original_create = output_target_store.create_wled_target def _fail(*args, **kwargs): raise ValueError("Final step injected failure") output_target_store.create_wled_target = _fail try: _scaffold(setup_client, device_id=sample_device.id) finally: output_target_store.create_wled_target = original_create templates_after = {t.id for t in template_store.get_all_templates()} assert templates_before == templates_after, ( f"Template set changed after rollback: " f"before={templates_before} after={templates_after}" ) def test_final_step_failure_device_not_deleted( self, setup_client, sample_device, device_store, output_target_store, ): """The pre-existing device must never be touched by rollback of any step.""" original_create = output_target_store.create_wled_target def _fail(*args, **kwargs): raise ValueError("Final step injected failure") output_target_store.create_wled_target = _fail try: _scaffold(setup_client, device_id=sample_device.id) finally: output_target_store.create_wled_target = original_create device = device_store.get(sample_device.id) assert device is not None, "Pre-existing device was deleted during rollback" # --------------------------------------------------------------------------- # Validation: display_index bounds # Criteria: display_index > 63 → 422; display_index 63 → 201; negative → 422 # --------------------------------------------------------------------------- class TestDisplayIndexBounds: def test_display_index_64_returns_422(self, setup_client, sample_device): """display_index=64 (one above max) must be rejected with 422.""" resp = _scaffold(setup_client, device_id=sample_device.id, display_index=64) assert ( resp.status_code == 422 ), f"Expected 422 for display_index=64, got {resp.status_code}: {resp.text}" def test_display_index_63_returns_201(self, setup_client, sample_device): """display_index=63 is the maximum valid value — must be accepted.""" resp = _scaffold(setup_client, device_id=sample_device.id, display_index=63) assert ( resp.status_code == 201 ), f"Expected 201 for display_index=63, got {resp.status_code}: {resp.text}" def test_display_index_0_returns_201(self, setup_client, sample_device): """display_index=0 is the minimum valid value.""" resp = _scaffold(setup_client, device_id=sample_device.id, display_index=0) assert ( resp.status_code == 201 ), f"Expected 201 for display_index=0, got {resp.status_code}: {resp.text}" def test_display_index_negative_1_returns_422(self, setup_client, sample_device): """display_index=-1 must be rejected with 422.""" resp = _scaffold(setup_client, device_id=sample_device.id, display_index=-1) assert ( resp.status_code == 422 ), f"Expected 422 for display_index=-1, got {resp.status_code}: {resp.text}" def test_display_index_very_large_returns_422(self, setup_client, sample_device): """display_index=10000 must be rejected with 422.""" resp = _scaffold(setup_client, device_id=sample_device.id, display_index=10000) assert ( resp.status_code == 422 ), f"Expected 422 for display_index=10000, got {resp.status_code}: {resp.text}" # --------------------------------------------------------------------------- # Validation: missing / unknown device_id # --------------------------------------------------------------------------- class TestDeviceValidation: def test_missing_device_id_returns_422(self, setup_client): """Omitting device_id entirely must yield 422 (Pydantic required field).""" resp = setup_client.post("/api/v1/setup/scaffold", json={"display_index": 0}) assert ( resp.status_code == 422 ), f"Expected 422 for missing device_id, got {resp.status_code}: {resp.text}" def test_empty_string_device_id_handled(self, setup_client): """device_id='' — should yield 404 (empty string not in device store) or 422.""" resp = _scaffold(setup_client, device_id="") assert resp.status_code in ( 404, 422, ), f"Expected 404 or 422 for empty device_id, got {resp.status_code}: {resp.text}" def test_unknown_device_id_returns_404(self, setup_client): """device_id that does not exist in the store must yield 404.""" resp = _scaffold(setup_client, device_id="device_definitely_does_not_exist") assert ( resp.status_code == 404 ), f"Expected 404 for unknown device_id, got {resp.status_code}: {resp.text}" def test_none_device_id_returns_422(self, setup_client): """device_id=null must yield 422 (not None-convertible to str).""" resp = setup_client.post( "/api/v1/setup/scaffold", json={"device_id": None, "display_index": 0} ) assert ( resp.status_code == 422 ), f"Expected 422 for null device_id, got {resp.status_code}: {resp.text}" # --------------------------------------------------------------------------- # Rollback idempotency: calling scaffold twice with the final step failing # must still leave exactly zero orphans (no accumulation across calls). # --------------------------------------------------------------------------- class TestRollbackIdempotency: def test_two_failed_scaffolds_leave_zero_orphans( self, setup_client, sample_device, picture_source_store, css_store, output_target_store, ): """Two sequential scaffold failures must not accumulate orphans.""" original_create = output_target_store.create_wled_target def _fail(*args, **kwargs): raise ValueError("Always fails") output_target_store.create_wled_target = _fail try: _scaffold(setup_client, device_id=sample_device.id) _scaffold(setup_client, device_id=sample_device.id) finally: output_target_store.create_wled_target = original_create assert ( len(picture_source_store.get_all_streams()) == 0 ), "Picture sources accumulated across two failed scaffolds" assert ( len(css_store.get_all_sources()) == 0 ), "Color-strip sources accumulated across two failed scaffolds" # --------------------------------------------------------------------------- # Onboarding: adversarial cases # --------------------------------------------------------------------------- @pytest.fixture def pref_client(tmp_db): from ledgrab.api.routes.preferences import router from ledgrab.api.auth import verify_api_key from ledgrab.api import dependencies as deps app = FastAPI() app.include_router(router) app.dependency_overrides[verify_api_key] = lambda: "test" app.dependency_overrides[deps.get_database] = lambda: tmp_db return TestClient(app) class TestOnboardingAdversarial: def test_put_false_clears_completed_at(self, pref_client): """PUT onboarded=false must clear completed_at to null, per criteria.""" # First mark as onboarded r1 = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) assert r1.status_code == 200 assert r1.json()["completed_at"] is not None # Now set to false — completed_at must be cleared r2 = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": False}) assert r2.status_code == 200 data = r2.json() assert data["onboarded"] is False assert ( data["completed_at"] is None ), f"completed_at should be null after PUT onboarded=false, got {data['completed_at']!r}" def test_put_false_then_get_returns_null_completed_at(self, pref_client): """After PUT false, GET must also return null completed_at (persisted correctly).""" pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": False}) resp = pref_client.get("/api/v1/preferences/onboarding") assert resp.status_code == 200 assert ( resp.json()["completed_at"] is None ), "GET after PUT false should return null completed_at" def test_corrupt_stored_value_falls_back_to_default(self, tmp_db, pref_client): """If the stored onboarding value is corrupt, GET must fall back to default. Criteria: "corrupt stored value falls back to default". We inject garbage into the db directly, then hit GET. """ # Inject a value that is syntactically valid JSON (dict) but fails # Pydantic validation because the types are wrong. tmp_db.set_setting("onboarded", {"onboarded": "not_a_bool", "completed_at": 12345}) resp = pref_client.get("/api/v1/preferences/onboarding") assert ( resp.status_code == 200 ), f"Expected 200 for corrupt onboarding value, got {resp.status_code}" data = resp.json() # Must fall back to default assert ( data["onboarded"] is False ), f"Expected onboarded=false as default after corrupt value, got {data['onboarded']!r}" assert ( data["completed_at"] is None ), f"Expected completed_at=null as default, got {data['completed_at']!r}" def test_corrupt_stored_value_as_wrong_type_falls_back(self, tmp_db, pref_client): """Stored value is a string (not a dict) — must fall back to default.""" tmp_db.set_setting("onboarded", "this_is_not_valid") resp = pref_client.get("/api/v1/preferences/onboarding") assert resp.status_code == 200 data = resp.json() assert data["onboarded"] is False assert data["completed_at"] is None def test_corrupt_stored_null_falls_back_to_default(self, tmp_db, pref_client): """Stored value is null/None — must return default (not crash).""" tmp_db.set_setting("onboarded", None) resp = pref_client.get("/api/v1/preferences/onboarding") assert resp.status_code == 200 assert resp.json()["onboarded"] is False def test_put_true_without_completed_at_stamps_timestamp(self, pref_client): """PUT onboarded=true without completed_at must auto-stamp a timestamp.""" resp = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) assert resp.status_code == 200 data = resp.json() assert ( data["completed_at"] is not None ), "Server must auto-stamp completed_at when onboarded=true is sent without a timestamp" # Should be a non-empty ISO timestamp string assert len(data["completed_at"]) > 10 def test_put_true_with_completed_at_preserves_it(self, pref_client): """PUT onboarded=true with explicit completed_at must preserve that value.""" ts = "2025-03-15T10:00:00+00:00" resp = pref_client.put( "/api/v1/preferences/onboarding", json={"onboarded": True, "completed_at": ts}, ) assert resp.status_code == 200 assert resp.json()["completed_at"] == ts def test_put_false_with_completed_at_clears_it(self, pref_client): """PUT onboarded=false even with a completed_at payload must clear it. The criteria say: 'Setting onboarded=false clears completed_at to null.' """ ts = "2025-01-01T00:00:00+00:00" resp = pref_client.put( "/api/v1/preferences/onboarding", json={"onboarded": False, "completed_at": ts}, ) assert resp.status_code == 200 data = resp.json() assert data["onboarded"] is False assert ( data["completed_at"] is None ), f"completed_at should be cleared to null when onboarded=false, got {data['completed_at']!r}" def test_multiple_true_puts_only_stamp_once(self, pref_client): """Two successive PUT true calls — second call must preserve the original timestamp.""" r1 = pref_client.put("/api/v1/preferences/onboarding", json={"onboarded": True}) ts1 = r1.json()["completed_at"] assert ts1 is not None # Explicitly provide the original timestamp in second call r2 = pref_client.put( "/api/v1/preferences/onboarding", json={"onboarded": True, "completed_at": ts1}, ) assert ( r2.json()["completed_at"] == ts1 ), "Explicit completed_at should be preserved on second PUT"