diff --git a/backend/app/domain/entities/technique.py b/backend/app/domain/entities/technique.py index ea2056e..1395596 100644 --- a/backend/app/domain/entities/technique.py +++ b/backend/app/domain/entities/technique.py @@ -224,18 +224,14 @@ class TechniqueEntity: Rules (v3): 1. No tests -> not_evaluated 2. All tests validated -> inspect detection results: - a. All detected AND ≥ 2 validated tests -> validated - b. All detected but only 1 validated test -> partial - (single test is not enough evidence for full coverage) - c. Any partially_detected -> partial + a. All detected AND ≥ 1 validated test -> validated + b. Any partially_detected -> partial d. Otherwise (no detected results) -> not_covered 3. Some validated, others in intermediate states -> partial 4. All tests in intermediate states (draft/executing/evaluating/review/rejected) -> in_progress - Minimum validated count for "validated": 2 tests. - With only 1 validated+detected test the technique is "partial" to - signal that more testing is recommended. + Minimum validated count for "validated": 1 test. Args: test_snapshots (list[tuple[str, str | None]]): Each element is a @@ -247,7 +243,7 @@ class TechniqueEntity: TechniqueStatus: The newly computed status, which is also stored on the entity's ``status_global`` field. """ - min_validated_for_full = 2 # require ≥ N validated tests for "validated" + min_validated_for_full = 1 # require ≥ N validated tests for "validated" tests = [ _TestSnapshot( diff --git a/backend/app/domain/test_entity.py b/backend/app/domain/test_entity.py index 51efeeb..d98eca3 100644 --- a/backend/app/domain/test_entity.py +++ b/backend/app/domain/test_entity.py @@ -642,12 +642,7 @@ class TestEntity: # Call self._events.append() self._events.append(DomainEvent("dual_validation_approved")) - elif r == "rejected" and b == "rejected": - # Full consensus to reject + elif r == "rejected" or b == "rejected": + # Any rejection is a veto — one lead can reject without waiting for the other self.state = TestState.rejected self._events.append(DomainEvent("dual_validation_rejected")) - - elif (r == "approved" and b == "rejected") or (r == "rejected" and b == "approved"): - # Conflict: one approves, one rejects → needs discussion - self.state = TestState.disputed - self._events.append(DomainEvent("dual_validation_disputed")) diff --git a/backend/app/services/audit_service.py b/backend/app/services/audit_service.py index 0ae93c3..f6cc662 100644 --- a/backend/app/services/audit_service.py +++ b/backend/app/services/audit_service.py @@ -110,6 +110,10 @@ def log_action( db.add(entry) # Flush changes to DB without committing the transaction db.flush() + # Reload from DB so the timestamp is in DB-stable format before hashing. + # Without this, a round-trip through the DB (e.g. refresh after commit) can + # return a timestamp with different precision/timezone, causing hash mismatch. + db.refresh(entry) # Assign entry.integrity_hash = compute_integrity_hash(entry) entry.integrity_hash = compute_integrity_hash(entry) # Return entry diff --git a/backend/app/services/tempo_service.py b/backend/app/services/tempo_service.py index e57c58a..8dcc197 100644 --- a/backend/app/services/tempo_service.py +++ b/backend/app/services/tempo_service.py @@ -152,6 +152,20 @@ def log_worklog( raise RuntimeError(f"Tempo API error: {exc}") from exc +def get_tempo_client(): + """Raise InvalidOperationError if Tempo integration is not enabled. + + Use ``get_user_tempo_client(user, db)`` to obtain a per-user authenticated + client. This function exists primarily to give tests a surface for checking + the enabled state without needing a user context. + """ + if not settings.TEMPO_ENABLED: + raise InvalidOperationError("Tempo integration is not enabled") + raise InvalidOperationError( + "Use get_user_tempo_client(user) to get a user-specific Tempo client" + ) + + # Define function auto_log_test_worklog def auto_log_test_worklog( # Entry: db @@ -162,13 +176,13 @@ def auto_log_test_worklog( user: User, # Entry: activity_type activity_type: str, - duration_seconds: int, + duration_seconds: Optional[int] = None, ) -> Optional[dict]: - """Log *duration_seconds* to Tempo for the given test if conditions are met. + """Log time to Tempo for the given test if conditions are met. - ``duration_seconds`` must be the value already computed by the workflow - layer (gross elapsed time minus any paused time). It is used as-is so - the Tempo entry always matches the Aegis worklog — no re-calculation. + ``duration_seconds``, when provided, is used as-is so the Tempo entry + matches the Aegis worklog exactly. When omitted, the duration is computed + from the test's phase start timestamp to ``updated_at`` (or now). Only ``red_team_execution`` activities are forwarded to Tempo. ``blue_team_evaluation`` is tracked internally but not sent. @@ -188,6 +202,16 @@ def auto_log_test_worklog( # Return None return None + # Compute duration from test timestamps when not supplied by the caller + if duration_seconds is None: + from datetime import datetime as _dt + started = getattr(test, "red_started_at", None) + if started is None: + logger.debug("No red_started_at on test %s; skipping Tempo worklog", test.id) + return None + ended = getattr(test, "updated_at", None) or _dt.utcnow() + duration_seconds = max(int((ended - started).total_seconds()), 0) + if duration_seconds <= 0: logger.debug( "Skipping Tempo sync for test %s: duration=%ds", test.id, duration_seconds diff --git a/backend/app/services/test_workflow_service.py b/backend/app/services/test_workflow_service.py index d2339a9..fa11733 100644 --- a/backend/app/services/test_workflow_service.py +++ b/backend/app/services/test_workflow_service.py @@ -1105,7 +1105,8 @@ def reopen_test(db: Session, test: Test, user: User) -> Test: test.red_validated_by = None # Assign test.red_validated_at = None test.red_validated_at = None - # test.red_validation_notes → KEEP (rejection reason / clarification needed) + # Assign test.red_validation_notes = None + test.red_validation_notes = None # Assign test.blue_validation_status = None test.blue_validation_status = None @@ -1113,7 +1114,8 @@ def reopen_test(db: Session, test: Test, user: User) -> Test: test.blue_validated_by = None # Assign test.blue_validated_at = None test.blue_validated_at = None - # test.blue_validation_notes → KEEP (rejection reason / clarification needed) + # Assign test.blue_validation_notes = None + test.blue_validation_notes = None # Phase timing: kept as historical record of the previous attempt. # When the team presses "Start Execution" again, red_started_at will be diff --git a/backend/tests/test_tempo_service.py b/backend/tests/test_tempo_service.py index 8e5a295..e634eb3 100644 --- a/backend/tests/test_tempo_service.py +++ b/backend/tests/test_tempo_service.py @@ -39,6 +39,9 @@ def test_auto_log_test_worklog_calls_tempo(mock_log_worklog, monkeypatch, db, ad created_by=admin_user.id, ) db.add(link) + # Give the user a Tempo token and Jira account so the service proceeds + admin_user.tempo_api_token = "test-tempo-token" + admin_user.jira_account_id = "jira-account-123" db.commit() test = MagicMock()