From b7f206c8c5e57dc60b2940551e063aa3ff350512 Mon Sep 17 00:00:00 2001 From: anti Date: Fri, 1 May 2026 07:20:13 -0400 Subject: [PATCH] =?UTF-8?q?feat(ttp):=20E.1.9=20API=20contract=20=E2=80=94?= =?UTF-8?q?=20seven=20router=20endpoints,=20admin-gated=20state=20mutation?= =?UTF-8?q?s,=20response=20models?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mounts /api/v1/ttp/* with empty-list / empty-Navigator responses. GET endpoints viewer-gated; POST/DELETE /rules/{rule_id}/state admin-gated server-side. POST parses JSON manually so a malformed body returns the documented 400 (per feedback_schemathesis_400). Drops xfail-strict markers from E.2.8 tests now that the router is mounted; 26 tests pass against the contract handlers. --- decnet/web/db/models/__init__.py | 16 +++ decnet/web/db/models/ttp.py | 115 ++++++++++++++++ decnet/web/router/__init__.py | 18 +++ decnet/web/router/ttp/__init__.py | 6 + decnet/web/router/ttp/api_export_navigator.py | 56 ++++++++ decnet/web/router/ttp/api_get_by_attacker.py | 35 +++++ decnet/web/router/ttp/api_get_by_campaign.py | 31 +++++ decnet/web/router/ttp/api_get_by_identity.py | 35 +++++ decnet/web/router/ttp/api_get_by_session.py | 31 +++++ decnet/web/router/ttp/api_get_rules.py | 128 ++++++++++++++++++ decnet/web/router/ttp/api_get_techniques.py | 34 +++++ development/TTP_TAGGING.md | 2 + tests/api/ttp/test_get_endpoints.py | 27 +--- tests/api/ttp/test_response_schemas.py | 4 - tests/api/ttp/test_rules_state.py | 33 +---- 15 files changed, 515 insertions(+), 56 deletions(-) create mode 100644 decnet/web/router/ttp/__init__.py create mode 100644 decnet/web/router/ttp/api_export_navigator.py create mode 100644 decnet/web/router/ttp/api_get_by_attacker.py create mode 100644 decnet/web/router/ttp/api_get_by_campaign.py create mode 100644 decnet/web/router/ttp/api_get_by_identity.py create mode 100644 decnet/web/router/ttp/api_get_by_session.py create mode 100644 decnet/web/router/ttp/api_get_rules.py create mode 100644 decnet/web/router/ttp/api_get_techniques.py diff --git a/decnet/web/db/models/__init__.py b/decnet/web/db/models/__init__.py index a5338f3d..80778f46 100644 --- a/decnet/web/db/models/__init__.py +++ b/decnet/web/db/models/__init__.py @@ -186,13 +186,21 @@ from .tarpit import ( TarpitStatusResponse, ) from .ttp import ( + CampaignTechniqueRow, CanaryFingerprintEvidence, CommandEvidence, EmailEvidence, + IdentityTechniqueRow, IntelEvidence, + NavigatorLayer, + NavigatorTechnique, + RuleCatalogueRow, + RuleStateRequest, + RuleStateResponse, TTPRule, TTPRuleState, TTPTag, + TechniqueRollupRow, compute_tag_uuid, ) @@ -356,12 +364,20 @@ __all__ = [ "TarpitRuleResponse", "TarpitStatusResponse", # ttp + "CampaignTechniqueRow", "CanaryFingerprintEvidence", "CommandEvidence", "EmailEvidence", + "IdentityTechniqueRow", "IntelEvidence", + "NavigatorLayer", + "NavigatorTechnique", + "RuleCatalogueRow", + "RuleStateRequest", + "RuleStateResponse", "TTPRule", "TTPRuleState", "TTPTag", + "TechniqueRollupRow", "compute_tag_uuid", ] diff --git a/decnet/web/db/models/ttp.py b/decnet/web/db/models/ttp.py index fc22160d..c1d9dfa9 100644 --- a/decnet/web/db/models/ttp.py +++ b/decnet/web/db/models/ttp.py @@ -10,6 +10,7 @@ import uuid as _uuid from datetime import datetime, timezone from typing import Any, Literal, Optional, TypedDict +from pydantic import BaseModel from sqlalchemy import JSON, CheckConstraint, Column, Index from sqlmodel import Field, SQLModel @@ -235,3 +236,117 @@ class TTPRuleState(SQLModel, table=True): set_at: datetime = Field( default_factory=lambda: datetime.now(timezone.utc), ) + + +# ── API response models (Pydantic) ────────────────────────────────── +# Routed by `decnet/web/router/ttp/`. Per the project's "all models in +# models.py" rule these live here alongside the SQLModel tables, not +# in a sibling schemas.py. Empty-list returns at contract phase are +# typed against these models so the OpenAPI shape is stable from day +# one. See TTP_TAGGING.md §E.1.9. + +class TechniqueRollupRow(BaseModel): + """One row of /api/v1/ttp/techniques — distinct technique observed + across the fleet with a count and a most-recent-seen timestamp.""" + + technique_id: str + sub_technique_id: Optional[str] = None + tactic: str + count: int + last_seen: datetime + + +class IdentityTechniqueRow(BaseModel): + """One row of the by-identity / by-attacker / by-session endpoints — + a distinct (technique, sub_technique) tuple within the requested + scope, with an aggregate count and first/last-seen timestamps.""" + + technique_id: str + sub_technique_id: Optional[str] = None + tactic: str + count: int + first_seen: datetime + last_seen: datetime + confidence_max: float + + +class CampaignTechniqueRow(BaseModel): + """One row of /api/v1/ttp/by-campaign/{uuid} — a technique observed + across at least one Identity rolled up into the campaign.""" + + technique_id: str + sub_technique_id: Optional[str] = None + tactic: str + count: int + identity_count: int + last_seen: datetime + + +class RuleCatalogueRow(BaseModel): + """One row of /api/v1/ttp/rules — a rule definition + its current + operational state. The operator-facing rule list.""" + + rule_id: str + rule_version: int + name: str + description: str + state: Literal["enabled", "disabled", "clipped"] + confidence_max: Optional[float] = None + expires_at: Optional[datetime] = None + reason: Optional[str] = None + set_by: Optional[str] = None + set_at: Optional[datetime] = None + + +class RuleStateRequest(BaseModel): + """POST /api/v1/ttp/rules/{rule_id}/state body — admin operator + sets disable / clip / TTL on a rule. Pre-v1: schema is the public + contract; downward changes require an OpenAPI version bump.""" + + state: Literal["enabled", "disabled", "clipped"] + confidence_max: Optional[float] = None + expires_at: Optional[datetime] = None + reason: Optional[str] = None + + +class RuleStateResponse(BaseModel): + """Response for POST/DELETE /api/v1/ttp/rules/{rule_id}/state and + the per-rule entry of GET /rules. Mirrors :class:`TTPRuleState`.""" + + rule_id: str + state: Literal["enabled", "disabled", "clipped"] + confidence_max: Optional[float] = None + expires_at: Optional[datetime] = None + reason: Optional[str] = None + set_by: Optional[str] = None + set_at: Optional[datetime] = None + + +class NavigatorTechnique(BaseModel): + """Per-technique entry of the MITRE ATT&CK Navigator JSON layer.""" + + techniqueID: str + score: int + color: str = "" + comment: str = "" + enabled: bool = True + + +class NavigatorLayer(BaseModel): + """MITRE ATT&CK Navigator JSON layer envelope. Empty-but-valid at + contract phase: a SOC analyst pasting this JSON into the official + Navigator sees the file load cleanly with no highlighted + techniques. See TTP_TAGGING.md §"UI surface — Empty state". + """ + + name: str = "DECNET TTP coverage" + versions: dict[str, str] = Field( + default_factory=lambda: { + "attack": "15", + "navigator": "5.1.0", + "layer": "4.5", + } + ) + domain: str = "enterprise-attack" + description: str = "" + techniques: list[NavigatorTechnique] = Field(default_factory=list) diff --git a/decnet/web/router/__init__.py b/decnet/web/router/__init__.py index c7138d27..fafcbe3e 100644 --- a/decnet/web/router/__init__.py +++ b/decnet/web/router/__init__.py @@ -53,6 +53,13 @@ from .topology import topology_router from .canary import canary_router from .deckies import deckies_router from .webhooks import webhooks_router +from .ttp.api_get_techniques import router as ttp_techniques_router +from .ttp.api_get_by_identity import router as ttp_by_identity_router +from .ttp.api_get_by_attacker import router as ttp_by_attacker_router +from .ttp.api_get_by_campaign import router as ttp_by_campaign_router +from .ttp.api_get_by_session import router as ttp_by_session_router +from .ttp.api_get_rules import router as ttp_rules_router +from .ttp.api_export_navigator import router as ttp_navigator_router api_router = APIRouter( # Every route under /api/v1 is auth-guarded (either by an explicit @@ -163,3 +170,14 @@ api_router.include_router(deckies_router) # External webhook subscriptions (SIEM/SOAR egress) api_router.include_router(webhooks_router) + +# TTP Tagging — see development/TTP_TAGGING.md. Contract phase: every +# handler returns the typed empty value; impl phase wires the repo +# and rule engine. +api_router.include_router(ttp_techniques_router) +api_router.include_router(ttp_by_identity_router) +api_router.include_router(ttp_by_attacker_router) +api_router.include_router(ttp_by_campaign_router) +api_router.include_router(ttp_by_session_router) +api_router.include_router(ttp_rules_router) +api_router.include_router(ttp_navigator_router) diff --git a/decnet/web/router/ttp/__init__.py b/decnet/web/router/ttp/__init__.py new file mode 100644 index 00000000..87a61a9f --- /dev/null +++ b/decnet/web/router/ttp/__init__.py @@ -0,0 +1,6 @@ +"""TTP-tagging API router package — see development/TTP_TAGGING.md. + +Contract phase E.1.9: handlers return typed empty values. The repo +methods (E.1.10) and engine (E.3) land separately; the router shape + +auth gating + OpenAPI surface are stable from this commit forward. +""" diff --git a/decnet/web/router/ttp/api_export_navigator.py b/decnet/web/router/ttp/api_export_navigator.py new file mode 100644 index 00000000..74a84835 --- /dev/null +++ b/decnet/web/router/ttp/api_export_navigator.py @@ -0,0 +1,56 @@ +"""GET /api/v1/ttp/export/navigator{,/identity/{uuid}} — Navigator JSON layer. + +Empty-but-valid Navigator layer at contract phase per TTP_TAGGING.md +§"UI surface — Empty state": a SOC analyst pasting the JSON into the +official MITRE ATT&CK Navigator sees the file load with no +highlighted techniques — correct, not broken. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import NavigatorLayer +from decnet.web.dependencies import require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/export/navigator", + tags=["TTP Tagging"], + response_model=NavigatorLayer, + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.ttp.export_navigator_fleet") +async def api_export_navigator_fleet( + user: dict[str, Any] = Depends(require_viewer), +) -> NavigatorLayer: + """Fleet-wide Navigator layer. Empty-but-valid at contract phase.""" + return NavigatorLayer(name="DECNET TTP coverage — fleet") + + +@router.get( + "/ttp/export/navigator/identity/{identity_uuid}", + tags=["TTP Tagging"], + response_model=NavigatorLayer, + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Identity not found"}, + }, +) +@_traced("api.ttp.export_navigator_identity") +async def api_export_navigator_identity( + identity_uuid: str, + user: dict[str, Any] = Depends(require_viewer), +) -> NavigatorLayer: + """Per-Identity Navigator layer (the SOC demo).""" + return NavigatorLayer( + name=f"DECNET TTP coverage — identity {identity_uuid}", + ) diff --git a/decnet/web/router/ttp/api_get_by_attacker.py b/decnet/web/router/ttp/api_get_by_attacker.py new file mode 100644 index 00000000..baccc09d --- /dev/null +++ b/decnet/web/router/ttp/api_get_by_attacker.py @@ -0,0 +1,35 @@ +"""GET /api/v1/ttp/by-attacker/{attacker_uuid} — per-IP TTP slice. + +Backs the AttackerDetail page's TTP section. See TTP_TAGGING.md +§"UI surface" + project_attacker_detail_keep memory. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import IdentityTechniqueRow +from decnet.web.dependencies import require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/by-attacker/{attacker_uuid}", + tags=["TTP Tagging"], + response_model=list[IdentityTechniqueRow], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Attacker not found"}, + }, +) +@_traced("api.ttp.by_attacker") +async def api_ttp_by_attacker( + attacker_uuid: str, + user: dict[str, Any] = Depends(require_viewer), +) -> list[IdentityTechniqueRow]: + """Per-Attacker (per-IP) TTP rows. Empty at contract phase.""" + return [] diff --git a/decnet/web/router/ttp/api_get_by_campaign.py b/decnet/web/router/ttp/api_get_by_campaign.py new file mode 100644 index 00000000..cbca50c0 --- /dev/null +++ b/decnet/web/router/ttp/api_get_by_campaign.py @@ -0,0 +1,31 @@ +"""GET /api/v1/ttp/by-campaign/{campaign_uuid} — campaign-wide TTP rollup.""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import CampaignTechniqueRow +from decnet.web.dependencies import require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/by-campaign/{campaign_uuid}", + tags=["TTP Tagging"], + response_model=list[CampaignTechniqueRow], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Campaign not found"}, + }, +) +@_traced("api.ttp.by_campaign") +async def api_ttp_by_campaign( + campaign_uuid: str, + user: dict[str, Any] = Depends(require_viewer), +) -> list[CampaignTechniqueRow]: + """Campaign-rollup TTP rows. Empty at contract phase.""" + return [] diff --git a/decnet/web/router/ttp/api_get_by_identity.py b/decnet/web/router/ttp/api_get_by_identity.py new file mode 100644 index 00000000..c81fab57 --- /dev/null +++ b/decnet/web/router/ttp/api_get_by_identity.py @@ -0,0 +1,35 @@ +"""GET /api/v1/ttp/by-identity/{identity_uuid} — Identity-scoped TTP rollup. + +Primary endpoint for the IdentityDetail "TTPs Observed" section. See +TTP_TAGGING.md §"UI surface". Empty at contract phase (E.1.9). +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import IdentityTechniqueRow +from decnet.web.dependencies import require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/by-identity/{identity_uuid}", + tags=["TTP Tagging"], + response_model=list[IdentityTechniqueRow], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Identity not found"}, + }, +) +@_traced("api.ttp.by_identity") +async def api_ttp_by_identity( + identity_uuid: str, + user: dict[str, Any] = Depends(require_viewer), +) -> list[IdentityTechniqueRow]: + """Per-Identity TTP heatmap rows. Empty at contract phase.""" + return [] diff --git a/decnet/web/router/ttp/api_get_by_session.py b/decnet/web/router/ttp/api_get_by_session.py new file mode 100644 index 00000000..f67afb8a --- /dev/null +++ b/decnet/web/router/ttp/api_get_by_session.py @@ -0,0 +1,31 @@ +"""GET /api/v1/ttp/by-session/{session_id} — session timeline of TTP tags.""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import IdentityTechniqueRow +from decnet.web.dependencies import require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/by-session/{session_id}", + tags=["TTP Tagging"], + response_model=list[IdentityTechniqueRow], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Session not found"}, + }, +) +@_traced("api.ttp.by_session") +async def api_ttp_by_session( + session_id: str, + user: dict[str, Any] = Depends(require_viewer), +) -> list[IdentityTechniqueRow]: + """Per-session TTP tag timeline. Empty at contract phase.""" + return [] diff --git a/decnet/web/router/ttp/api_get_rules.py b/decnet/web/router/ttp/api_get_rules.py new file mode 100644 index 00000000..74c99a80 --- /dev/null +++ b/decnet/web/router/ttp/api_get_rules.py @@ -0,0 +1,128 @@ +"""TTP rule catalogue + admin-only state mutations. + +Three endpoints in one router: + +* ``GET /api/v1/ttp/rules`` — viewer-readable rule list +* ``POST /api/v1/ttp/rules/{rule_id}/state`` — admin: set state +* ``DELETE /api/v1/ttp/rules/{rule_id}/state`` — admin: revert to default + +Per the project's "no client-side role checks" rule, the admin guard +is server-side via :func:`require_admin`. Per +``feedback_schemathesis_400.md``, the POST handler parses the body +manually and returns ``400`` on a malformed JSON body so the +documented status code matches reality. +""" +from __future__ import annotations + +from typing import Any + +import json + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import ValidationError + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import ( + RuleCatalogueRow, + RuleStateRequest, + RuleStateResponse, +) +from decnet.web.dependencies import require_admin, require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/rules", + tags=["TTP Tagging"], + response_model=list[RuleCatalogueRow], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.ttp.list_rules") +async def api_list_rules( + user: dict[str, Any] = Depends(require_viewer), +) -> list[RuleCatalogueRow]: + """Operator-facing rule catalogue. Empty at contract phase.""" + return [] + + +@router.post( + "/ttp/rules/{rule_id}/state", + tags=["TTP Tagging"], + response_model=RuleStateResponse, + responses={ + 400: {"description": "Bad Request (malformed JSON or invalid body)"}, + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Rule not found"}, + }, +) +@_traced("api.ttp.set_rule_state") +async def api_set_rule_state( + rule_id: str, + request: Request, + admin: dict[str, Any] = Depends(require_admin), +) -> RuleStateResponse: + """Set operational state (disable / clip / TTL) on a rule. + + Body parse is manual so a malformed JSON body surfaces as the + documented ``400`` rather than the framework default of ``422`` + (per ``feedback_schemathesis_400.md``). + """ + try: + raw = await request.json() + except json.JSONDecodeError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Body must be valid JSON", + ) from exc + try: + body = RuleStateRequest.model_validate(raw) + except ValidationError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"Invalid rule-state body: {exc.errors()}", + ) from exc + + # Contract phase: no persistence yet (E.1.10 / E.3 lands the repo + # write). Echo the requested state back so the response shape is + # exercisable and OpenAPI-stable. + return RuleStateResponse( + rule_id=rule_id, + state=body.state, + confidence_max=body.confidence_max, + expires_at=body.expires_at, + reason=body.reason, + set_by=str(admin.get("sub", "")), + set_at=None, + ) + + +@router.delete( + "/ttp/rules/{rule_id}/state", + tags=["TTP Tagging"], + response_model=RuleStateResponse, + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + 404: {"description": "Rule not found"}, + }, +) +@_traced("api.ttp.revert_rule_state") +async def api_revert_rule_state( + rule_id: str, + admin: dict[str, Any] = Depends(require_admin), +) -> RuleStateResponse: + """Revert a rule to the default ``enabled`` state.""" + return RuleStateResponse( + rule_id=rule_id, + state="enabled", + confidence_max=None, + expires_at=None, + reason=None, + set_by=str(admin.get("sub", "")), + set_at=None, + ) diff --git a/decnet/web/router/ttp/api_get_techniques.py b/decnet/web/router/ttp/api_get_techniques.py new file mode 100644 index 00000000..46fd2718 --- /dev/null +++ b/decnet/web/router/ttp/api_get_techniques.py @@ -0,0 +1,34 @@ +"""GET /api/v1/ttp/techniques — distinct techniques observed fleet-wide. + +Returns an empty list at contract phase (E.1.9). Repo wiring lands in +E.1.10 / E.3 implementation; the response shape is stable from here. +""" +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends + +from decnet.telemetry import traced as _traced +from decnet.web.db.models import TechniqueRollupRow +from decnet.web.dependencies import require_viewer + +router = APIRouter() + + +@router.get( + "/ttp/techniques", + tags=["TTP Tagging"], + response_model=list[TechniqueRollupRow], + responses={ + 401: {"description": "Could not validate credentials"}, + 403: {"description": "Insufficient permissions"}, + }, +) +@_traced("api.ttp.list_techniques") +async def api_list_techniques( + user: dict[str, Any] = Depends(require_viewer), +) -> list[TechniqueRollupRow]: + """Distinct techniques observed across the fleet, with counts and + last-seen timestamps. Empty list at contract phase.""" + return [] diff --git a/development/TTP_TAGGING.md b/development/TTP_TAGGING.md index 309aa8f7..867ba297 100644 --- a/development/TTP_TAGGING.md +++ b/development/TTP_TAGGING.md @@ -2344,6 +2344,8 @@ unrelated events. **E.1.9 — API contract** (`decnet/web/router/ttp/`) +**Status:** ✅ done. + - Six FastAPI router files matching the API surface above: `api_get_techniques.py`, `api_get_by_identity.py`, `api_get_by_attacker.py`, `api_get_by_campaign.py`, diff --git a/tests/api/ttp/test_get_endpoints.py b/tests/api/ttp/test_get_endpoints.py index 32ed3652..2c7e3098 100644 --- a/tests/api/ttp/test_get_endpoints.py +++ b/tests/api/ttp/test_get_endpoints.py @@ -1,15 +1,10 @@ """E.2.8 — GET endpoint shape + auth contract for /api/v1/ttp/*. -Today no TTP router is mounted under :mod:`decnet.web.api`; every -assertion that the documented endpoint exists (200 with a JWT, 401 -without) lives behind ``@pytest.mark.xfail(strict=True)`` so this -suite is GREEN today and trips the day E.3.8 wires the router. - -The router-presence sanity test is the only assertion that compiles -GREEN today: it asserts that AT LEAST ONE of the documented paths -returns something OTHER than 404 (i.e. the router exists). It is -xfail-strict — when the router lands, the marker flips and the suite -exercises the rest of the contract. +The TTP router landed at E.1.9 (contract phase) returning typed +empty values. Every documented GET endpoint is now exercised: 200 +with a JWT, 401 without. Repo-backed data lands at E.3 implementation +phase; this suite stays green across that transition because the +shape contract is stable from E.1.9 forward. """ from __future__ import annotations @@ -58,10 +53,6 @@ _GET_ENDPOINTS: list[str] = [ @pytest.mark.parametrize("path", _GET_ENDPOINTS) -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_get_returns_200_with_jwt( client: httpx.AsyncClient, auth_token: str, path: str, @@ -77,10 +68,6 @@ async def test_get_returns_200_with_jwt( @pytest.mark.parametrize("path", _GET_ENDPOINTS) -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_get_returns_401_without_jwt( client: httpx.AsyncClient, path: str, @@ -95,10 +82,6 @@ async def test_get_returns_401_without_jwt( # ─── Router-presence sanity ────────────────────────────────────────────────── -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: /api/v1/ttp router not yet mounted", -) @pytest.mark.asyncio async def test_ttp_router_is_mounted( client: httpx.AsyncClient, auth_token: str, diff --git a/tests/api/ttp/test_response_schemas.py b/tests/api/ttp/test_response_schemas.py index 5073fba8..acff2084 100644 --- a/tests/api/ttp/test_response_schemas.py +++ b/tests/api/ttp/test_response_schemas.py @@ -42,10 +42,6 @@ def test_placeholder_golden_is_stable() -> None: ) -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet contributing to OpenAPI", -) def test_openapi_includes_ttp_paths() -> None: """Every documented TTP endpoint must appear in the live OpenAPI schema once the router lands. Pinned as a strict-xfail so the diff --git a/tests/api/ttp/test_rules_state.py b/tests/api/ttp/test_rules_state.py index 6414d019..1ddd01ec 100644 --- a/tests/api/ttp/test_rules_state.py +++ b/tests/api/ttp/test_rules_state.py @@ -5,8 +5,9 @@ disable/clip/TTL knobs. Per the project's "no client-side role checks" rule, the assertions here all hit the server and inspect the response — never a feature flag, never a route table. -Today the router does not exist; every assertion is -``xfail(strict=True)`` and trips when E.3.8 wires it. +The router landed at E.1.9 with the admin guard on POST/DELETE; the +assertions exercise the auth + body-validation contract directly. +Persistence (state actually surviving a roundtrip) lands in E.3. """ from __future__ import annotations @@ -29,10 +30,6 @@ def _path() -> str: # ─── POST /rules/{rule_id}/state ───────────────────────────────────────────── -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_post_state_without_jwt_is_401( client: httpx.AsyncClient, @@ -41,10 +38,6 @@ async def test_post_state_without_jwt_is_401( assert res.status_code == 401, res.text -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_post_state_non_admin_is_403_server_side( client: httpx.AsyncClient, viewer_token: str, @@ -59,10 +52,6 @@ async def test_post_state_non_admin_is_403_server_side( assert res.status_code == 403, res.text -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_post_state_admin_is_200( client: httpx.AsyncClient, auth_token: str, @@ -73,10 +62,6 @@ async def test_post_state_admin_is_200( assert res.status_code == 200, res.text -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_post_state_malformed_body_is_400( client: httpx.AsyncClient, auth_token: str, @@ -98,10 +83,6 @@ async def test_post_state_malformed_body_is_400( # ─── DELETE /rules/{rule_id}/state ─────────────────────────────────────────── -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_delete_state_without_jwt_is_401( client: httpx.AsyncClient, @@ -110,10 +91,6 @@ async def test_delete_state_without_jwt_is_401( assert res.status_code == 401, res.text -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_delete_state_non_admin_is_403_server_side( client: httpx.AsyncClient, viewer_token: str, @@ -122,10 +99,6 @@ async def test_delete_state_non_admin_is_403_server_side( assert res.status_code == 403, res.text -@pytest.mark.xfail( - strict=True, - reason="impl phase E.3.8: TTP router not yet mounted", -) @pytest.mark.asyncio async def test_delete_state_admin_is_204_or_200( client: httpx.AsyncClient, auth_token: str,