diff --git a/.github/product-version-operations/l3-project-dag-read-20261001.json b/.github/product-version-operations/l3-project-dag-read-20261001.json new file mode 100644 index 00000000..8d2fb065 --- /dev/null +++ b/.github/product-version-operations/l3-project-dag-read-20261001.json @@ -0,0 +1,16 @@ +{ + "baseline_version": "2.7.39", + "classification_rationale": "L3-FORGE-PROJECT-DAG-READ-V1-20261001", + "component": "product", + "event_lineage": "L3-FORGE-PROJECT-DAG-READ-V1-20261001", + "expected_head": "35815de3c04e24c3a913a4f95cfe5af8c27dd768", + "operation_id": "l3-project-dag-read-20261001", + "policy_revision": "forge-bootstrap-release-cadence-v2", + "product": "forge", + "projection_paths": "product-version.json", + "release_class": "PATCH", + "requested_bump": "patch", + "requested_version": null, + "schema_version": "1", + "target_version": "2.7.40" +} diff --git a/docs/architecture/FORGE_PROJECT_DAG_READ_V1.md b/docs/architecture/FORGE_PROJECT_DAG_READ_V1.md new file mode 100644 index 00000000..604e16c8 --- /dev/null +++ b/docs/architecture/FORGE_PROJECT_DAG_READ_V1.md @@ -0,0 +1,11 @@ +# Forge project-bound Mission/Action read projection V1 + +This is a bounded producer subset of `PRM-F-CONTRACT` and `PRM-F-PROJECTION` through the existing Forge Server `FH-HTTP` surface. It is not the complete project-loop capability or a new Project registration service. The current Forge Server binds at most one Engineering Platform project and repository through its existing versioned peer configuration. Both endpoints use the Server's instance-owned bearer credential and return JSON with `Cache-Control: no-store`. + +- `GET /v1/projects` returns `project-roadmap-read/v1`, the exact Forge instance ID and either one configured EP project/repository binding or `UNCONFIGURED` with an empty list. The list is a binding readback, not an EP availability check or a project creation operation. +- `GET /v1/projects/{project_id}/roadmap` accepts only the configured project ID. It reads up to 256 actual persisted Forge Mission states within one SQLite read transaction, in stable Mission-ID order. They are nested under `repository_scope.missions`, not represented as members of the EP project: Forge persists a repository source for each Mission but no project ID. `project_mission_attribution` is therefore `UNAVAILABLE`, including after a same-repository project rebind. Each repository-scoped Mission includes its actual lifecycle, revision, presentation group and stored Action IDs/status/dependency edges. `APPROVED_PLANNABLE` is shown as `APPROVED_PENDING`; terminal `COMPLETED`/`ARCHIVED` are `HISTORY`; other recorded states remain visible in `ACTIVE`, including blocked and recoverable failures. These are display groups, not changed lifecycle states. +- The projection reports source freshness and marks `project_capability_graph` and `candidate_and_expected_views` `UNAVAILABLE`. It does not infer project capability edges, approvals, completion, time estimates or runtime concurrency from documentary DAGs. It makes no EP request and performs no planning, admission, Mission allocation, provider call or state mutation. + +A missing binding yields `UNCONFIGURED` in the list and `503 PROJECT_UNCONFIGURED` for detail. A different project yields `404 PROJECT_MISSING`; malformed IDs yield `400 PROJECT_REFERENCE_INVALID`. Inconsistent installed identity or binding, unreadable storage and more than 256 Missions return an explicit `503`; inconsistent Mission identity or Action edges/cycles return `409`. No partial graph is represented as complete. Authentication failure returns `401`; unsupported methods return `405`. The exact route inventory is checked against OpenAPI and Postman in the repository validation suite. + +This producer can be exercised from a fresh installed Forge wheel. Workspace may later consume it only through the authenticated HTTP contract and must not treat `repository_scope.missions` as project membership. Full project attribution, capability DAGs, Candidate/Expected views, cross-Mission edges, remote HTTPS, Workspace peer integration and `PRM-F-Q` remain under their owning dependencies and evidence gates. Forge runtime installation and public registry release are separate product operations. diff --git a/docs/roadmap/LIVE_PROJECT_ROADMAP_MANAGEMENT_V1.md b/docs/roadmap/LIVE_PROJECT_ROADMAP_MANAGEMENT_V1.md index 1c223710..3fe2cd0c 100644 --- a/docs/roadmap/LIVE_PROJECT_ROADMAP_MANAGEMENT_V1.md +++ b/docs/roadmap/LIVE_PROJECT_ROADMAP_MANAGEMENT_V1.md @@ -25,6 +25,12 @@ F5 is not a scheduler; its parent refinement is navigation, not a backward edge. All nine nodes remain PLANNED with empty qualification evidence. External evidence is a real producer subset, not permission to allocate peer implementation. +The bounded [project-bound Mission/Action read projection](../architecture/FORGE_PROJECT_DAG_READ_V1.md) +can deliver a useful HTTP producer subset before the full project-loop graph. +Its configured-project binding, repository-scoped installed Mission/Action edges +and explicit unavailable project membership and graph fields do not mark `PRM-F-PROJECTION`, its capability graph or `PRM-F-Q` +complete. Workspace peer consumption remains separately owned. + PRM-F-PROJECTION consumes F2/FH operation/HTTP subsets. PRM-F-ELIGIBILITY and PRM-F-ACTIVATION consume the relevant existing intake, approval, progression, claim and recovery services. PRM-F-CHANGES reuses the existing context/observer diff --git a/forge/api/server-openapi-v1.json b/forge/api/server-openapi-v1.json index c1abf303..73e100b6 100644 --- a/forge/api/server-openapi-v1.json +++ b/forge/api/server-openapi-v1.json @@ -464,6 +464,32 @@ } ] } + }, + "/v1/projects": { + "get": { + "summary": "Read the configured project binding for this Forge instance", + "description": "Read-only. Returns an empty list with UNCONFIGURED when no current binding exists; never creates a Project.", + "responses": { + "200": {"description": "Configured project or explicit empty state"}, + "401": {"description": "Authentication required"}, + "503": {"description": "Installed source or binding is unavailable"} + } + } + }, + "/v1/projects/{project_id}/roadmap": { + "get": { + "summary": "Read the persisted Mission and Action DAG subset for one configured project", + "description": "The response explicitly marks capability, Candidate, Expected and cross-Mission project graph views unavailable. No peer call or Mission mutation occurs.", + "parameters": [{"name": "project_id", "in": "path", "required": true, "schema": {"type": "string", "pattern": "^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$"}}], + "responses": { + "200": {"description": "Consistent installed Mission/Action snapshot"}, + "400": {"description": "Invalid project reference"}, + "401": {"description": "Authentication required"}, + "404": {"description": "Project is not the configured project"}, + "409": {"description": "Stored Mission/Action graph is inconsistent"}, + "503": {"description": "Project binding or bounded projection is unavailable"} + } + } } }, "components": { diff --git a/forge/api/server-postman-v1.json b/forge/api/server-postman-v1.json index 30447f26..2b5abca0 100644 --- a/forge/api/server-postman-v1.json +++ b/forge/api/server-postman-v1.json @@ -15,6 +15,10 @@ { "key": "missionId", "value": "MISSION-0001" + }, + { + "key": "projectId", + "value": "replace-with-configured-project-id" } ], "auth": { @@ -493,6 +497,30 @@ ] } } + }, + { + "name": "List configured Forge project", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/v1/projects", + "host": ["{{baseUrl}}"], + "path": ["v1", "projects"] + } + } + }, + { + "name": "Read configured project Mission and Action DAG", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{baseUrl}}/v1/projects/{{projectId}}/roadmap", + "host": ["{{baseUrl}}"], + "path": ["v1", "projects", "{{projectId}}", "roadmap"] + } + } } ] } diff --git a/forge/operations_read_api.py b/forge/operations_read_api.py index 6a17c096..d0750534 100644 --- a/forge/operations_read_api.py +++ b/forge/operations_read_api.py @@ -30,6 +30,8 @@ API_VERSION = "1" DEFAULT_STALE_AFTER = timedelta(minutes=5) _MISSION_PATH = re.compile(r"^/v1/missions/([^/]+)$") +_PROJECT_ROADMAP_PATH = re.compile(r"^/v1/projects/([^/]+)/roadmap$") +_PROJECT_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,255}\Z") _SENSITIVE_KEY = re.compile(r"(?:authorization|bearer|credential|password|secret|token)", re.IGNORECASE) _BEARER_VALUE = re.compile(r"(?i)\bbearer\s+[^\s,;]+") _KEYCHAIN_REFERENCE = re.compile(r"(?i)\bkeychain://[^\s,;]+") @@ -263,6 +265,108 @@ def mission_detail(self, mission_id: str) -> dict[str, Any]: "mission": projection, }) + def project_index(self) -> dict[str, Any]: + """Expose only the project selected by this exact Forge instance binding.""" + with self._runtime_snapshot() as (connection, metadata): + binding = self._project_binding(connection, metadata) + return { + "api_version": API_VERSION, + "contract_version": "project-roadmap-read/v1", + "instance_id": metadata["runtime_id"], + "availability": "AVAILABLE" if binding is not None else "UNCONFIGURED", + "projects": [] if binding is None else [{ + "project_id": binding.ep_project_id, + "repository_id": binding.ep_repository_id, + "roadmap_url": f"/v1/projects/{binding.ep_project_id}/roadmap", + }], + "read_only": True, + } + + def project_roadmap(self, project_id: str) -> dict[str, Any]: + """Project the installed Mission/Action subset from one SQLite snapshot.""" + if _PROJECT_ID.fullmatch(project_id) is None: + raise OperationsProjectionError("PROJECT_REFERENCE_INVALID", "Project reference is invalid", status=400) + with self._runtime_snapshot() as (connection, metadata): + binding = self._project_binding(connection, metadata) + if binding is None: + raise OperationsProjectionError("PROJECT_UNCONFIGURED", "Project binding is not configured", status=503) + if project_id != binding.ep_project_id: + raise OperationsProjectionError("PROJECT_MISSING", "Project was not found", status=404) + rows = connection.execute("SELECT mission_id FROM mission_state ORDER BY mission_id LIMIT 257").fetchall() + if len(rows) > 256: + raise OperationsProjectionError("PROJECT_TOO_LARGE", "Project projection exceeds the bounded read limit", status=503) + missions: list[dict[str, Any]] = [] + for (mission_id,) in rows: + projection, state = mission_status_projection(connection, mission_id) + if state.mission_id != mission_id or state.mission.get("id") != mission_id: + raise OperationsProjectionError("PROJECT_AMBIGUOUS", "Mission identity is inconsistent", status=409) + source = state.mission.get("repository_evidence_source") + if not isinstance(source, Mapping) or source.get("repository_id") != binding.ep_repository_id: + raise OperationsProjectionError("PROJECT_AMBIGUOUS", "Mission repository binding is inconsistent", status=409) + actions = [_project_action(item) for item in state.actions] + ids = {item.get("id") for item in actions} + if len(ids) != len(actions) or None in ids or any( + dependency not in ids + for item in actions for dependency in item.get("dependencies", ()) + ): + raise OperationsProjectionError("PROJECT_DAG_INVALID", "Mission Action graph is inconsistent", status=409) + dependencies = {item["id"]: tuple(item.get("dependencies", ())) for item in actions} + active: set[str] = set() + complete: set[str] = set() + + def visit(action_id: str) -> None: + if action_id in active: + raise OperationsProjectionError("PROJECT_DAG_INVALID", "Mission Action graph has a cycle", status=409) + if action_id in complete: + return + active.add(action_id) + for predecessor in dependencies[action_id]: + visit(predecessor) + active.remove(action_id) + complete.add(action_id) + + for action_id in dependencies: + visit(action_id) + status = str(projection["status"]) + group = ("APPROVED_PENDING" if status == "APPROVED_PLANNABLE" else + "HISTORY" if status in {"COMPLETED", "ARCHIVED"} else "ACTIVE") + missions.append({ + "mission_id": mission_id, "group": group, "status": status, + "revision": projection["revision"], + "actions": [{key: item[key] for key in ("id", "status", "dependencies") if key in item} + for item in actions], + }) + return { + "api_version": API_VERSION, + "contract_version": "project-roadmap-read/v1", + "instance_id": metadata["runtime_id"], + "project_id": project_id, + "repository_id": binding.ep_repository_id, + "availability": "AVAILABLE", + "freshness": _freshness(metadata.get("last_access_at"), now=self.clock(), stale_after=self.stale_after), + "graph_kind": "REPOSITORY_MISSION_ACTION_SUBSET", + "project_mission_attribution": "UNAVAILABLE", + "project_capability_graph": "UNAVAILABLE", + "candidate_and_expected_views": "UNAVAILABLE", + "repository_scope": {"repository_id": binding.ep_repository_id, "missions": missions}, + "read_only": True, + } + + def _project_binding(self, connection: sqlite3.Connection, metadata: Mapping[str, str]): + """Join peer readback to the same storage generation as Mission state.""" + readback = read_peer_configuration(self.root) + if readback.runtime_id != metadata.get("runtime_id") or readback.status not in { + "CONFIGURED", "NOT_CONFIGURED", "DETACHED", + }: + raise OperationsProjectionError("PROJECT_UNAVAILABLE", "Project binding is unavailable", status=503) + row = connection.execute( + "SELECT configuration_digest FROM execution_host_peer_configuration WHERE singleton=1" + ).fetchone() + expected = readback.configuration.configuration_digest if readback.configuration is not None else None + if (row[0] if row is not None else None) != expected: + raise OperationsProjectionError("PROJECT_UNAVAILABLE", "Project binding changed during readback", status=503) + return readback.configuration + def installed_health_snapshot(self) -> dict[str, Any]: """Return the canonical bounded installed-health assessment.""" return InstalledHealthSnapshotService( @@ -335,6 +439,11 @@ def handle(self, method: str, target: str, authorization: str | None) -> APIResp if path == "/v1/health": body = self.service.installed_health_snapshot() return APIResponse(200 if body.get("outcome") == "HEALTHY" else 503, body, headers) + if path == "/v1/projects": + return APIResponse(200, self.service.project_index(), headers) + project_match = _PROJECT_ROADMAP_PATH.fullmatch(path) + if project_match: + return APIResponse(200, self.service.project_roadmap(unquote(project_match.group(1))), headers) match = _MISSION_PATH.fullmatch(path) if match: return APIResponse(200, self.service.mission_detail(unquote(match.group(1))), headers) diff --git a/forge/server_runtime.py b/forge/server_runtime.py index 9ff1fbf7..46a7d00e 100644 --- a/forge/server_runtime.py +++ b/forge/server_runtime.py @@ -71,6 +71,8 @@ ("GET", "/v1/instance"), ("GET", "/v1/version"), ("GET", "/v1/provider-context"), + ("GET", "/v1/projects"), + ("GET", "/v1/projects/{project_id}/roadmap"), ("POST", "/v1/provider-context"), ("GET", "/v1/execution-host/preflight"), ("POST", "/v1/execution-host/configure"), @@ -522,6 +524,8 @@ def handle(self, method: str, target: str, authorization: str | None, "code": "AUTHENTICATION_REQUIRED", "message": "Authentication is required", }}, headers) path = urlsplit(target).path + if path == "/v1/projects" or path.startswith("/v1/projects/"): + return self._read_api.handle(method, target, authorization) if method == "GET" and ( path in {"/v1/status", "/v1/health"} or path.startswith("/v1/missions/") and not path.endswith(("/controller/start", "/controller/reopen")) diff --git a/product-version.json b/product-version.json index b0f4d020..5d209506 100644 --- a/product-version.json +++ b/product-version.json @@ -1,5 +1,5 @@ { "product": "forge", "schema_version": 1, - "version": "2.7.39" + "version": "2.7.40" } diff --git a/tests/test_operations_read_api.py b/tests/test_operations_read_api.py index 2813808a..0dee8582 100644 --- a/tests/test_operations_read_api.py +++ b/tests/test_operations_read_api.py @@ -2,20 +2,26 @@ from __future__ import annotations from dataclasses import replace +from http.client import HTTPConnection import json from pathlib import Path from tempfile import TemporaryDirectory from threading import Thread +from types import SimpleNamespace import unittest from urllib.error import HTTPError from urllib.request import Request, urlopen from forge.models.action import EngineeringAction, EngineeringActionStatus from forge.models.architecture_mission import ArchitectureMission, ArchitectureMissionStatus +from forge.models.criterion_assessment import ApprovedRepositoryEvidenceSource from forge.models.intent import EngineeringIntent, IntentCategory, IntentReference, IntentTraceability from forge.models.mission import EngineeringMission, MissionIntentMembership, MissionScope +from forge.execution_host_configuration import EngineeringPlatformPeerConfigurationService from forge.operations_read_api import InstalledOperationsReadService, OperationsReadAPI, make_server from forge.runtime import RuntimeBootstrap +from forge.secure_store import SecretReference +from forge.server_runtime import ForgeServerAPI, make_server as make_forge_server from forge.state import MissionStateStore @@ -115,6 +121,229 @@ def test_absent_runtime_is_unavailable_without_initialization(self) -> None: self.assertFalse(absent_root.exists()) +class TestProjectRoadmapEndpoint(_InstalledFixture): + def configure_project(self, project_id: str = "forge-project", *, replace: bool = False, + expected_revision: int | None = None, + expected_digest: str | None = None): + return EngineeringPlatformPeerConfigurationService(self.root).configure( + binding_id="ep-primary", endpoint="https://ep.test", + expected_ep_instance_id="ep-instance-1", ep_consumer_id="forge-consumer-1", + execution_host_id="engineering-platform", ep_project_id=project_id, + ep_repository_id="forge-repository", repository_identity="forge-source", + credential_reference=SecretReference.parse("keychain://forge.ep/consumer"), + operator_id="local-admin", occurred_at="2026-09-10T18:00:00Z", + replace=replace, expected_revision=expected_revision, expected_digest=expected_digest, + ) + + def create_mission(self, number: str = "0042") -> None: + mission = ArchitectureMission( + id=f"MISSION-{number}", candidate_id=f"CANDIDATE-{number}", title="Read project", + summary="Project existing work", business_objective="Show actual project work", + business_value="Read-only overview", architecture_review_reference=f"review-{number}", + mission_recommendation_reference=f"recommendation-{number}", scope=("forge-repository",), + status=ArchitectureMissionStatus.APPROVED_FOR_ENGINEERING, + repository_evidence_source=ApprovedRepositoryEvidenceSource("forge-repository", "pcvantol/forge"), + ) + reference = IntentReference("source", "1", "docs/source.md") + intent = EngineeringIntent( + f"INTENT-{number}", "1", "Projection", "Read the existing DAG", + IntentCategory.IMPLEMENTATION, + IntentTraceability((reference,), (reference,), (reference,), (reference,), (reference,)), + ) + first = EngineeringAction(1, f"ACTION-{number}-A", intent.id, "1", "Read", ("proof",)) + second = EngineeringAction(2, f"ACTION-{number}-B", intent.id, "1", "Project", ("proof",), + dependencies=(first.id,)) + MissionStateStore(self.database, data_root=str(self.root)).create( + mission, (intent,), (first, second), occurred_at="2026-09-21T05:00:00Z", + ) + + def test_unconfigured_project_is_empty_and_read_only(self) -> None: + before = self.snapshot() + denied = self.api.handle("GET", "/v1/projects", None) + listed = self.api.handle("GET", "/v1/projects", "Bearer " + CREDENTIAL) + detail = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual((denied.status, listed.status, detail.status), (401, 200, 503)) + self.assertEqual(listed.body["availability"], "UNCONFIGURED") + self.assertEqual(listed.body["projects"], []) + self.assertEqual(detail.body["error"]["code"], "PROJECT_UNCONFIGURED") + self.assertEqual(self.snapshot(), before) + + def test_configured_project_returns_actual_mission_action_graph(self) -> None: + self.configure_project() + empty = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual(empty.body["repository_scope"]["missions"], []) + self.create_mission() + before = self.snapshot() + listed = self.api.handle("GET", "/v1/projects", "Bearer " + CREDENTIAL) + response = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual((listed.status, response.status), (200, 200)) + self.assertEqual(listed.body["projects"][0]["project_id"], "forge-project") + self.assertEqual(response.body["project_capability_graph"], "UNAVAILABLE") + self.assertEqual(response.body["candidate_and_expected_views"], "UNAVAILABLE") + self.assertEqual(response.body["graph_kind"], "REPOSITORY_MISSION_ACTION_SUBSET") + self.assertEqual(response.body["project_mission_attribution"], "UNAVAILABLE") + self.assertEqual(response.body["repository_scope"]["missions"][0]["group"], "ACTIVE") + self.assertEqual(response.body["repository_scope"]["missions"][0]["actions"][1]["dependencies"], ["ACTION-0042-A"]) + self.assertNotIn("keychain://", json.dumps(response.body)) + self.assertEqual(self.snapshot(), before) + + def test_multiple_missions_have_stable_order_without_claiming_runtime_parallelism(self) -> None: + self.configure_project() + self.create_mission("0043") + self.create_mission("0042") + response = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual(response.status, 200) + self.assertEqual([item["mission_id"] for item in response.body["repository_scope"]["missions"]], + ["MISSION-0042", "MISSION-0043"]) + self.assertEqual([item["group"] for item in response.body["repository_scope"]["missions"]], ["ACTIVE", "ACTIVE"]) + + def test_same_repository_project_rebind_does_not_claim_mission_membership(self) -> None: + old = self.configure_project() + self.create_mission() + self.configure_project("new-project", replace=True, + expected_revision=old.configuration_revision, + expected_digest=old.configuration_digest) + response = self.api.handle("GET", "/v1/projects/new-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual(response.status, 200) + self.assertEqual(response.body["project_id"], "new-project") + self.assertNotIn("missions", response.body) + self.assertEqual(response.body["project_mission_attribution"], "UNAVAILABLE") + self.assertEqual(response.body["repository_scope"]["missions"][0]["mission_id"], "MISSION-0042") + + def test_wrong_project_malformed_reference_and_method_fail_closed(self) -> None: + self.configure_project() + auth = "Bearer " + CREDENTIAL + wrong = self.api.handle("GET", "/v1/projects/other/roadmap", auth) + malformed = self.api.handle("GET", "/v1/projects/%2Fetc/roadmap", auth) + mutation = self.api.handle("POST", "/v1/projects/forge-project/roadmap", auth) + self.assertEqual((wrong.status, malformed.status, mutation.status), (404, 400, 405)) + self.assertEqual(wrong.body["error"]["code"], "PROJECT_MISSING") + + def test_corrupt_mission_graph_does_not_claim_a_valid_roadmap(self) -> None: + self.configure_project() + self.create_mission() + row = self.database._connection.execute( # noqa: SLF001 - controlled corruption fixture + "SELECT document FROM mission_state WHERE mission_id='MISSION-0042'" + ).fetchone() + document = json.loads(row[0]) + document["actions"][1]["dependencies"] = ["ACTION-MISSING"] + self.database._connection.execute( # noqa: SLF001 + "UPDATE mission_state SET document=? WHERE mission_id='MISSION-0042'", (json.dumps(document),) + ) + self.database._connection.commit() # noqa: SLF001 + response = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual(response.status, 409) + self.assertEqual(response.body["error"]["code"], "PROJECT_DAG_INVALID") + + def test_mission_from_other_repository_is_not_attributed_to_project(self) -> None: + self.configure_project() + self.create_mission() + row = self.database._connection.execute( # noqa: SLF001 - controlled foreign-source fixture + "SELECT document FROM mission_state WHERE mission_id='MISSION-0042'" + ).fetchone() + document = json.loads(row[0]) + document["mission"]["repository_evidence_source"]["repository_id"] = "other-repository" + self.database._connection.execute( # noqa: SLF001 + "UPDATE mission_state SET document=? WHERE mission_id='MISSION-0042'", (json.dumps(document),) + ) + self.database._connection.commit() # noqa: SLF001 + response = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual(response.status, 409) + self.assertEqual(response.body["error"]["code"], "PROJECT_AMBIGUOUS") + + def test_cyclic_action_dependencies_are_not_exposed_as_a_dag(self) -> None: + self.configure_project() + self.create_mission() + row = self.database._connection.execute( # noqa: SLF001 - controlled cycle fixture + "SELECT document FROM mission_state WHERE mission_id='MISSION-0042'" + ).fetchone() + document = json.loads(row[0]) + document["actions"][0]["dependencies"] = ["ACTION-0042-B"] + self.database._connection.execute( # noqa: SLF001 + "UPDATE mission_state SET document=? WHERE mission_id='MISSION-0042'", (json.dumps(document),) + ) + self.database._connection.commit() # noqa: SLF001 + response = self.api.handle("GET", "/v1/projects/forge-project/roadmap", "Bearer " + CREDENTIAL) + self.assertEqual(response.status, 409) + self.assertEqual(response.body["error"]["code"], "PROJECT_DAG_INVALID") + + def test_real_forge_server_route_uses_bearer_and_read_service(self) -> None: + self.configure_project() + api = ForgeServerAPI(SimpleNamespace(root=self.root), CREDENTIAL) + server = make_forge_server("127.0.0.1", 0, api) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + endpoint = f"http://127.0.0.1:{server.server_address[1]}/v1/projects" + with self.assertRaises(HTTPError) as denied: + urlopen(Request(endpoint), timeout=3) + self.assertEqual(denied.exception.code, 401) + denied.exception.close() + with urlopen(Request(endpoint, headers={"Authorization": "Bearer " + CREDENTIAL}), timeout=3) as response: + document = json.load(response) + self.assertEqual(document["projects"][0]["project_id"], "forge-project") + self.assertTrue(document["read_only"]) + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + def test_real_server_rejects_mutation_and_malformed_requests(self) -> None: + api = ForgeServerAPI(SimpleNamespace(root=self.root), CREDENTIAL) + with self.assertRaises(ValueError): + make_forge_server("0.0.0.0", 0, api) + with self.assertRaises(ValueError): + make_forge_server("127.0.0.1", 65536, api) + server = make_forge_server("127.0.0.1", 0, api) + thread = Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + port = server.server_address[1] + for body, expected in ((b"", 405), (b"{}", 405), (b"[]", 400), (b"{bad", 400)): + connection = HTTPConnection("127.0.0.1", port, timeout=3) + connection.request("POST", "/v1/projects", body=body, + headers={"Authorization": "Bearer " + CREDENTIAL, + "Content-Type": "application/json"}) + response = connection.getresponse() + self.assertEqual(response.status, expected) + response.read() + connection.close() + connection = HTTPConnection("127.0.0.1", port, timeout=3) + connection.request("HEAD", "/v1/projects", headers={"Authorization": "Bearer " + CREDENTIAL}) + response = connection.getresponse() + self.assertEqual(response.status, 405) + self.assertEqual(response.read(), b"") + connection.close() + connection = HTTPConnection("127.0.0.1", port, timeout=3) + connection.request("GET", "/v1/status", headers={"Authorization": "Bearer " + CREDENTIAL}) + response = connection.getresponse() + self.assertEqual(response.status, 200) + self.assertTrue(json.load(response)["read_only"]) + connection.close() + connection = HTTPConnection("127.0.0.1", port, timeout=3) + connection.putrequest("POST", "/v1/projects") + connection.putheader("Authorization", "Bearer " + CREDENTIAL) + connection.putheader("Content-Length", "invalid") + connection.endheaders() + response = connection.getresponse() + self.assertEqual(response.status, 400) + response.read() + connection.close() + connection = HTTPConnection("127.0.0.1", port, timeout=3) + connection.putrequest("POST", "/v1/projects") + connection.putheader("Authorization", "Bearer " + CREDENTIAL) + connection.putheader("Content-Length", "1048577") + connection.endheaders() + response = connection.getresponse() + self.assertEqual(response.status, 400) + response.read() + connection.close() + finally: + server.shutdown() + server.server_close() + thread.join(timeout=3) + + class TestMissionEndpoint(_InstalledFixture): def test_mission_lineage_read_only(self) -> None: criterion = "Report approved criteria, Actions, and evidence lineage" diff --git a/tests/test_server_api_contract.py b/tests/test_server_api_contract.py index 0e5ca0b3..43b58f87 100644 --- a/tests/test_server_api_contract.py +++ b/tests/test_server_api_contract.py @@ -26,6 +26,7 @@ def test_openapi_and_postman_exactly_match_server_route_inventory(self) -> None: request = item["request"] raw = request["url"]["raw"].removeprefix("{{baseUrl}}") raw = raw.replace("{{missionId}}", "{mission_id}") + raw = raw.replace("{{projectId}}", "{project_id}") postman_routes.add((request["method"].upper(), raw)) expected = set(SERVER_ROUTE_INVENTORY) diff --git a/tests/test_server_runtime.py b/tests/test_server_runtime.py index cc4339dd..913eef0f 100644 --- a/tests/test_server_runtime.py +++ b/tests/test_server_runtime.py @@ -59,6 +59,16 @@ def test_server_requires_an_existing_explicit_current_instance(self) -> None: existing_instance(absent) self.assertFalse(absent.exists()) + def test_server_rejects_nonpositive_tick_before_opening_an_instance(self) -> None: + with TemporaryDirectory() as temporary: + absent = Path(temporary) / "absent" + with self.assertRaisesRegex(ValueError, "tick interval must be positive"): + ForgeServerRuntime( + data_root=absent, credential_file=absent / "credential", + host="127.0.0.1", port=0, tick_interval=0, + ) + self.assertFalse(absent.exists()) + def test_two_instances_bind_distinct_runtime_state_and_listeners(self) -> None: with TemporaryDirectory() as temporary: root_a = self._root(temporary, "alpha")