feat: add Operations incident management
This commit is contained in:
parent
d31f88e97d
commit
8681e6f259
|
|
@ -0,0 +1,24 @@
|
|||
"""link firebase identity
|
||||
|
||||
Revision ID: e0f5a7123b46
|
||||
Revises: d9e4f6012a35
|
||||
Create Date: 2026-09-08
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "e0f5a7123b46"
|
||||
down_revision = "d9e4f6012a35"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column("users", sa.Column("firebase_uid", sa.String(length=128), nullable=True))
|
||||
op.create_index("ix_users_firebase_uid", "users", ["firebase_uid"], unique=True)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_index("ix_users_firebase_uid", table_name="users")
|
||||
op.drop_column("users", "firebase_uid")
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
"""add ops incidents
|
||||
|
||||
Revision ID: f1a2b3c4d5e6
|
||||
Revises: f1a6b8234c57
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
|
||||
revision = "f1a2b3c4d5e6"
|
||||
down_revision = "f1b7c8345d68"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. ops.incident_case_seq for readable immutable case references: OPS-YYYY-NNNNNN
|
||||
op.execute("CREATE SEQUENCE IF NOT EXISTS ops.incident_case_seq START 1")
|
||||
|
||||
# 2. ops.incidents table
|
||||
op.create_table(
|
||||
"incidents",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("case_reference", sa.String(length=32), nullable=False, unique=True),
|
||||
sa.Column("title", sa.String(length=255), nullable=False),
|
||||
sa.Column("summary", sa.Text(), nullable=False),
|
||||
sa.Column("severity", sa.String(length=32), nullable=False, server_default="medium"),
|
||||
sa.Column("status", sa.String(length=32), nullable=False, server_default="open"),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("closed_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("closed_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True),
|
||||
sa.Column("resolution", sa.Text(), nullable=True),
|
||||
schema="ops",
|
||||
)
|
||||
op.create_index("ix_ops_incidents_case_reference", "incidents", ["case_reference"], unique=True, schema="ops")
|
||||
op.create_index("ix_ops_incidents_status", "incidents", ["status"], unique=False, schema="ops")
|
||||
op.create_index("ix_ops_incidents_severity", "incidents", ["severity"], unique=False, schema="ops")
|
||||
op.create_index("ix_ops_incidents_created_at", "incidents", ["created_at"], unique=False, schema="ops")
|
||||
|
||||
# 3. ops.incident_notes table
|
||||
op.create_table(
|
||||
"incident_notes",
|
||||
sa.Column("id", UUID(as_uuid=True), primary_key=True),
|
||||
sa.Column("incident_id", UUID(as_uuid=True), sa.ForeignKey("ops.incidents.id", ondelete="CASCADE"), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()),
|
||||
sa.Column("created_by_operator_id", UUID(as_uuid=True), sa.ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False),
|
||||
sa.Column("body", sa.Text(), nullable=False),
|
||||
schema="ops",
|
||||
)
|
||||
op.create_index("ix_ops_incident_notes_incident_id", "incident_notes", ["incident_id"], unique=False, schema="ops")
|
||||
op.create_index("ix_ops_incident_notes_created_at", "incident_notes", ["created_at"], unique=False, schema="ops")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_table("incident_notes", schema="ops")
|
||||
op.drop_table("incidents", schema="ops")
|
||||
op.execute("DROP SEQUENCE IF EXISTS ops.incident_case_seq")
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
"""Add isolated Operations foundation; no rider table changes.
|
||||
|
||||
Revision ID: f1a6b8234c57
|
||||
Revises: e0f5a7123b46
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "f1a6b8234c57"
|
||||
down_revision = "e0f5a7123b46"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.execute("CREATE SCHEMA ops")
|
||||
op.create_table(
|
||||
"operators",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("firebase_uid", sa.String(128), nullable=False, unique=True),
|
||||
sa.Column("verified_email", sa.String(320), nullable=False),
|
||||
sa.Column("role", sa.String(16), nullable=False),
|
||||
sa.Column("active", sa.Boolean(), nullable=False),
|
||||
sa.Column("authority_version", sa.Integer(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.CheckConstraint("role IN ('operator', 'owner')", name="operator_role"),
|
||||
sa.CheckConstraint("authority_version > 0", name="operator_version"),
|
||||
sa.CheckConstraint("length(firebase_uid) > 0 AND length(verified_email) > 0", name="operator_identity"),
|
||||
schema="ops",
|
||||
)
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("operator_id", sa.Uuid(), sa.ForeignKey("ops.operators.id"), nullable=False),
|
||||
sa.Column("token_digest", sa.String(64), nullable=False, unique=True),
|
||||
sa.Column("authority_version", sa.Integer(), nullable=False),
|
||||
sa.Column("firebase_auth_time", sa.BigInteger(), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("last_seen_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("absolute_expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("idle_timeout_seconds", sa.Integer(), nullable=False),
|
||||
sa.Column("revoked_at", sa.DateTime(timezone=True)),
|
||||
sa.CheckConstraint("authority_version > 0", name="session_version"),
|
||||
sa.CheckConstraint("idle_timeout_seconds > 0 AND idle_timeout_seconds <= 900", name="session_idle"),
|
||||
sa.CheckConstraint("token_digest ~ '^[a-f0-9]{64}$'", name="session_digest"),
|
||||
sa.CheckConstraint("absolute_expires_at > created_at AND absolute_expires_at <= created_at + interval '8 hours'", name="session_absolute"),
|
||||
schema="ops",
|
||||
)
|
||||
op.create_index("ix_ops_sessions_operator_id", "sessions", ["operator_id"], schema="ops")
|
||||
op.create_table(
|
||||
"audit_events",
|
||||
sa.Column("id", sa.Uuid(), primary_key=True),
|
||||
sa.Column("schema_version", sa.Integer(), nullable=False),
|
||||
sa.Column("occurred_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("actor_operator_id", sa.Uuid(), sa.ForeignKey("ops.operators.id")),
|
||||
sa.Column("actor_kind", sa.String(24), nullable=False),
|
||||
sa.Column("actor_role", sa.String(16)),
|
||||
sa.Column("action", sa.String(32), nullable=False),
|
||||
sa.Column("target_type", sa.String(16), nullable=False),
|
||||
sa.Column("target_id", sa.Uuid()),
|
||||
sa.Column("request_id", sa.Uuid(), nullable=False),
|
||||
sa.Column("operation_id", sa.Uuid()),
|
||||
sa.Column("result", sa.String(16), nullable=False),
|
||||
sa.Column("reason_code", sa.String(32)),
|
||||
sa.Column("case_reference", sa.String(64)),
|
||||
sa.Column("sanitized_metadata", sa.JSON(), nullable=False),
|
||||
sa.CheckConstraint("schema_version = 1", name="audit_schema_version"),
|
||||
sa.CheckConstraint("actor_kind IN ('operator', 'unidentified', 'manual_admin')", name="audit_actor"),
|
||||
sa.CheckConstraint("actor_role IS NULL OR actor_role IN ('operator', 'owner')", name="audit_role"),
|
||||
sa.CheckConstraint("action IN ('session.create', 'session.logout', 'authorization.deny', 'session.failure', 'operator.bootstrap', 'operator.disable')", name="audit_action"),
|
||||
sa.CheckConstraint("target_type IN ('operator', 'session')", name="audit_target"),
|
||||
sa.CheckConstraint("result IN ('success', 'denied')", name="audit_result"),
|
||||
sa.CheckConstraint("case_reference IS NULL OR case_reference ~ '^OPS-[0-9]{1,12}$'", name="audit_case"),
|
||||
schema="ops",
|
||||
)
|
||||
op.create_index("ix_ops_audit_events_occurred_at", "audit_events", ["occurred_at"], schema="ops")
|
||||
# Guard immutable identity and version invalidation even for manual SQL updates.
|
||||
op.execute("""
|
||||
CREATE FUNCTION ops.guard_operator_identity() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
||||
BEGIN
|
||||
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.firebase_uid IS DISTINCT FROM OLD.firebase_uid THEN
|
||||
RAISE EXCEPTION 'Operations identity is immutable';
|
||||
END IF;
|
||||
IF NEW.authority_version < OLD.authority_version THEN
|
||||
RAISE EXCEPTION 'Operations authority version cannot decrease';
|
||||
END IF;
|
||||
IF NEW.role IS DISTINCT FROM OLD.role OR NEW.active IS DISTINCT FROM OLD.active THEN
|
||||
NEW.authority_version := GREATEST(NEW.authority_version, OLD.authority_version + 1);
|
||||
END IF;
|
||||
NEW.updated_at := CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END $$
|
||||
""")
|
||||
op.execute("CREATE TRIGGER guard_operator_identity BEFORE UPDATE ON ops.operators FOR EACH ROW EXECUTE FUNCTION ops.guard_operator_identity()")
|
||||
op.execute("REVOKE ALL ON SCHEMA ops FROM PUBLIC")
|
||||
op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA ops FROM PUBLIC")
|
||||
op.execute("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ops FROM PUBLIC")
|
||||
|
||||
|
||||
def downgrade():
|
||||
# Explicit downgrade removes Operations data only. Never run in production.
|
||||
op.drop_table("audit_events", schema="ops")
|
||||
op.drop_table("sessions", schema="ops")
|
||||
op.drop_table("operators", schema="ops")
|
||||
op.execute("DROP FUNCTION ops.guard_operator_identity()")
|
||||
op.execute("DROP SCHEMA ops")
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
"""evolve ops runtime schema preserving security invariants
|
||||
|
||||
Revision ID: f1b7c8345d68
|
||||
Revises: f1a6b8234c57
|
||||
Create Date: 2026-09-10
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
|
||||
revision = "f1b7c8345d68"
|
||||
down_revision = "f1a6b8234c57"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
# 1. Evolve ops.operators
|
||||
op.add_column("operators", sa.Column("identity", sa.String(length=320), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.operators SET identity = verified_email WHERE identity IS NULL")
|
||||
op.alter_column("operators", "identity", nullable=False, schema="ops")
|
||||
op.create_index("ix_ops_operators_identity", "operators", ["identity"], unique=True, schema="ops")
|
||||
|
||||
op.add_column("operators", sa.Column("status", sa.String(length=32), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.operators SET status = CASE WHEN active THEN 'active' ELSE 'suspended' END WHERE status IS NULL")
|
||||
op.alter_column("operators", "status", nullable=False, server_default="active", schema="ops")
|
||||
op.create_check_constraint("operator_status", "operators", "status IN ('active', 'suspended', 'revoked')", schema="ops")
|
||||
|
||||
# Update trigger to protect new identity column and status field
|
||||
op.execute("""
|
||||
CREATE OR REPLACE FUNCTION ops.guard_operator_identity() RETURNS trigger
|
||||
LANGUAGE plpgsql SET search_path = pg_catalog AS $$
|
||||
BEGIN
|
||||
IF NEW.id IS DISTINCT FROM OLD.id OR NEW.identity IS DISTINCT FROM OLD.identity THEN
|
||||
RAISE EXCEPTION 'Operations identity is immutable';
|
||||
END IF;
|
||||
IF NEW.authority_version < OLD.authority_version THEN
|
||||
RAISE EXCEPTION 'Operations authority version cannot decrease';
|
||||
END IF;
|
||||
IF NEW.role IS DISTINCT FROM OLD.role OR NEW.status IS DISTINCT FROM OLD.status THEN
|
||||
NEW.authority_version := GREATEST(NEW.authority_version, OLD.authority_version + 1);
|
||||
END IF;
|
||||
NEW.updated_at := CURRENT_TIMESTAMP;
|
||||
RETURN NEW;
|
||||
END $$;
|
||||
""")
|
||||
|
||||
# 2. Evolve ops.sessions
|
||||
op.add_column("sessions", sa.Column("session_token_hash", sa.String(length=128), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.sessions SET session_token_hash = token_digest WHERE session_token_hash IS NULL")
|
||||
op.alter_column("sessions", "session_token_hash", nullable=False, schema="ops")
|
||||
op.create_index("ix_ops_sessions_token_hash", "sessions", ["session_token_hash"], unique=True, schema="ops")
|
||||
|
||||
op.add_column("sessions", sa.Column("expires_at", sa.DateTime(timezone=True), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.sessions SET expires_at = absolute_expires_at WHERE expires_at IS NULL")
|
||||
op.alter_column("sessions", "expires_at", nullable=False, schema="ops")
|
||||
|
||||
op.add_column("sessions", sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.sessions SET last_activity_at = last_seen_at WHERE last_activity_at IS NULL")
|
||||
|
||||
# 3. Evolve ops.audit_events
|
||||
op.add_column("audit_events", sa.Column("timestamp", sa.DateTime(timezone=True), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.audit_events SET timestamp = occurred_at WHERE timestamp IS NULL")
|
||||
op.alter_column("audit_events", "timestamp", nullable=False, server_default=sa.func.now(), schema="ops")
|
||||
op.create_index("ix_ops_audit_events_timestamp", "audit_events", ["timestamp"], unique=False, schema="ops")
|
||||
|
||||
op.add_column("audit_events", sa.Column("actor_identity", sa.String(length=320), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.audit_events SET actor_identity = actor_kind WHERE actor_identity IS NULL")
|
||||
op.alter_column("audit_events", "actor_identity", nullable=False, schema="ops")
|
||||
|
||||
op.add_column("audit_events", sa.Column("target_resource", sa.String(length=96), nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.audit_events SET target_resource = target_type WHERE target_resource IS NULL")
|
||||
|
||||
op.add_column("audit_events", sa.Column("metadata_json", JSONB, nullable=True), schema="ops")
|
||||
op.execute("UPDATE ops.audit_events SET metadata_json = sanitized_metadata::jsonb WHERE metadata_json IS NULL")
|
||||
|
||||
# 4. Revoke public permissions
|
||||
op.execute("REVOKE ALL ON SCHEMA ops FROM PUBLIC")
|
||||
op.execute("REVOKE ALL ON ALL TABLES IN SCHEMA ops FROM PUBLIC")
|
||||
op.execute("REVOKE ALL ON ALL FUNCTIONS IN SCHEMA ops FROM PUBLIC")
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("audit_events", "metadata_json", schema="ops")
|
||||
op.drop_column("audit_events", "target_resource", schema="ops")
|
||||
op.drop_column("audit_events", "actor_identity", schema="ops")
|
||||
op.drop_index("ix_ops_audit_events_timestamp", table_name="audit_events", schema="ops")
|
||||
op.drop_column("audit_events", "timestamp", schema="ops")
|
||||
|
||||
op.drop_column("sessions", "last_activity_at", schema="ops")
|
||||
op.drop_column("sessions", "expires_at", schema="ops")
|
||||
op.drop_index("ix_ops_sessions_token_hash", table_name="sessions", schema="ops")
|
||||
op.drop_column("sessions", "session_token_hash", schema="ops")
|
||||
|
||||
op.drop_constraint("operator_status", "operators", schema="ops")
|
||||
op.drop_column("operators", "status", schema="ops")
|
||||
op.drop_index("ix_ops_operators_identity", table_name="operators", schema="ops")
|
||||
op.drop_column("operators", "identity", schema="ops")
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from cryptography import x509
|
||||
from pwdlib import PasswordHash
|
||||
|
||||
|
||||
|
|
@ -15,6 +19,9 @@ ACCESS_TOKEN_MINUTES = int(os.environ.get("ACCESS_TOKEN_MINUTES", "15"))
|
|||
REFRESH_TOKEN_DAYS = int(os.environ.get("REFRESH_TOKEN_DAYS", "30"))
|
||||
|
||||
JWT_ALGORITHM = "HS256"
|
||||
FIREBASE_CERTS_URL = "https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com"
|
||||
_firebase_certs: dict[str, str] = {}
|
||||
_firebase_certs_expires_at = 0.0
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
|
|
@ -87,3 +94,33 @@ def decode_access_token(token: str) -> dict:
|
|||
JWT_SECRET,
|
||||
algorithms=[JWT_ALGORITHM],
|
||||
)
|
||||
|
||||
|
||||
def _load_firebase_certs() -> dict[str, str]:
|
||||
global _firebase_certs, _firebase_certs_expires_at
|
||||
now = time.time()
|
||||
if _firebase_certs and now < _firebase_certs_expires_at:
|
||||
return _firebase_certs
|
||||
with urllib.request.urlopen(FIREBASE_CERTS_URL, timeout=5) as response:
|
||||
certs = json.loads(response.read())
|
||||
cache_control = response.headers.get("Cache-Control", "")
|
||||
max_age = next((int(value.split("=", 1)[1]) for value in cache_control.split(",") if value.strip().startswith("max-age=")), 300)
|
||||
_firebase_certs = certs
|
||||
_firebase_certs_expires_at = now + max_age
|
||||
return certs
|
||||
|
||||
|
||||
def decode_firebase_id_token(token: str) -> dict:
|
||||
project_id = os.environ["FIREBASE_PROJECT_ID"]
|
||||
header = jwt.get_unverified_header(token)
|
||||
if header.get("alg") != "RS256" or not header.get("kid"):
|
||||
raise jwt.InvalidTokenError("Invalid Firebase token header")
|
||||
certificate_pem = _load_firebase_certs().get(header["kid"])
|
||||
if certificate_pem is None:
|
||||
raise jwt.InvalidTokenError("Unknown Firebase signing key")
|
||||
public_key = x509.load_pem_x509_certificate(certificate_pem.encode("ascii")).public_key()
|
||||
claims = jwt.decode(token, public_key, algorithms=["RS256"], audience=project_id, issuer=f"https://securetoken.google.com/{project_id}")
|
||||
subject = claims.get("sub")
|
||||
if not isinstance(subject, str) or not subject or len(subject) > 128:
|
||||
raise jwt.InvalidTokenError("Invalid Firebase subject")
|
||||
return claims
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ from email.message import EmailMessage
|
|||
|
||||
from email_validator import EmailNotValidError, validate_email
|
||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, or_, select
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
from sqlalchemy import func, or_, select, text
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from auth import (
|
||||
|
|
@ -16,6 +16,7 @@ from auth import (
|
|||
create_refresh_token,
|
||||
create_one_time_token,
|
||||
decode_access_token,
|
||||
decode_firebase_id_token,
|
||||
hash_password,
|
||||
hash_refresh_token,
|
||||
hash_one_time_token,
|
||||
|
|
@ -24,7 +25,24 @@ from auth import (
|
|||
verify_password,
|
||||
)
|
||||
from db import check_database, engine
|
||||
from models import AuditEvent, AuthRateLimit, EmailVerificationToken, Entitlement, PasswordResetToken, Ride, Session, Subscription, TelemetryPoint, User, Vehicle
|
||||
from models import (
|
||||
AuditEvent,
|
||||
AuthRateLimit,
|
||||
EmailVerificationToken,
|
||||
Entitlement,
|
||||
Operator,
|
||||
OpsAuditEvent,
|
||||
OpsIncident,
|
||||
OpsIncidentNote,
|
||||
OpsSession,
|
||||
PasswordResetToken,
|
||||
Ride,
|
||||
Session,
|
||||
Subscription,
|
||||
TelemetryPoint,
|
||||
User,
|
||||
Vehicle,
|
||||
)
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
|
@ -63,6 +81,10 @@ class TokenRequest(BaseModel):
|
|||
token: str = Field(min_length=20, max_length=256)
|
||||
|
||||
|
||||
class FirebaseLinkRequest(BaseModel):
|
||||
id_token: str = Field(min_length=100, max_length=8192)
|
||||
|
||||
|
||||
class PasswordResetRequest(TokenRequest):
|
||||
password: str = Field(min_length=12, max_length=128)
|
||||
|
||||
|
|
@ -546,10 +568,35 @@ def me(user: User = Depends(get_current_user)):
|
|||
"id": str(user.id),
|
||||
"email": user.email,
|
||||
"email_verified": user.email_verified,
|
||||
"firebase_linked": user.firebase_uid is not None,
|
||||
"role": user.role,
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/firebase/link")
|
||||
def link_firebase_identity(request: FirebaseLinkRequest, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
||||
try:
|
||||
claims = decode_firebase_id_token(request.id_token)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="Invalid Firebase identity")
|
||||
firebase_email = claims.get("email")
|
||||
if claims.get("email_verified") is not True or not isinstance(firebase_email, str) or normalize_email(firebase_email) != user.email:
|
||||
raise HTTPException(status_code=403, detail="Firebase email must be verified and match this account")
|
||||
firebase_uid = claims["sub"]
|
||||
linked_user = db.scalar(select(User).where(User.firebase_uid == firebase_uid))
|
||||
if linked_user is not None and linked_user.id != user.id:
|
||||
raise HTTPException(status_code=409, detail="Firebase identity is already linked")
|
||||
if user.firebase_uid is not None and user.firebase_uid != firebase_uid:
|
||||
raise HTTPException(status_code=409, detail="Account is already linked to another Firebase identity")
|
||||
if user.firebase_uid is None:
|
||||
user.firebase_uid = firebase_uid
|
||||
user.email_verified = True
|
||||
audit(db, "auth.firebase_linked", actor_user_id=user.id, target_user_id=user.id)
|
||||
db.commit()
|
||||
return {"firebase_linked": True}
|
||||
|
||||
|
||||
@app.post("/api/v1/auth/verify-email/request", status_code=202)
|
||||
def request_email_verification(payload: EmailRequest, http_request: Request, db: DbSession = Depends(get_db)):
|
||||
enforce_auth_rate_limit(http_request, db, "verify-email-request")
|
||||
|
|
@ -982,6 +1029,20 @@ def list_my_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1,
|
|||
return {"items": [telemetry_payload(point) for point in points], "count": len(points)}
|
||||
|
||||
|
||||
def telemetry_summary(ride_id: uuid.UUID, db: DbSession) -> dict:
|
||||
count, first_recorded, last_recorded, max_speed, max_lean, max_g = db.execute(
|
||||
select(func.count(TelemetryPoint.id), func.min(TelemetryPoint.recorded_at), func.max(TelemetryPoint.recorded_at), func.max(TelemetryPoint.speed_mph), func.max(func.abs(TelemetryPoint.lean_angle_deg)), func.max(TelemetryPoint.g_force)).where(TelemetryPoint.ride_id == ride_id)
|
||||
).one()
|
||||
duration_seconds = (last_recorded - first_recorded).total_seconds() if first_recorded and last_recorded else 0
|
||||
return {"ride_id": str(ride_id), "point_count": count, "duration_seconds": duration_seconds, "max_speed_mph": max_speed, "max_lean_angle_deg": max_lean, "max_g_force": max_g}
|
||||
|
||||
|
||||
@app.get("/api/v1/rides/{ride_id}/telemetry/summary")
|
||||
def my_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(get_current_user), db: DbSession = Depends(get_db)):
|
||||
get_owned_ride(ride_id, user.id, db)
|
||||
return telemetry_summary(ride_id, db)
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/rides/{ride_id}/telemetry")
|
||||
def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge=1, le=5000), user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
|
||||
if db.get(Ride, ride_id) is None:
|
||||
|
|
@ -990,6 +1051,13 @@ def list_admin_telemetry(ride_id: uuid.UUID, limit: int = Query(default=1000, ge
|
|||
return {"items": [telemetry_payload(point) for point in points], "count": len(points)}
|
||||
|
||||
|
||||
@app.get("/api/v1/admin/rides/{ride_id}/telemetry/summary")
|
||||
def admin_telemetry_summary(ride_id: uuid.UUID, user: User = Depends(require_roles("admin", "support")), db: DbSession = Depends(get_db)):
|
||||
if db.get(Ride, ride_id) is None:
|
||||
raise HTTPException(status_code=404, detail="Ride not found")
|
||||
return telemetry_summary(ride_id, db)
|
||||
|
||||
|
||||
def subscription_payload(subscription: Subscription) -> dict:
|
||||
return {"id": str(subscription.id), "user_id": str(subscription.user_id), "plan": subscription.plan, "status": subscription.status, "current_period_start": subscription.current_period_start, "current_period_end": subscription.current_period_end, "created_at": subscription.created_at, "updated_at": subscription.updated_at}
|
||||
|
||||
|
|
@ -1061,3 +1129,614 @@ def create_entitlement(user_id: uuid.UUID, request: EntitlementCreate, actor: Us
|
|||
db.commit()
|
||||
db.refresh(entitlement)
|
||||
return entitlement_payload(entitlement)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Operations (OPS-3B / OPS-3C / OPS-4) Platform Endpoints
|
||||
# ============================================================
|
||||
|
||||
import hashlib
|
||||
|
||||
|
||||
def get_current_operator(
|
||||
request: Request,
|
||||
db: DbSession = Depends(get_db),
|
||||
) -> tuple[Operator, OpsSession]:
|
||||
auth_header = request.headers.get("authorization")
|
||||
token: str | None = None
|
||||
if auth_header:
|
||||
scheme, _, param = auth_header.partition(" ")
|
||||
if scheme.lower() == "bearer" and param:
|
||||
token = param
|
||||
if not token:
|
||||
token = request.cookies.get("__Host-motorsignal_ops") or request.cookies.get("motorsignal_ops")
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing Operations session credentials",
|
||||
)
|
||||
|
||||
token_hash = hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
stmt = (
|
||||
select(OpsSession, Operator)
|
||||
.join(Operator, OpsSession.operator_id == Operator.id)
|
||||
.where(
|
||||
OpsSession.session_token_hash == token_hash,
|
||||
OpsSession.revoked_at.is_(None),
|
||||
OpsSession.expires_at > now,
|
||||
Operator.status == "active",
|
||||
)
|
||||
)
|
||||
result = db.execute(stmt).first()
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Operations session expired or invalid",
|
||||
)
|
||||
|
||||
ops_session, operator = result
|
||||
ops_session.last_activity_at = now
|
||||
db.commit()
|
||||
return operator, ops_session
|
||||
|
||||
|
||||
def require_ops_roles(*roles: str):
|
||||
def dependency(
|
||||
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
||||
) -> tuple[Operator, OpsSession]:
|
||||
operator, session = auth
|
||||
if operator.role not in roles:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Insufficient Operations authority",
|
||||
)
|
||||
return operator, session
|
||||
|
||||
return dependency
|
||||
|
||||
|
||||
def ops_audit(
|
||||
db: DbSession,
|
||||
action: str,
|
||||
actor_identity: str,
|
||||
actor_role: str | None,
|
||||
target_resource: str | None = None,
|
||||
reason_code: str | None = None,
|
||||
case_reference: str | None = None,
|
||||
result: str = "success",
|
||||
metadata: dict | None = None,
|
||||
) -> OpsAuditEvent:
|
||||
event = OpsAuditEvent(
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
action=action,
|
||||
actor_identity=actor_identity,
|
||||
actor_role=actor_role,
|
||||
target_resource=target_resource,
|
||||
reason_code=reason_code,
|
||||
case_reference=case_reference,
|
||||
result=result,
|
||||
metadata_json=metadata,
|
||||
)
|
||||
db.add(event)
|
||||
return event
|
||||
|
||||
|
||||
def generate_incident_case_reference(db: DbSession) -> str:
|
||||
year = datetime.now(timezone.utc).year
|
||||
seq_val = db.execute(text("SELECT nextval('ops.incident_case_seq')")).scalar()
|
||||
return f"OPS-{year}-{int(seq_val):06d}"
|
||||
|
||||
|
||||
class OpsIncidentCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
title: str = Field(min_length=1, max_length=255)
|
||||
summary: str = Field(min_length=1, max_length=4000)
|
||||
severity: str = Field(default="medium")
|
||||
|
||||
|
||||
class OpsIncidentPatch(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
severity: str | None = None
|
||||
status: str | None = None
|
||||
resolution: str | None = Field(default=None, max_length=4000)
|
||||
|
||||
|
||||
class OpsIncidentNoteCreate(BaseModel):
|
||||
model_config = ConfigDict(extra="forbid")
|
||||
body: str = Field(min_length=1, max_length=4000)
|
||||
|
||||
|
||||
@app.get("/api/v1/ops/me")
|
||||
def ops_get_me(
|
||||
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
||||
):
|
||||
operator, session = auth
|
||||
return {
|
||||
"identity": operator.identity,
|
||||
"role": operator.role,
|
||||
"status": operator.status,
|
||||
"created_at": operator.created_at.isoformat(),
|
||||
"last_activity_at": session.last_activity_at.isoformat() if session.last_activity_at else None,
|
||||
"expires_at": session.expires_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/ops/health")
|
||||
def ops_get_health():
|
||||
now_iso = datetime.now(timezone.utc).isoformat()
|
||||
db_ok = False
|
||||
try:
|
||||
db_ok = check_database()
|
||||
except Exception:
|
||||
db_ok = False
|
||||
|
||||
overall = "available" if db_ok else "degraded"
|
||||
return {
|
||||
"status": overall,
|
||||
"checked_at": now_iso,
|
||||
"components": {
|
||||
"operations_api": {
|
||||
"status": "available",
|
||||
"description": "FastAPI core endpoint runtime & ingress",
|
||||
},
|
||||
"operations_database": {
|
||||
"status": "available" if db_ok else "unavailable",
|
||||
"description": "PostgreSQL ops schema relational persistence",
|
||||
},
|
||||
"session_validation": {
|
||||
"status": "available" if db_ok else "degraded",
|
||||
"description": "__Host-motorsignal_ops token verification & authority",
|
||||
},
|
||||
"audit_persistence": {
|
||||
"status": "available" if db_ok else "degraded",
|
||||
"description": "Immutable operational security activity persistence",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@app.delete("/api/v1/ops/sessions/current", status_code=204)
|
||||
def ops_logout_current_session(
|
||||
auth: tuple[Operator, OpsSession] = Depends(get_current_operator),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
operator, session = auth
|
||||
session.revoked_at = datetime.now(timezone.utc)
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="auth.session_revoked",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.session",
|
||||
result="success",
|
||||
)
|
||||
db.commit()
|
||||
|
||||
|
||||
@app.get("/api/v1/ops/audit-events")
|
||||
def ops_list_audit_events(
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(OpsAuditEvent).order_by(OpsAuditEvent.timestamp.desc())
|
||||
if cursor:
|
||||
try:
|
||||
cursor_dt = datetime.fromisoformat(cursor)
|
||||
stmt = stmt.where(OpsAuditEvent.timestamp < cursor_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
events = db.scalars(stmt.limit(limit + 1)).all()
|
||||
has_more = len(events) > limit
|
||||
returned_events = events[:limit]
|
||||
|
||||
next_cursor = returned_events[-1].timestamp.isoformat() if has_more and returned_events else None
|
||||
total_count = db.scalar(select(func.count(OpsAuditEvent.id))) or 0
|
||||
|
||||
items = [
|
||||
{
|
||||
"id": str(e.id),
|
||||
"timestamp": e.timestamp.isoformat(),
|
||||
"action": e.action,
|
||||
"actor_identity": e.actor_identity,
|
||||
"actor_role": e.actor_role,
|
||||
"target_resource": e.target_resource,
|
||||
"reason_code": e.reason_code,
|
||||
"case_reference": e.case_reference,
|
||||
"result": e.result,
|
||||
"metadata": e.metadata_json or {},
|
||||
}
|
||||
for e in returned_events
|
||||
]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"next_cursor": next_cursor,
|
||||
"total": total_count,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Incidents Endpoints (OPS-4)
|
||||
# ============================================================
|
||||
|
||||
VALID_SEVERITIES = {"low", "medium", "high", "critical"}
|
||||
VALID_STATUSES = {"open", "investigating", "monitoring", "resolved", "closed", "archived"}
|
||||
|
||||
ALLOWED_TRANSITIONS = {
|
||||
"open": {"investigating", "resolved", "closed"},
|
||||
"investigating": {"monitoring", "resolved", "closed"},
|
||||
"monitoring": {"investigating", "resolved", "closed"},
|
||||
"resolved": {"closed", "investigating"},
|
||||
"closed": {"investigating"}, # Reopen (Owner only)
|
||||
"archived": set(), # Terminal (no further mutation)
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/ops/incidents")
|
||||
def ops_list_incidents(
|
||||
status: str | None = None,
|
||||
severity: str | None = None,
|
||||
case_reference: str | None = None,
|
||||
cursor: str | None = None,
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(OpsIncident).order_by(OpsIncident.created_at.desc())
|
||||
|
||||
if case_reference:
|
||||
stmt = stmt.where(OpsIncident.case_reference == case_reference.strip())
|
||||
if status:
|
||||
stmt = stmt.where(OpsIncident.status == status.strip().lower())
|
||||
if severity:
|
||||
stmt = stmt.where(OpsIncident.severity == severity.strip().lower())
|
||||
|
||||
if cursor:
|
||||
try:
|
||||
cursor_dt = datetime.fromisoformat(cursor)
|
||||
stmt = stmt.where(OpsIncident.created_at < cursor_dt)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
incidents = db.scalars(stmt.limit(limit + 1)).all()
|
||||
has_more = len(incidents) > limit
|
||||
returned_incidents = incidents[:limit]
|
||||
next_cursor = returned_incidents[-1].created_at.isoformat() if has_more and returned_incidents else None
|
||||
|
||||
items = [
|
||||
{
|
||||
"case_reference": inc.case_reference,
|
||||
"title": inc.title,
|
||||
"severity": inc.severity,
|
||||
"status": inc.status,
|
||||
"created_at": inc.created_at.isoformat(),
|
||||
"updated_at": inc.updated_at.isoformat(),
|
||||
}
|
||||
for inc in returned_incidents
|
||||
]
|
||||
|
||||
return {
|
||||
"items": items,
|
||||
"next_cursor": next_cursor,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/ops/incidents", status_code=201)
|
||||
def ops_create_incident(
|
||||
request: OpsIncidentCreate,
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
operator, _ = auth
|
||||
sev = request.severity.strip().lower()
|
||||
if sev not in VALID_SEVERITIES:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"Invalid severity '{request.severity}'. Must be one of: {sorted(VALID_SEVERITIES)}",
|
||||
)
|
||||
|
||||
case_ref = generate_incident_case_reference(db)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
incident = OpsIncident(
|
||||
case_reference=case_ref,
|
||||
title=request.title.strip(),
|
||||
summary=request.summary.strip(),
|
||||
severity=sev,
|
||||
status="open",
|
||||
created_at=now,
|
||||
created_by_operator_id=operator.id,
|
||||
updated_at=now,
|
||||
)
|
||||
db.add(incident)
|
||||
db.flush()
|
||||
|
||||
# Transactional Audit
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="incident.created",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.incident",
|
||||
case_reference=case_ref,
|
||||
result="success",
|
||||
metadata={"severity": sev},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(incident)
|
||||
|
||||
return {
|
||||
"case_reference": incident.case_reference,
|
||||
"title": incident.title,
|
||||
"summary": incident.summary,
|
||||
"severity": incident.severity,
|
||||
"status": incident.status,
|
||||
"created_at": incident.created_at.isoformat(),
|
||||
"updated_at": incident.updated_at.isoformat(),
|
||||
"closed_at": None,
|
||||
"resolution": None,
|
||||
"created_by_identity": operator.identity,
|
||||
"closed_by_identity": None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/ops/incidents/{case_reference}")
|
||||
def ops_get_incident_detail(
|
||||
case_reference: str,
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
||||
incident = db.scalar(stmt)
|
||||
if incident is None:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
|
||||
creator = db.get(Operator, incident.created_by_operator_id)
|
||||
closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None
|
||||
|
||||
return {
|
||||
"case_reference": incident.case_reference,
|
||||
"title": incident.title,
|
||||
"summary": incident.summary,
|
||||
"severity": incident.severity,
|
||||
"status": incident.status,
|
||||
"created_at": incident.created_at.isoformat(),
|
||||
"updated_at": incident.updated_at.isoformat(),
|
||||
"closed_at": incident.closed_at.isoformat() if incident.closed_at else None,
|
||||
"resolution": incident.resolution,
|
||||
"created_by_identity": creator.identity if creator else "operator",
|
||||
"closed_by_identity": closer.identity if closer else None,
|
||||
}
|
||||
|
||||
|
||||
@app.patch("/api/v1/ops/incidents/{case_reference}")
|
||||
def ops_patch_incident(
|
||||
case_reference: str,
|
||||
patch: OpsIncidentPatch,
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
operator, _ = auth
|
||||
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
||||
incident = db.scalar(stmt)
|
||||
if incident is None:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
|
||||
if incident.status == "archived":
|
||||
raise HTTPException(status_code=409, detail="Archived incidents cannot be modified")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
target_status = patch.status.strip().lower() if patch.status else None
|
||||
target_severity = patch.severity.strip().lower() if patch.severity else None
|
||||
new_resolution = patch.resolution.strip() if patch.resolution is not None else None
|
||||
|
||||
# Severity update
|
||||
if target_severity:
|
||||
if target_severity not in VALID_SEVERITIES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid severity '{patch.severity}'. Must be: {sorted(VALID_SEVERITIES)}",
|
||||
)
|
||||
if target_severity != incident.severity:
|
||||
old_sev = incident.severity
|
||||
incident.severity = target_severity
|
||||
incident.updated_at = now
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="incident.severity_changed",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.incident",
|
||||
case_reference=incident.case_reference,
|
||||
result="success",
|
||||
metadata={"from": old_sev, "to": target_severity},
|
||||
)
|
||||
|
||||
# Resolution update
|
||||
if new_resolution is not None and new_resolution != incident.resolution:
|
||||
incident.resolution = new_resolution
|
||||
incident.updated_at = now
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="incident.resolved",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.incident",
|
||||
case_reference=incident.case_reference,
|
||||
result="success",
|
||||
)
|
||||
|
||||
# Status transition
|
||||
if target_status and target_status != incident.status:
|
||||
if target_status not in VALID_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=f"Invalid status '{patch.status}'. Must be: {sorted(VALID_STATUSES)}",
|
||||
)
|
||||
|
||||
# Owner only transitions
|
||||
if target_status == "archived" and operator.role != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only Owners may archive incidents")
|
||||
|
||||
if incident.status == "closed" and target_status == "investigating" and operator.role != "owner":
|
||||
raise HTTPException(status_code=403, detail="Only Owners may reopen closed incidents")
|
||||
|
||||
# Allowed transitions
|
||||
allowed = ALLOWED_TRANSITIONS.get(incident.status, set())
|
||||
# Add archived as valid destination for non-archived if owner
|
||||
if operator.role == "owner" and incident.status != "archived":
|
||||
allowed = allowed | {"archived"}
|
||||
|
||||
if target_status not in allowed:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail=f"Invalid status transition from '{incident.status}' to '{target_status}'",
|
||||
)
|
||||
|
||||
# Resolution required for resolved or closed
|
||||
effective_res = new_resolution if new_resolution is not None else incident.resolution
|
||||
if target_status in {"resolved", "closed"} and (not effective_res or not effective_res.strip()):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail="Resolution text is required before resolving or closing an incident",
|
||||
)
|
||||
|
||||
old_status = incident.status
|
||||
incident.status = target_status
|
||||
incident.updated_at = now
|
||||
|
||||
if target_status == "closed":
|
||||
incident.closed_at = now
|
||||
incident.closed_by_operator_id = operator.id
|
||||
audit_action = "incident.closed"
|
||||
elif target_status == "resolved":
|
||||
audit_action = "incident.resolved"
|
||||
elif old_status == "closed" and target_status == "investigating":
|
||||
incident.closed_at = None
|
||||
incident.closed_by_operator_id = None
|
||||
audit_action = "incident.reopened"
|
||||
elif target_status == "archived":
|
||||
audit_action = "incident.archived"
|
||||
else:
|
||||
audit_action = "incident.status_changed"
|
||||
|
||||
ops_audit(
|
||||
db=db,
|
||||
action=audit_action,
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.incident",
|
||||
case_reference=incident.case_reference,
|
||||
result="success",
|
||||
metadata={"from": old_status, "to": target_status},
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(incident)
|
||||
|
||||
creator = db.get(Operator, incident.created_by_operator_id)
|
||||
closer = db.get(Operator, incident.closed_by_operator_id) if incident.closed_by_operator_id else None
|
||||
|
||||
return {
|
||||
"case_reference": incident.case_reference,
|
||||
"title": incident.title,
|
||||
"summary": incident.summary,
|
||||
"severity": incident.severity,
|
||||
"status": incident.status,
|
||||
"created_at": incident.created_at.isoformat(),
|
||||
"updated_at": incident.updated_at.isoformat(),
|
||||
"closed_at": incident.closed_at.isoformat() if incident.closed_at else None,
|
||||
"resolution": incident.resolution,
|
||||
"created_by_identity": creator.identity if creator else "operator",
|
||||
"closed_by_identity": closer.identity if closer else None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/v1/ops/incidents/{case_reference}/notes")
|
||||
def ops_list_incident_notes(
|
||||
case_reference: str,
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
||||
incident = db.scalar(stmt)
|
||||
if incident is None:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
|
||||
notes_stmt = (
|
||||
select(OpsIncidentNote, Operator.identity)
|
||||
.join(Operator, OpsIncidentNote.created_by_operator_id == Operator.id)
|
||||
.where(OpsIncidentNote.incident_id == incident.id)
|
||||
.order_by(OpsIncidentNote.created_at.desc())
|
||||
)
|
||||
notes_rows = db.execute(notes_stmt).all()
|
||||
|
||||
return {
|
||||
"notes": [
|
||||
{
|
||||
"created_at": note.created_at.isoformat(),
|
||||
"created_by_identity": op_identity,
|
||||
"body": note.body,
|
||||
}
|
||||
for note, op_identity in notes_rows
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/v1/ops/incidents/{case_reference}/notes", status_code=201)
|
||||
def ops_create_incident_note(
|
||||
case_reference: str,
|
||||
request: OpsIncidentNoteCreate,
|
||||
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||
db: DbSession = Depends(get_db),
|
||||
):
|
||||
operator, _ = auth
|
||||
stmt = select(OpsIncident).where(OpsIncident.case_reference == case_reference.strip())
|
||||
incident = db.scalar(stmt)
|
||||
if incident is None:
|
||||
raise HTTPException(status_code=404, detail="Incident not found")
|
||||
|
||||
if incident.status == "archived":
|
||||
raise HTTPException(status_code=409, detail="Archived incidents cannot be modified")
|
||||
|
||||
body_text = request.body.strip()
|
||||
if not body_text:
|
||||
raise HTTPException(status_code=422, detail="Note body cannot be empty")
|
||||
|
||||
now = datetime.now(timezone.utc)
|
||||
note = OpsIncidentNote(
|
||||
incident_id=incident.id,
|
||||
created_at=now,
|
||||
created_by_operator_id=operator.id,
|
||||
body=body_text,
|
||||
)
|
||||
db.add(note)
|
||||
incident.updated_at = now
|
||||
db.flush()
|
||||
|
||||
# Transactional Audit
|
||||
ops_audit(
|
||||
db=db,
|
||||
action="incident.note_added",
|
||||
actor_identity=operator.identity,
|
||||
actor_role=operator.role,
|
||||
target_resource="ops.incident",
|
||||
case_reference=incident.case_reference,
|
||||
result="success",
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(note)
|
||||
|
||||
return {
|
||||
"created_at": note.created_at.isoformat(),
|
||||
"created_by_identity": operator.identity,
|
||||
"body": note.body,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,10 @@ class User(Base):
|
|||
index=True,
|
||||
)
|
||||
|
||||
firebase_uid: Mapped[str | None] = mapped_column(
|
||||
String(128), unique=True, nullable=True, index=True
|
||||
)
|
||||
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
default=datetime.utcnow,
|
||||
|
|
@ -231,3 +235,146 @@ class AuthRateLimit(Base):
|
|||
key: Mapped[str] = mapped_column(String(255), primary_key=True)
|
||||
window_started: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
attempts: Mapped[int] = mapped_column(nullable=False, default=0)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Operations (ops schema) Models
|
||||
# ============================================================
|
||||
|
||||
|
||||
class Operator(Base):
|
||||
__tablename__ = "operators"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
identity: Mapped[str] = mapped_column(
|
||||
String(320), unique=True, nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="operator", index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="active"
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
|
||||
sessions: Mapped[list["OpsSession"]] = relationship(
|
||||
back_populates="operator", cascade="all, delete-orphan"
|
||||
)
|
||||
|
||||
|
||||
class OpsSession(Base):
|
||||
__tablename__ = "sessions"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
operator_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("ops.operators.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
session_token_hash: Mapped[str] = mapped_column(
|
||||
String(128), unique=True, nullable=False, index=True
|
||||
)
|
||||
expires_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), nullable=False
|
||||
)
|
||||
last_activity_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False
|
||||
)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
|
||||
operator: Mapped["Operator"] = relationship(back_populates="sessions")
|
||||
|
||||
|
||||
class OpsAuditEvent(Base):
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
timestamp: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True
|
||||
)
|
||||
action: Mapped[str] = mapped_column(String(96), nullable=False, index=True)
|
||||
actor_identity: Mapped[str] = mapped_column(String(320), nullable=False)
|
||||
actor_role: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
target_resource: Mapped[str | None] = mapped_column(String(96), nullable=True)
|
||||
reason_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
case_reference: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
result: Mapped[str] = mapped_column(String(32), nullable=False, default="success")
|
||||
metadata_json: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class OpsIncident(Base):
|
||||
__tablename__ = "incidents"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
case_reference: Mapped[str] = mapped_column(
|
||||
String(32), unique=True, nullable=False, index=True
|
||||
)
|
||||
title: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
summary: Mapped[str] = mapped_column(String, nullable=False)
|
||||
severity: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="medium", index=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(32), nullable=False, default="open", index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True
|
||||
)
|
||||
created_by_operator_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False
|
||||
)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
closed_by_operator_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=True
|
||||
)
|
||||
resolution: Mapped[str | None] = mapped_column(String, nullable=True)
|
||||
|
||||
notes: Mapped[list["OpsIncidentNote"]] = relationship(
|
||||
back_populates="incident", cascade="all, delete-orphan", order_by="OpsIncidentNote.created_at.desc()"
|
||||
)
|
||||
|
||||
|
||||
class OpsIncidentNote(Base):
|
||||
__tablename__ = "incident_notes"
|
||||
__table_args__ = {"schema": "ops"}
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
|
||||
)
|
||||
incident_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("ops.incidents.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True), default=datetime.utcnow, nullable=False, index=True
|
||||
)
|
||||
created_by_operator_id: Mapped[uuid.UUID] = mapped_column(
|
||||
ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False
|
||||
)
|
||||
body: Mapped[str] = mapped_column(String, nullable=False)
|
||||
|
||||
incident: Mapped["OpsIncident"] = relationship(back_populates="notes")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,62 @@
|
|||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
|
||||
sys.path.insert(0, os.path.join(ROOT, "app"))
|
||||
os.environ.setdefault("JWT_SECRET", "local-test-secret")
|
||||
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
|
||||
|
||||
from auth import decode_firebase_id_token # noqa: E402
|
||||
import auth # noqa: E402
|
||||
|
||||
|
||||
private_key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
|
||||
now = datetime.now(timezone.utc)
|
||||
name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "firebase-test")])
|
||||
certificate = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(name)
|
||||
.issuer_name(name)
|
||||
.public_key(private_key.public_key())
|
||||
.serial_number(x509.random_serial_number())
|
||||
.not_valid_before(now - timedelta(minutes=1))
|
||||
.not_valid_after(now + timedelta(days=1))
|
||||
.sign(private_key, algorithm=hashes.SHA256())
|
||||
)
|
||||
auth._firebase_certs = {"test-key": certificate.public_bytes(serialization.Encoding.PEM).decode()}
|
||||
auth._firebase_certs_expires_at = time.time() + 60
|
||||
|
||||
|
||||
def token(**overrides):
|
||||
claims = {
|
||||
"sub": "firebase-user-123",
|
||||
"email": "rider@example.com",
|
||||
"email_verified": True,
|
||||
"aud": "motoapp-d22e0",
|
||||
"iss": "https://securetoken.google.com/motoapp-d22e0",
|
||||
"iat": now,
|
||||
"exp": now + timedelta(minutes=5),
|
||||
}
|
||||
claims.update(overrides)
|
||||
return jwt.encode(claims, private_key, algorithm="RS256", headers={"kid": "test-key"})
|
||||
|
||||
|
||||
assert decode_firebase_id_token(token())["sub"] == "firebase-user-123"
|
||||
for invalid in (token(aud="other-project"), token(iss="https://securetoken.google.com/other-project"), token(sub="")):
|
||||
try:
|
||||
decode_firebase_id_token(invalid)
|
||||
except jwt.PyJWTError:
|
||||
pass
|
||||
else:
|
||||
raise AssertionError("Invalid Firebase token accepted")
|
||||
|
||||
print("PASS: Firebase signature, project, issuer, and subject validation")
|
||||
|
|
@ -0,0 +1,559 @@
|
|||
import hashlib
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(__file__))
|
||||
sys.path.insert(0, os.path.join(ROOT, ".python-packages"))
|
||||
sys.path.insert(0, os.path.join(ROOT, "app"))
|
||||
|
||||
os.environ.setdefault("JWT_SECRET", "local-test-secret")
|
||||
os.environ["FIREBASE_PROJECT_ID"] = "motoapp-d22e0"
|
||||
os.environ["DATABASE_USER"] = "test"
|
||||
os.environ["DATABASE_PASSWORD"] = "test"
|
||||
os.environ["DATABASE_NAME"] = "test"
|
||||
|
||||
from sqlalchemy import create_engine, event, select, text
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
from starlette.testclient import TestClient
|
||||
|
||||
import main
|
||||
from main import (
|
||||
app,
|
||||
generate_incident_case_reference,
|
||||
get_db,
|
||||
OpsIncidentCreate,
|
||||
OpsIncidentPatch,
|
||||
OpsIncidentNoteCreate,
|
||||
)
|
||||
from models import Base, Operator, OpsAuditEvent, OpsIncident, OpsIncidentNote, OpsSession
|
||||
|
||||
|
||||
def hash_token(raw_token: str) -> str:
|
||||
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
|
||||
class TestOpsIncidents(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
# Create an isolated SQLite database attached with 'ops' schema
|
||||
cls.db_path = os.path.join(ROOT, "tests", "test_ops_incidents.db")
|
||||
cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_schema.db")
|
||||
for p in (cls.db_path, cls.ops_db_path):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
cls.engine = create_engine(f"sqlite:///{cls.db_path}")
|
||||
|
||||
cls.seq_val = 0
|
||||
|
||||
@event.listens_for(cls.engine, "connect")
|
||||
def attach_ops(dbapi_con, con_record):
|
||||
dbapi_con.execute(f"ATTACH DATABASE '{cls.ops_db_path}' AS ops")
|
||||
|
||||
def sqlite_nextval(name):
|
||||
cls.seq_val += 1
|
||||
return cls.seq_val
|
||||
|
||||
dbapi_con.create_function("nextval", 1, sqlite_nextval)
|
||||
|
||||
Base.metadata.create_all(cls.engine)
|
||||
|
||||
def override_get_db():
|
||||
db = DbSession(cls.engine)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
app.dependency_overrides[get_db] = override_get_db
|
||||
cls.client = TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
app.dependency_overrides.clear()
|
||||
cls.engine.dispose()
|
||||
for p in (cls.db_path, cls.ops_db_path):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
os.remove(p)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def setUp(self):
|
||||
# Clear tables and seed standard test operators & sessions
|
||||
with DbSession(self.engine) as db:
|
||||
db.query(OpsIncidentNote).delete()
|
||||
db.query(OpsIncident).delete()
|
||||
db.query(OpsAuditEvent).delete()
|
||||
db.query(OpsSession).delete()
|
||||
db.query(Operator).delete()
|
||||
db.commit()
|
||||
|
||||
# Seed Active Operator
|
||||
self.op_id = uuid.uuid4()
|
||||
self.op_identity = "operator@motorsignal.internal"
|
||||
self.op_token = "valid-operator-token-123"
|
||||
op = Operator(
|
||||
id=self.op_id,
|
||||
identity=self.op_identity,
|
||||
role="operator",
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
op_session = OpsSession(
|
||||
operator_id=self.op_id,
|
||||
session_token_hash=hash_token(self.op_token),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Seed Active Owner
|
||||
self.owner_id = uuid.uuid4()
|
||||
self.owner_identity = "owner@motorsignal.internal"
|
||||
self.owner_token = "valid-owner-token-456"
|
||||
owner = Operator(
|
||||
id=self.owner_id,
|
||||
identity=self.owner_identity,
|
||||
role="owner",
|
||||
status="active",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
owner_session = OpsSession(
|
||||
operator_id=self.owner_id,
|
||||
session_token_hash=hash_token(self.owner_token),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Seed Suspended Operator
|
||||
self.suspended_id = uuid.uuid4()
|
||||
self.suspended_token = "suspended-operator-token"
|
||||
suspended_op = Operator(
|
||||
id=self.suspended_id,
|
||||
identity="suspended@motorsignal.internal",
|
||||
role="operator",
|
||||
status="suspended",
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
suspended_session = OpsSession(
|
||||
operator_id=self.suspended_id,
|
||||
session_token_hash=hash_token(self.suspended_token),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||
created_at=datetime.now(timezone.utc),
|
||||
)
|
||||
|
||||
# Seed Expired Session
|
||||
self.expired_token = "expired-token-789"
|
||||
expired_session = OpsSession(
|
||||
operator_id=self.op_id,
|
||||
session_token_hash=hash_token(self.expired_token),
|
||||
expires_at=datetime.now(timezone.utc) - timedelta(minutes=5),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(hours=2),
|
||||
)
|
||||
|
||||
# Seed Revoked Session
|
||||
self.revoked_token = "revoked-token-000"
|
||||
revoked_session = OpsSession(
|
||||
operator_id=self.op_id,
|
||||
session_token_hash=hash_token(self.revoked_token),
|
||||
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||
revoked_at=datetime.now(timezone.utc) - timedelta(minutes=1),
|
||||
created_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
)
|
||||
|
||||
db.add_all([op, owner, suspended_op, op_session, owner_session, expired_session, revoked_session])
|
||||
db.commit()
|
||||
|
||||
self.op_headers = {"Authorization": f"Bearer {self.op_token}"}
|
||||
self.owner_headers = {"Authorization": f"Bearer {self.owner_token}"}
|
||||
|
||||
# ============================================================
|
||||
# 1. Authority and Authentication Tests
|
||||
# ============================================================
|
||||
def test_unauthenticated_requests_rejected(self):
|
||||
routes = [
|
||||
("GET", "/api/v1/ops/incidents", None),
|
||||
("POST", "/api/v1/ops/incidents", {"title": "Test", "summary": "Sum"}),
|
||||
("GET", "/api/v1/ops/incidents/OPS-2026-000001", None),
|
||||
("PATCH", "/api/v1/ops/incidents/OPS-2026-000001", {"status": "investigating"}),
|
||||
("GET", "/api/v1/ops/incidents/OPS-2026-000001/notes", None),
|
||||
("POST", "/api/v1/ops/incidents/OPS-2026-000001/notes", {"body": "Note"}),
|
||||
]
|
||||
for method, path, json_data in routes:
|
||||
response = self.client.request(method, path, json=json_data)
|
||||
self.assertEqual(response.status_code, 401, f"{method} {path} should reject unauthenticated request")
|
||||
|
||||
def test_invalid_bearer_token_rejected(self):
|
||||
headers = {"Authorization": "Bearer completely-invalid-token"}
|
||||
res = self.client.get("/api/v1/ops/incidents", headers=headers)
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
def test_expired_and_revoked_session_rejected(self):
|
||||
res_expired = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.expired_token}"})
|
||||
self.assertEqual(res_expired.status_code, 401)
|
||||
|
||||
res_revoked = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.revoked_token}"})
|
||||
self.assertEqual(res_revoked.status_code, 401)
|
||||
|
||||
def test_suspended_operator_rejected(self):
|
||||
res = self.client.get("/api/v1/ops/incidents", headers={"Authorization": f"Bearer {self.suspended_token}"})
|
||||
self.assertEqual(res.status_code, 401)
|
||||
|
||||
# ============================================================
|
||||
# 2. Case Reference Generation & Format Tests (Hardened)
|
||||
# ============================================================
|
||||
def test_case_reference_format_and_sequence(self):
|
||||
year = datetime.now(timezone.utc).year
|
||||
payload = {"title": "Sequential Test Incident", "summary": "Testing format", "severity": "low"}
|
||||
|
||||
res1 = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res1.status_code, 201)
|
||||
data1 = res1.json()
|
||||
case_ref1 = data1["case_reference"]
|
||||
|
||||
# Invariant checks: prefix OPS-YYYY-, numeric suffix >= 6 digits, untruncated
|
||||
self.assertTrue(case_ref1.startswith(f"OPS-{year}-"))
|
||||
parts1 = case_ref1.split("-")
|
||||
self.assertEqual(len(parts1), 3)
|
||||
self.assertEqual(parts1[0], "OPS")
|
||||
self.assertEqual(parts1[1], str(year))
|
||||
self.assertTrue(parts1[2].isdigit())
|
||||
self.assertGreaterEqual(len(parts1[2]), 6)
|
||||
|
||||
res2 = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res2.status_code, 201)
|
||||
data2 = res2.json()
|
||||
case_ref2 = data2["case_reference"]
|
||||
self.assertTrue(case_ref2.startswith(f"OPS-{year}-"))
|
||||
self.assertNotEqual(case_ref1, case_ref2)
|
||||
|
||||
def test_case_reference_sequence_greater_than_999999(self):
|
||||
"""Values > 999999 format properly without truncation (e.g. sequence 1000000 -> OPS-2026-1000000)."""
|
||||
year = datetime.now(timezone.utc).year
|
||||
with DbSession(self.engine) as db:
|
||||
with patch.object(db, "execute") as mock_exec:
|
||||
mock_exec.return_value.scalar.return_value = 1000000
|
||||
case_ref = generate_incident_case_reference(db)
|
||||
self.assertEqual(case_ref, f"OPS-{year}-1000000")
|
||||
self.assertEqual(len(case_ref), 16)
|
||||
parts = case_ref.split("-")
|
||||
self.assertEqual(parts[0], "OPS")
|
||||
self.assertEqual(parts[1], str(year))
|
||||
self.assertEqual(parts[2], "1000000")
|
||||
|
||||
def test_fail_closed_case_reference_if_sequence_errors(self):
|
||||
with patch.object(main, "generate_incident_case_reference", side_effect=RuntimeError("Sequence connection lost")):
|
||||
payload = {"title": "Fail Closed Test", "summary": "Should fail", "severity": "high"}
|
||||
res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 500)
|
||||
|
||||
# Confirm zero incidents persisted
|
||||
with DbSession(self.engine) as db:
|
||||
count = db.scalar(select(OpsIncident.id))
|
||||
self.assertIsNone(count)
|
||||
|
||||
# ============================================================
|
||||
# 3. Pydantic Extra-Field Hardening Tests (extra='forbid')
|
||||
# ============================================================
|
||||
def test_incident_create_forbids_extra_fields(self):
|
||||
forbidden_fields = [
|
||||
{"id": str(uuid.uuid4())},
|
||||
{"case_reference": "OPS-2026-999999"},
|
||||
{"created_by_operator_id": str(uuid.uuid4())},
|
||||
{"closed_by_operator_id": str(uuid.uuid4())},
|
||||
{"actor_identity": "hacker@evil.com"},
|
||||
{"actor_role": "owner"},
|
||||
{"created_at": "2026-01-01T00:00:00Z"},
|
||||
]
|
||||
for extra in forbidden_fields:
|
||||
payload = {"title": "Valid Title", "summary": "Valid summary", "severity": "low"}
|
||||
payload.update(extra)
|
||||
res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422")
|
||||
|
||||
def test_incident_patch_forbids_extra_fields(self):
|
||||
inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Patch Extra Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
|
||||
case_ref = inc_res.json()["case_reference"]
|
||||
|
||||
forbidden_fields = [
|
||||
{"id": str(uuid.uuid4())},
|
||||
{"case_reference": "OPS-2026-888888"},
|
||||
{"created_by_operator_id": str(uuid.uuid4())},
|
||||
{"actor_role": "owner"},
|
||||
{"created_at": "2026-01-01T00:00:00Z"},
|
||||
]
|
||||
for extra in forbidden_fields:
|
||||
payload = {"status": "investigating"}
|
||||
payload.update(extra)
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422")
|
||||
|
||||
def test_incident_note_create_forbids_extra_fields(self):
|
||||
inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Note Extra Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
|
||||
case_ref = inc_res.json()["case_reference"]
|
||||
|
||||
forbidden_fields = [
|
||||
{"id": str(uuid.uuid4())},
|
||||
{"case_reference": "OPS-2026-777777"},
|
||||
{"created_by_operator_id": str(uuid.uuid4())},
|
||||
{"actor_role": "owner"},
|
||||
{"created_at": "2026-01-01T00:00:00Z"},
|
||||
]
|
||||
for extra in forbidden_fields:
|
||||
payload = {"body": "Legitimate note body"}
|
||||
payload.update(extra)
|
||||
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422, f"Extra field {list(extra.keys())[0]} must be rejected with 422")
|
||||
|
||||
# ============================================================
|
||||
# 4. Standard Input Validation Tests
|
||||
# ============================================================
|
||||
def test_incident_create_validation(self):
|
||||
# Empty title
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "", "summary": "Valid summary", "severity": "low"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
# Empty summary
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Valid title", "summary": "", "severity": "low"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
# Invalid severity
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Valid", "summary": "Valid", "severity": "catastrophic"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
# Title over 255 chars
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "A" * 256, "summary": "Valid", "severity": "low"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
def test_note_create_validation(self):
|
||||
inc_res = self.client.post("/api/v1/ops/incidents", json={"title": "Note Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
|
||||
case_ref = inc_res.json()["case_reference"]
|
||||
|
||||
# Empty note
|
||||
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": ""}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
# Whitespace-only note
|
||||
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": " \n\t "}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
# Note on nonexistent incident
|
||||
res = self.client.post("/api/v1/ops/incidents/OPS-9999-999999/notes", json={"body": "Note"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 404)
|
||||
|
||||
# ============================================================
|
||||
# 5. State Machine Transitions & Role Restrictions
|
||||
# ============================================================
|
||||
def test_standard_incident_lifecycle(self):
|
||||
# 1. Create -> Initial status is "open"
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Lifecycle", "summary": "Sum", "severity": "high"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 201)
|
||||
case_ref = res.json()["case_reference"]
|
||||
self.assertEqual(res.json()["status"], "open")
|
||||
|
||||
# 2. open -> investigating
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["status"], "investigating")
|
||||
|
||||
# 3. investigating -> monitoring
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "monitoring"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["status"], "monitoring")
|
||||
|
||||
# 4. monitoring -> resolved without resolution fails (422)
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "resolved"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 422)
|
||||
|
||||
# 5. monitoring -> resolved with resolution succeeds
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "resolved", "resolution": "Applied hotfix patch to ingress"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["status"], "resolved")
|
||||
self.assertEqual(res.json()["resolution"], "Applied hotfix patch to ingress")
|
||||
|
||||
# 6. resolved -> closed succeeds
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "closed"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["status"], "closed")
|
||||
self.assertIsNotNone(res.json()["closed_at"])
|
||||
self.assertEqual(res.json()["closed_by_identity"], self.op_identity)
|
||||
|
||||
def test_reopen_closed_incident_requires_owner(self):
|
||||
# Create and close incident
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Reopen Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
|
||||
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "closed", "resolution": "Fix verified"}, headers=self.op_headers)
|
||||
|
||||
# Operator attempt to reopen -> 403 Forbidden
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
self.assertIn("Only Owners may reopen", res.json()["detail"])
|
||||
|
||||
# Owner attempt to reopen -> 200 OK
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.owner_headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["status"], "investigating")
|
||||
self.assertIsNone(res.json()["closed_at"])
|
||||
self.assertIsNone(res.json()["closed_by_identity"])
|
||||
|
||||
def test_archive_incident_requires_owner_and_locks_modifications(self):
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Archive Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
|
||||
# Operator attempt to archive -> 403 Forbidden
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "archived"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 403)
|
||||
self.assertIn("Only Owners may archive", res.json()["detail"])
|
||||
|
||||
# Owner archives -> 200 OK
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "archived"}, headers=self.owner_headers)
|
||||
self.assertEqual(res.status_code, 200)
|
||||
self.assertEqual(res.json()["status"], "archived")
|
||||
|
||||
# Any subsequent patch on archived incident -> 409 Conflict
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"severity": "high"}, headers=self.owner_headers)
|
||||
self.assertEqual(res.status_code, 409)
|
||||
|
||||
# Note creation on archived incident -> 409 Conflict
|
||||
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Post-archive note"}, headers=self.owner_headers)
|
||||
self.assertEqual(res.status_code, 409)
|
||||
|
||||
def test_invalid_status_transition_rejected(self):
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Invalid Transition", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
|
||||
# open -> monitoring is disallowed (must go through investigating)
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "monitoring"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 409)
|
||||
|
||||
# ============================================================
|
||||
# 6. Append-Only Notes Tests
|
||||
# ============================================================
|
||||
def test_append_only_notes_chronology(self):
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Notes Chronology", "summary": "Sum", "severity": "high"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
|
||||
# Add Note 1 (by operator)
|
||||
n1 = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Initial triage complete."}, headers=self.op_headers)
|
||||
self.assertEqual(n1.status_code, 201)
|
||||
self.assertEqual(n1.json()["created_by_identity"], self.op_identity)
|
||||
|
||||
# Add Note 2 (by owner)
|
||||
n2 = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Infrastructure team engaged."}, headers=self.owner_headers)
|
||||
self.assertEqual(n2.status_code, 201)
|
||||
self.assertEqual(n2.json()["created_by_identity"], self.owner_identity)
|
||||
|
||||
# Fetch notes list -> returned in reverse chronological order (newest first)
|
||||
notes_res = self.client.get(f"/api/v1/ops/incidents/{case_ref}/notes", headers=self.op_headers)
|
||||
self.assertEqual(notes_res.status_code, 200)
|
||||
notes = notes_res.json()["notes"]
|
||||
self.assertEqual(len(notes), 2)
|
||||
self.assertEqual(notes[0]["body"], "Infrastructure team engaged.")
|
||||
self.assertEqual(notes[0]["created_by_identity"], self.owner_identity)
|
||||
self.assertEqual(notes[1]["body"], "Initial triage complete.")
|
||||
self.assertEqual(notes[1]["created_by_identity"], self.op_identity)
|
||||
|
||||
# ============================================================
|
||||
# 7. Transactional Audit Logging Tests
|
||||
# ============================================================
|
||||
def test_audit_events_recorded_on_incident_lifecycle(self):
|
||||
# Create incident
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Audit Lifecycle", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
|
||||
# Change severity
|
||||
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"severity": "critical"}, headers=self.op_headers)
|
||||
|
||||
# Add note
|
||||
self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Investigating spike in 5xx errors"}, headers=self.op_headers)
|
||||
|
||||
# Transition status
|
||||
self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
|
||||
|
||||
# Check audit events recorded in database
|
||||
with DbSession(self.engine) as db:
|
||||
events = db.scalars(
|
||||
select(OpsAuditEvent)
|
||||
.where(OpsAuditEvent.case_reference == case_ref)
|
||||
.order_by(OpsAuditEvent.timestamp.asc())
|
||||
).all()
|
||||
|
||||
actions = [e.action for e in events]
|
||||
self.assertIn("incident.created", actions)
|
||||
self.assertIn("incident.severity_changed", actions)
|
||||
self.assertIn("incident.note_added", actions)
|
||||
self.assertIn("incident.status_changed", actions)
|
||||
|
||||
# Verify actor identity and safe metadata
|
||||
for e in events:
|
||||
self.assertEqual(e.actor_identity, self.op_identity)
|
||||
self.assertEqual(e.target_resource, "ops.incident")
|
||||
self.assertEqual(e.result, "success")
|
||||
|
||||
# ============================================================
|
||||
# 8. Audit-Failure Rollback Tests (P1 Critical Requirement)
|
||||
# ============================================================
|
||||
def test_audit_failure_rolls_back_incident_creation(self):
|
||||
"""If ops_audit fails during incident creation, database transaction rolls back completely."""
|
||||
payload = {"title": "Rollback Create Incident", "summary": "Should never persist", "severity": "high"}
|
||||
|
||||
# Force ops_audit to raise RuntimeError
|
||||
with patch("main.ops_audit", side_effect=RuntimeError("Audit persistence failure!")):
|
||||
res = self.client.post("/api/v1/ops/incidents", json=payload, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 500)
|
||||
|
||||
# Assert ZERO records were committed to ops.incidents
|
||||
with DbSession(self.engine) as db:
|
||||
incident = db.scalar(select(OpsIncident).where(OpsIncident.title == "Rollback Create Incident"))
|
||||
self.assertIsNone(incident, "Incident must NOT be committed if audit fails")
|
||||
|
||||
def test_audit_failure_rolls_back_note_creation(self):
|
||||
"""If ops_audit fails during note creation, database transaction rolls back completely."""
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Rollback Note Test", "summary": "Sum", "severity": "low"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
|
||||
with patch("main.ops_audit", side_effect=RuntimeError("Audit disk full!")):
|
||||
res = self.client.post(f"/api/v1/ops/incidents/{case_ref}/notes", json={"body": "Phantom note"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 500)
|
||||
|
||||
with DbSession(self.engine) as db:
|
||||
notes = db.scalars(select(OpsIncidentNote).where(OpsIncidentNote.body == "Phantom note")).all()
|
||||
self.assertEqual(len(notes), 0, "Note must NOT be committed if audit fails")
|
||||
|
||||
def test_audit_failure_rolls_back_status_patch(self):
|
||||
"""If ops_audit fails during status mutation, database transaction rolls back completely."""
|
||||
res = self.client.post("/api/v1/ops/incidents", json={"title": "Rollback Patch Test", "summary": "Sum", "severity": "medium"}, headers=self.op_headers)
|
||||
case_ref = res.json()["case_reference"]
|
||||
|
||||
with patch("main.ops_audit", side_effect=RuntimeError("Audit stream broken!")):
|
||||
res = self.client.patch(f"/api/v1/ops/incidents/{case_ref}", json={"status": "investigating"}, headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 500)
|
||||
|
||||
with DbSession(self.engine) as db:
|
||||
incident = db.scalar(select(OpsIncident).where(OpsIncident.case_reference == case_ref))
|
||||
self.assertIsNotNone(incident)
|
||||
self.assertEqual(incident.status, "open", "Status must remain 'open' following audit failure rollback")
|
||||
|
||||
# ============================================================
|
||||
# 9. Foundation Security Invariants Tests
|
||||
# ============================================================
|
||||
def test_session_revocation_endpoint(self):
|
||||
"""DELETE /api/v1/ops/sessions/current revokes active session and logs audit event."""
|
||||
res = self.client.delete("/api/v1/ops/sessions/current", headers=self.op_headers)
|
||||
self.assertEqual(res.status_code, 204)
|
||||
|
||||
# Subsequent request with revoked session fails
|
||||
res2 = self.client.get("/api/v1/ops/incidents", headers=self.op_headers)
|
||||
self.assertEqual(res2.status_code, 401)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -12,11 +12,12 @@ import {
|
|||
interface AdminPageProps {
|
||||
searchParams?: Promise<{
|
||||
nav?: string;
|
||||
case?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
function parseNavParam(nav?: string): OperationsNavId {
|
||||
if (nav === 'audit' || nav === 'operators' || nav === 'session') {
|
||||
if (nav === 'incidents' || nav === 'audit' || nav === 'operators' || nav === 'session') {
|
||||
return nav;
|
||||
}
|
||||
return 'overview';
|
||||
|
|
@ -25,9 +26,10 @@ function parseNavParam(nav?: string): OperationsNavId {
|
|||
export default async function AdminPage({ searchParams }: AdminPageProps) {
|
||||
const resolvedParams = searchParams ? await searchParams : {};
|
||||
const initialNav = parseNavParam(resolvedParams.nav);
|
||||
const initialCaseRef = typeof resolvedParams.case === 'string' ? resolvedParams.case : null;
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
|
|
@ -148,6 +150,7 @@ export default async function AdminPage({ searchParams }: AdminPageProps) {
|
|||
total: auditTotal,
|
||||
}}
|
||||
initialNav={initialNav}
|
||||
initialCaseRef={initialCaseRef}
|
||||
/>
|
||||
);
|
||||
} catch {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sanitizeIncidentNote } from '../../../../../../lib/ops-data';
|
||||
import { hasValidOrigin } from '../../../../../../lib/request-origin';
|
||||
import { SafeIncidentNote } from '../../../../../../components/ops/types';
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{
|
||||
caseRef: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { caseRef } = await context.params;
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}/notes`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${opsToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Your Operations session has expired.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'You do not have authority to view incident notes.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Incident not found.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawNotes = Array.isArray(data?.notes) ? data.notes : [];
|
||||
const notes: SafeIncidentNote[] = rawNotes
|
||||
.map(sanitizeIncidentNote)
|
||||
.filter((n: SafeIncidentNote | null): n is SafeIncidentNote => n !== null);
|
||||
|
||||
return NextResponse.json({ notes });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to Operations platform.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
if (!hasValidOrigin(request)) {
|
||||
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { caseRef } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Invalid request body.' },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}/notes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${opsToken}`,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Your Operations session has expired.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'You do not have authority to add notes.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Incident not found.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (response.status === 409) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: data?.detail ?? 'Archived incidents cannot be modified.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (response.status === 422) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: data?.detail ?? 'Review the note fields and try again.' },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const sanitized = sanitizeIncidentNote(data);
|
||||
return NextResponse.json(sanitized ?? data, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to Operations platform.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sanitizeIncidentDetail } from '../../../../../lib/ops-data';
|
||||
import { hasValidOrigin } from '../../../../../lib/request-origin';
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{
|
||||
caseRef: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { caseRef } = await context.params;
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}`, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${opsToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Your Operations session has expired.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'You do not have authority to view this Incident.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Incident not found.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const sanitized = sanitizeIncidentDetail(data);
|
||||
if (!sanitized) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Invalid incident response from backend.' },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(sanitized);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to Operations platform.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: RouteContext) {
|
||||
if (!hasValidOrigin(request)) {
|
||||
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const { caseRef } = await context.params;
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Invalid request body.' },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/ops/incidents/${encodeURIComponent(caseRef)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
authorization: `Bearer ${opsToken}`,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Your Operations session has expired.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: data?.detail ?? 'You do not have authority for this action.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (response.status === 404) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Incident not found.' },
|
||||
{ status: 404 }
|
||||
);
|
||||
}
|
||||
if (response.status === 409) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: data?.detail ?? 'That status transition is not permitted.' },
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
if (response.status === 422) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: data?.detail ?? 'Review the incident fields and try again.' },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const sanitized = sanitizeIncidentDetail(data);
|
||||
return NextResponse.json(sanitized ?? data);
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to Operations platform.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,158 @@
|
|||
import { cookies } from 'next/headers';
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { sanitizeIncidentSummary } from '../../../../lib/ops-data';
|
||||
import { hasValidOrigin } from '../../../../lib/request-origin';
|
||||
import { SafeIncidentSummary } from '../../../../components/ops/types';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const status = searchParams.get('status');
|
||||
const severity = searchParams.get('severity');
|
||||
const caseRef = searchParams.get('case_reference');
|
||||
const cursor = searchParams.get('cursor');
|
||||
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
const targetUrl = new URL(`${baseUrl}/api/v1/ops/incidents`);
|
||||
if (status) targetUrl.searchParams.set('status', status);
|
||||
if (severity) targetUrl.searchParams.set('severity', severity);
|
||||
if (caseRef) targetUrl.searchParams.set('case_reference', caseRef);
|
||||
if (cursor) targetUrl.searchParams.set('cursor', cursor);
|
||||
|
||||
try {
|
||||
const response = await fetch(targetUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
authorization: `Bearer ${opsToken}`,
|
||||
accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Your Operations session has expired.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'You do not have authority to view Incidents.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const rawItems = Array.isArray(data?.items) ? data.items : [];
|
||||
const items: SafeIncidentSummary[] = rawItems
|
||||
.map(sanitizeIncidentSummary)
|
||||
.filter((i: SafeIncidentSummary | null): i is SafeIncidentSummary => i !== null);
|
||||
|
||||
return NextResponse.json({
|
||||
items,
|
||||
next_cursor: data?.next_cursor ?? null,
|
||||
});
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to Operations platform.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (!hasValidOrigin(request)) {
|
||||
return NextResponse.json({ detail: 'Invalid request origin.' }, { status: 403 });
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
const isProduction = process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_ENV === 'production';
|
||||
const opsToken = isProduction
|
||||
? cookieStore.get('__Host-motorsignal_ops')?.value
|
||||
: cookieStore.get('__Host-motorsignal_ops')?.value ??
|
||||
cookieStore.get('motorsignal_ops')?.value;
|
||||
|
||||
if (!opsToken) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Authentication required.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await request.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Invalid request body.' },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
|
||||
const baseUrl = process.env.MOTORSIGNAL_API_BASE_URL ?? 'https://api.motorsignal.app';
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/v1/ops/incidents`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
authorization: `Bearer ${opsToken}`,
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Your Operations session has expired.' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
if (response.status === 403) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'You do not have authority to create Incidents.' },
|
||||
{ status: 403 }
|
||||
);
|
||||
}
|
||||
if (response.status === 422) {
|
||||
const data = await response.json().catch(() => null);
|
||||
return NextResponse.json(
|
||||
{ detail: data?.detail ?? 'Invalid incident fields.' },
|
||||
{ status: 422 }
|
||||
);
|
||||
}
|
||||
if (!response.ok) {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Operations is temporarily unavailable.' },
|
||||
{ status: response.status >= 500 ? response.status : 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return NextResponse.json(data, { status: 201 });
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ detail: 'Unable to connect to Operations platform.' },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { IncidentSeverity } from './types';
|
||||
|
||||
interface OperationsCreateIncidentProps {
|
||||
onIncidentCreated: (caseRef: string) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
export function OperationsCreateIncident({
|
||||
onIncidentCreated,
|
||||
onCancel,
|
||||
}: OperationsCreateIncidentProps) {
|
||||
const [title, setTitle] = useState('');
|
||||
const [summary, setSummary] = useState('');
|
||||
const [severity, setSeverity] = useState<IncidentSeverity>('medium');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!title.trim()) {
|
||||
setErrorMessage('Incident title is required.');
|
||||
return;
|
||||
}
|
||||
if (!summary.trim()) {
|
||||
setErrorMessage('Incident summary is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/ops/incidents', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
title: title.trim(),
|
||||
summary: summary.trim(),
|
||||
severity,
|
||||
}),
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
setErrorMessage('Your Operations session has expired.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
setErrorMessage('You do not have authority to create incidents.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (response.status === 422) {
|
||||
const data = await response.json().catch(() => null);
|
||||
setErrorMessage(data?.detail ?? 'Please check the incident fields and try again.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setErrorMessage('Operations platform is temporarily unavailable.');
|
||||
setIsSubmitting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (data?.case_reference) {
|
||||
onIncidentCreated(data.case_reference);
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('Unable to connect to Operations platform.');
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b border-outline-variant/20 pb-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
|
||||
Create Operational Incident
|
||||
</h1>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">
|
||||
Case reference and open status will be generated automatically.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error Alert */}
|
||||
{errorMessage && (
|
||||
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Card */}
|
||||
<form onSubmit={handleSubmit} className="bg-surface-container-low p-6 rounded-xl border border-outline-variant/30 space-y-5">
|
||||
{/* Title */}
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="incident-title" className="block text-xs font-semibold text-on-surface">
|
||||
Incident Title <span className="text-error">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="incident-title"
|
||||
type="text"
|
||||
required
|
||||
maxLength={255}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="e.g. Ingress latency spike on edge cluster"
|
||||
className="w-full bg-surface-container px-3.5 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Severity Selection */}
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-xs font-semibold text-on-surface">
|
||||
Initial Severity <span className="text-error">*</span>
|
||||
</label>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2.5">
|
||||
{(['low', 'medium', 'high', 'critical'] as IncidentSeverity[]).map((sev) => {
|
||||
const isSelected = severity === sev;
|
||||
return (
|
||||
<button
|
||||
key={sev}
|
||||
type="button"
|
||||
onClick={() => setSeverity(sev)}
|
||||
className={`px-3 py-2.5 rounded-lg border text-xs font-mono font-bold uppercase transition-all flex flex-col items-center gap-1 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none ${
|
||||
isSelected
|
||||
? sev === 'critical'
|
||||
? 'bg-error/20 border-error text-error shadow-[0_0_12px_rgba(255,100,100,0.2)]'
|
||||
: sev === 'high'
|
||||
? 'bg-amber-500/20 border-amber-500 text-amber-300'
|
||||
: sev === 'medium'
|
||||
? 'bg-primary-container/20 border-primary-container text-primary-container'
|
||||
: 'bg-surface-container-high border-outline text-on-surface'
|
||||
: 'bg-surface-container border-outline-variant/30 text-outline hover:text-on-surface hover:bg-surface-container-high'
|
||||
}`}
|
||||
>
|
||||
<span>{sev}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="incident-summary" className="block text-xs font-semibold text-on-surface">
|
||||
Summary & Symptoms <span className="text-error">*</span>
|
||||
</label>
|
||||
<span className="text-[10px] font-mono text-outline">
|
||||
{summary.length} / 4000
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="incident-summary"
|
||||
required
|
||||
rows={5}
|
||||
maxLength={4000}
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
placeholder="Describe the incident symptoms, initial scope, and observed system behavior..."
|
||||
className="w-full bg-surface-container px-3.5 py-2.5 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions Bar */}
|
||||
<div className="flex items-center justify-end gap-3 pt-3 border-t border-outline-variant/20">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className="flex items-center gap-2 px-5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
{isSubmitting && (
|
||||
<span className="material-symbols-outlined text-[16px] animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
<span>{isSubmitting ? 'Creating...' : 'Create Incident'}</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,797 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
SafeIncidentDetail,
|
||||
SafeIncidentNote,
|
||||
IncidentSeverity,
|
||||
IncidentStatus,
|
||||
OperationsRole,
|
||||
} from './types';
|
||||
import {
|
||||
OperationsIncidentModal,
|
||||
IncidentModalAction,
|
||||
} from './operations-incident-modal';
|
||||
import { isValidStatusTransition } from '../../lib/ops-data';
|
||||
|
||||
interface OperationsIncidentDetailProps {
|
||||
caseReference: string;
|
||||
role: OperationsRole;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function OperationsIncidentDetail({
|
||||
caseReference,
|
||||
role,
|
||||
onBack,
|
||||
}: OperationsIncidentDetailProps) {
|
||||
const isOwner = role === 'owner';
|
||||
const [incident, setIncident] = useState<SafeIncidentDetail | null>(null);
|
||||
const [notes, setNotes] = useState<SafeIncidentNote[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Note form state
|
||||
const [newNoteBody, setNewNoteBody] = useState('');
|
||||
const [isAddingNote, setIsAddingNote] = useState(false);
|
||||
const [noteError, setNoteError] = useState<string | null>(null);
|
||||
|
||||
// Mutation state
|
||||
const [isMutating, setIsMutating] = useState(false);
|
||||
const [mutationSuccess, setMutationSuccess] = useState<string | null>(null);
|
||||
|
||||
// Confirmation Modal state
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalAction, setModalAction] = useState<IncidentModalAction>('close');
|
||||
const [isSubmittingModal, setIsSubmittingModal] = useState(false);
|
||||
|
||||
async function fetchIncidentData() {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const [detailRes, notesRes] = await Promise.all([
|
||||
fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
}),
|
||||
fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}/notes`, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
}),
|
||||
]);
|
||||
|
||||
if (detailRes.status === 401 || notesRes.status === 401) {
|
||||
setErrorMessage('Your Operations session has expired.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (detailRes.status === 403 || notesRes.status === 403) {
|
||||
setErrorMessage('You do not have authority to view this incident.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (detailRes.status === 404) {
|
||||
setErrorMessage('Incident not found.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
if (!detailRes.ok) {
|
||||
setErrorMessage('Failed to load incident detail.');
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const detailData = (await detailRes.json()) as SafeIncidentDetail;
|
||||
setIncident(detailData);
|
||||
|
||||
if (notesRes.ok) {
|
||||
const notesData = await notesRes.json();
|
||||
setNotes(Array.isArray(notesData?.notes) ? notesData.notes : []);
|
||||
}
|
||||
} catch {
|
||||
setErrorMessage('Unable to connect to Operations platform.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchIncidentData();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [caseReference]);
|
||||
|
||||
// Handle Add Note
|
||||
async function handleAddNote(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!newNoteBody.trim()) {
|
||||
setNoteError('Note body cannot be empty.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsAddingNote(true);
|
||||
setNoteError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}/notes`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ body: newNoteBody.trim() }),
|
||||
});
|
||||
|
||||
if (res.status === 401) {
|
||||
setNoteError('Your Operations session has expired.');
|
||||
setIsAddingNote(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 403) {
|
||||
setNoteError('You do not have authority to add notes.');
|
||||
setIsAddingNote(false);
|
||||
return;
|
||||
}
|
||||
if (res.status === 409) {
|
||||
setNoteError('Archived incidents cannot be modified.');
|
||||
setIsAddingNote(false);
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setNoteError('Failed to append note.');
|
||||
setIsAddingNote(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const addedNote = (await res.json()) as SafeIncidentNote;
|
||||
setNotes((prev) => [addedNote, ...prev]);
|
||||
setNewNoteBody('');
|
||||
} catch {
|
||||
setNoteError('Unable to connect to Operations platform.');
|
||||
} finally {
|
||||
setIsAddingNote(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Severity Change
|
||||
async function handleSeverityChange(newSeverity: IncidentSeverity) {
|
||||
if (!incident || incident.severity === newSeverity || isMutating) return;
|
||||
setIsMutating(true);
|
||||
setMutationSuccess(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ severity: newSeverity }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setErrorMessage(data?.detail ?? 'Failed to update severity.');
|
||||
setIsMutating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = (await res.json()) as SafeIncidentDetail;
|
||||
setIncident(updated);
|
||||
setMutationSuccess(`Severity updated to ${newSeverity.toUpperCase()}`);
|
||||
setTimeout(() => setMutationSuccess(null), 4000);
|
||||
} catch {
|
||||
setErrorMessage('Unable to connect to Operations platform.');
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Status Transition via Select
|
||||
async function handleStatusChange(targetStatus: IncidentStatus) {
|
||||
if (!incident || incident.status === targetStatus || isMutating) return;
|
||||
|
||||
// If target status is closed, route to modal to capture resolution
|
||||
if (targetStatus === 'closed') {
|
||||
setModalAction('close');
|
||||
setModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If target status is archived, route to modal
|
||||
if (targetStatus === 'archived') {
|
||||
setModalAction('archive');
|
||||
setModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// If target status is resolved without existing resolution, prompt for modal
|
||||
if (targetStatus === 'resolved' && (!incident.resolution || !incident.resolution.trim())) {
|
||||
setModalAction('close');
|
||||
setModalOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsMutating(true);
|
||||
setMutationSuccess(null);
|
||||
setErrorMessage(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ status: targetStatus }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setErrorMessage(data?.detail ?? 'Failed to update incident status.');
|
||||
setIsMutating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = (await res.json()) as SafeIncidentDetail;
|
||||
setIncident(updated);
|
||||
setMutationSuccess(`Status transitioned to ${targetStatus.toUpperCase()}`);
|
||||
setTimeout(() => setMutationSuccess(null), 4000);
|
||||
} catch {
|
||||
setErrorMessage('Unable to connect to Operations platform.');
|
||||
} finally {
|
||||
setIsMutating(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle Modal Confirmation
|
||||
async function handleModalConfirm(modalResolution?: string) {
|
||||
if (!incident || isSubmittingModal) return;
|
||||
setIsSubmittingModal(true);
|
||||
setErrorMessage(null);
|
||||
|
||||
let patchPayload: Record<string, string> = {};
|
||||
if (modalAction === 'close') {
|
||||
patchPayload = {
|
||||
status: 'closed',
|
||||
resolution: modalResolution ?? '',
|
||||
};
|
||||
} else if (modalAction === 'reopen') {
|
||||
patchPayload = { status: 'investigating' };
|
||||
} else if (modalAction === 'archive') {
|
||||
patchPayload = { status: 'archived' };
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/ops/incidents/${encodeURIComponent(caseReference)}`, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
accept: 'application/json',
|
||||
},
|
||||
body: JSON.stringify(patchPayload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => null);
|
||||
setErrorMessage(data?.detail ?? `Failed to perform ${modalAction} action.`);
|
||||
setIsSubmittingModal(false);
|
||||
setModalOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = (await res.json()) as SafeIncidentDetail;
|
||||
setIncident(updated);
|
||||
setModalOpen(false);
|
||||
setMutationSuccess(`Incident successfully ${modalAction === 'close' ? 'closed' : modalAction === 'reopen' ? 'reopened' : 'archived'}.`);
|
||||
setTimeout(() => setMutationSuccess(null), 4000);
|
||||
} catch {
|
||||
setErrorMessage('Unable to connect to Operations platform.');
|
||||
} finally {
|
||||
setIsSubmittingModal(false);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso?: string | null) {
|
||||
if (!iso) return '—';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
function getSeverityBadge(severity: IncidentSeverity) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
|
||||
CRITICAL
|
||||
</span>
|
||||
);
|
||||
case 'high':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||
HIGH
|
||||
</span>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
|
||||
MEDIUM
|
||||
</span>
|
||||
);
|
||||
case 'low':
|
||||
default:
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
||||
LOW
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: IncidentStatus) {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
||||
OPEN
|
||||
</span>
|
||||
);
|
||||
case 'investigating':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
INVESTIGATING
|
||||
</span>
|
||||
);
|
||||
case 'monitoring':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||
MONITORING
|
||||
</span>
|
||||
);
|
||||
case 'resolved':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-bold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
RESOLVED
|
||||
</span>
|
||||
);
|
||||
case 'closed':
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
|
||||
CLOSED
|
||||
</span>
|
||||
);
|
||||
case 'archived':
|
||||
default:
|
||||
return (
|
||||
<span className="px-2.5 py-1 rounded text-xs font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
||||
ARCHIVED
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-16 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
||||
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-xs font-medium">Loading incident details...</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!incident) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors"
|
||||
>
|
||||
← Back to Incidents
|
||||
</button>
|
||||
<div className="p-8 text-center text-error bg-error/10 border border-error/30 rounded-xl text-xs">
|
||||
{errorMessage ?? 'Incident not found.'}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const isArchived = incident.status === 'archived';
|
||||
const isClosed = incident.status === 'closed';
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Navigation & Actions Topbar */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBack}
|
||||
className="flex items-center gap-1 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
<span className="font-mono font-bold text-sm text-primary-container">
|
||||
{incident.case_reference}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchIncidentData()}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">refresh</span>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
{/* Quick Modal Triggers */}
|
||||
{!isArchived && !isClosed && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModalAction('close');
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-surface-container-high border border-outline-variant/40 text-xs font-semibold text-on-surface hover:bg-surface-container-highest transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px] text-emerald-400">
|
||||
task_alt
|
||||
</span>
|
||||
<span>Close Incident...</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isClosed && isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModalAction('reopen');
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-amber-500/15 border border-amber-500/30 text-xs font-bold text-amber-300 hover:bg-amber-500/25 transition-all focus-visible:ring-2 focus-visible:ring-amber-500 focus-visible:outline-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">replay</span>
|
||||
<span>Reopen (Owner)</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{!isArchived && isOwner && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setModalAction('archive');
|
||||
setModalOpen(true);
|
||||
}}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-error/10 border border-error/25 text-xs font-semibold text-error hover:bg-error/20 transition-all focus-visible:ring-2 focus-visible:ring-error focus-visible:outline-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">archive</span>
|
||||
<span>Archive (Owner)</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Alerts */}
|
||||
{errorMessage && (
|
||||
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mutationSuccess && (
|
||||
<div className="p-4 rounded-xl bg-emerald-500/10 border border-emerald-500/30 text-emerald-400 flex items-center gap-3 text-xs">
|
||||
<span className="material-symbols-outlined text-[18px]">check_circle</span>
|
||||
<span>{mutationSuccess}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incident Header Card */}
|
||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 shadow-sm space-y-4">
|
||||
<div className="flex flex-col md:flex-row md:items-start justify-between gap-4">
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2.5">
|
||||
<span className="font-mono text-xs font-bold text-primary-fixed-dim uppercase tracking-wider">
|
||||
{incident.case_reference}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{getSeverityBadge(incident.severity)}
|
||||
{getStatusBadge(incident.status)}
|
||||
</div>
|
||||
</div>
|
||||
<h1 className="text-lg font-headline-sm font-bold text-on-surface">
|
||||
{incident.title}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
{/* Mutation Selects (Active incidents only) */}
|
||||
{!isArchived && (
|
||||
<div className="flex flex-wrap items-center gap-3 bg-surface-container p-3 rounded-xl border border-outline-variant/30 shrink-0">
|
||||
{/* Severity Selector */}
|
||||
<div className="space-y-1">
|
||||
<span className="block text-[10px] font-mono uppercase text-outline">
|
||||
Severity
|
||||
</span>
|
||||
<select
|
||||
value={incident.severity}
|
||||
disabled={isMutating}
|
||||
onChange={(e) => handleSeverityChange(e.target.value as IncidentSeverity)}
|
||||
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-bold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value="critical">CRITICAL</option>
|
||||
<option value="high">HIGH</option>
|
||||
<option value="medium">MEDIUM</option>
|
||||
<option value="low">LOW</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Status Transition Selector */}
|
||||
<div className="space-y-1">
|
||||
<span className="block text-[10px] font-mono uppercase text-outline">
|
||||
Transition Status
|
||||
</span>
|
||||
<select
|
||||
value={incident.status}
|
||||
disabled={isMutating}
|
||||
onChange={(e) => handleStatusChange(e.target.value as IncidentStatus)}
|
||||
className="bg-surface-container-high px-2.5 py-1 rounded-lg border border-outline-variant/40 text-xs font-mono font-bold text-on-surface focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50"
|
||||
>
|
||||
<option value={incident.status} disabled>
|
||||
Current ({incident.status.toUpperCase()})
|
||||
</option>
|
||||
{(['open', 'investigating', 'monitoring', 'resolved', 'closed', 'archived'] as IncidentStatus[])
|
||||
.filter((s) => isValidStatusTransition(incident.status, s, role))
|
||||
.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
→ {s.toUpperCase()}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Metadata Grid */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-4 border-t border-outline-variant/20 text-xs">
|
||||
<div>
|
||||
<span className="block text-[10px] font-mono uppercase text-outline">Created By</span>
|
||||
<span className="font-mono text-on-surface-variant break-all">
|
||||
{incident.created_by_identity}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-mono uppercase text-outline">Created At</span>
|
||||
<span className="font-mono text-on-surface-variant">
|
||||
{formatDate(incident.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-mono uppercase text-outline">Last Updated</span>
|
||||
<span className="font-mono text-on-surface-variant">
|
||||
{formatDate(incident.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="block text-[10px] font-mono uppercase text-outline">Closed</span>
|
||||
<span className="font-mono text-on-surface-variant">
|
||||
{incident.closed_at ? formatDate(incident.closed_at) : '—'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary Card */}
|
||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-2">
|
||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-outline">
|
||||
Incident Summary
|
||||
</h2>
|
||||
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed">
|
||||
{incident.summary}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resolution Card (if present) */}
|
||||
{incident.resolution && (
|
||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-emerald-500/30 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-emerald-400 text-[18px]">
|
||||
task_alt
|
||||
</span>
|
||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-emerald-400">
|
||||
Resolution Record
|
||||
</h2>
|
||||
</div>
|
||||
<div className="text-xs text-on-surface whitespace-pre-wrap leading-relaxed bg-surface-container/60 p-4 rounded-xl border border-outline-variant/20 font-mono">
|
||||
{incident.resolution}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Two Column Layout: Operational Timeline & Append-only Notes */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Append-only Notes Section */}
|
||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
||||
notes
|
||||
</span>
|
||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-on-surface">
|
||||
Operator Notes
|
||||
</h2>
|
||||
</div>
|
||||
<span className="text-[10px] font-mono text-outline">
|
||||
Append-Only ({notes.length})
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Add Note Form */}
|
||||
{!isArchived && (
|
||||
<form onSubmit={handleAddNote} className="space-y-3 bg-surface-container p-4 rounded-xl border border-outline-variant/30">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="incident-note-body" className="block text-[11px] font-semibold text-on-surface">
|
||||
Add Operator Note
|
||||
</label>
|
||||
<span className="text-[10px] font-mono text-outline">
|
||||
{newNoteBody.length} / 4000
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
id="incident-note-body"
|
||||
required
|
||||
rows={3}
|
||||
maxLength={4000}
|
||||
value={newNoteBody}
|
||||
onChange={(e) => setNewNoteBody(e.target.value)}
|
||||
placeholder="Record operational observations, actions taken, or debugging findings..."
|
||||
className="w-full bg-surface-container-high px-3 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
|
||||
/>
|
||||
{noteError && (
|
||||
<p className="text-[11px] text-error">{noteError}</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isAddingNote || !newNoteBody.trim()}
|
||||
className="flex items-center gap-1.5 px-3.5 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-sm hover:bg-primary-container/90 transition-all disabled:opacity-50"
|
||||
>
|
||||
{isAddingNote && (
|
||||
<span className="material-symbols-outlined text-[14px] animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
<span>Add Note</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
|
||||
{/* Notes Feed */}
|
||||
{notes.length === 0 ? (
|
||||
<p className="text-xs text-outline text-center py-6">
|
||||
No operator notes recorded for this incident yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3 max-h-96 overflow-y-auto pr-1">
|
||||
{notes.map((note, idx) => (
|
||||
<div
|
||||
key={`${note.created_at}-${idx}`}
|
||||
className="p-3.5 rounded-xl bg-surface-container/60 border border-outline-variant/20 space-y-1.5 text-xs"
|
||||
>
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||
<span className="text-primary-fixed-dim font-bold">
|
||||
{note.created_by_identity}
|
||||
</span>
|
||||
<span>{formatDate(note.created_at)}</span>
|
||||
</div>
|
||||
<div className="text-on-surface whitespace-pre-wrap font-mono text-xs leading-relaxed">
|
||||
{note.body}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Operational Timeline Section */}
|
||||
<div className="bg-surface-container-low p-6 rounded-2xl border border-outline-variant/30 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[18px] text-primary-container">
|
||||
history
|
||||
</span>
|
||||
<h2 className="text-xs font-headline-sm font-bold uppercase tracking-wider text-on-surface">
|
||||
Operational Timeline
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="relative pl-6 space-y-6 before:absolute before:left-2 before:top-2 before:bottom-2 before:w-0.5 before:bg-outline-variant/30">
|
||||
{/* Created Event */}
|
||||
<div className="relative space-y-1">
|
||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-blue-500 border-2 border-surface-container-low" />
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||
<span className="font-bold text-on-surface uppercase">Incident Opened</span>
|
||||
<span>{formatDate(incident.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant">
|
||||
Created by <span className="font-mono text-primary-container">{incident.created_by_identity}</span> with initial severity <span className="font-mono uppercase font-semibold">{incident.severity}</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Notes Count Event */}
|
||||
{notes.length > 0 && (
|
||||
<div className="relative space-y-1">
|
||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-purple-400 border-2 border-surface-container-low" />
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||
<span className="font-bold text-on-surface uppercase">Operator Notes ({notes.length})</span>
|
||||
<span>{formatDate(notes[0]?.created_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant">
|
||||
{notes.length} append-only operational {notes.length === 1 ? 'note' : 'notes'} recorded.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Resolution Event (if present) */}
|
||||
{incident.resolution && (
|
||||
<div className="relative space-y-1">
|
||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-emerald-400 border-2 border-surface-container-low" />
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||
<span className="font-bold text-emerald-400 uppercase">Resolution Documented</span>
|
||||
<span>{formatDate(incident.updated_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant">
|
||||
Resolution summary recorded for this case.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Closed Event (if closed) */}
|
||||
{incident.closed_at && (
|
||||
<div className="relative space-y-1">
|
||||
<span className="absolute -left-6 top-0.5 w-3 h-3 rounded-full bg-outline border-2 border-surface-container-low" />
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline">
|
||||
<span className="font-bold text-on-surface uppercase">Incident Closed</span>
|
||||
<span>{formatDate(incident.closed_at)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant">
|
||||
Closed by <span className="font-mono text-on-surface">{incident.closed_by_identity ?? 'operator'}</span>.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirmation Dialog Modal */}
|
||||
<OperationsIncidentModal
|
||||
isOpen={modalOpen}
|
||||
action={modalAction}
|
||||
caseReference={incident.case_reference}
|
||||
initialResolution={incident.resolution ?? ''}
|
||||
isSubmitting={isSubmittingModal}
|
||||
onConfirm={handleModalConfirm}
|
||||
onClose={() => setModalOpen(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
|
||||
export type IncidentModalAction = 'close' | 'reopen' | 'archive';
|
||||
|
||||
interface OperationsIncidentModalProps {
|
||||
isOpen: boolean;
|
||||
action: IncidentModalAction;
|
||||
caseReference: string;
|
||||
initialResolution?: string;
|
||||
isSubmitting: boolean;
|
||||
onConfirm: (resolution?: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function OperationsIncidentModal({
|
||||
isOpen,
|
||||
action,
|
||||
caseReference,
|
||||
initialResolution = '',
|
||||
isSubmitting,
|
||||
onConfirm,
|
||||
onClose,
|
||||
}: OperationsIncidentModalProps) {
|
||||
const [resolution, setResolution] = useState(initialResolution);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const modalRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setResolution(initialResolution);
|
||||
setError(null);
|
||||
}, [isOpen, initialResolution, action]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape' && !isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
// Auto-focus appropriate element
|
||||
setTimeout(() => {
|
||||
if (action === 'close' && textareaRef.current) {
|
||||
textareaRef.current.focus();
|
||||
} else if (modalRef.current) {
|
||||
modalRef.current.focus();
|
||||
}
|
||||
}, 50);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}
|
||||
}, [isOpen, isSubmitting, action, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (action === 'close') {
|
||||
if (!resolution.trim()) {
|
||||
setError('Resolution summary is required before closing this incident.');
|
||||
return;
|
||||
}
|
||||
onConfirm(resolution.trim());
|
||||
} else {
|
||||
onConfirm();
|
||||
}
|
||||
}
|
||||
|
||||
const titles: Record<IncidentModalAction, string> = {
|
||||
close: 'Close Incident',
|
||||
reopen: 'Reopen Incident',
|
||||
archive: 'Archive Incident',
|
||||
};
|
||||
|
||||
const descriptions: Record<IncidentModalAction, string> = {
|
||||
close: `Closing incident ${caseReference} requires an authoritative resolution record.`,
|
||||
reopen: `Reopening incident ${caseReference} will transition status back to INVESTIGATING. (Owner authorization required)`,
|
||||
archive: `Archiving incident ${caseReference} is terminal. No further mutations will be permitted. (Owner authorization required)`,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/70 backdrop-blur-sm animate-in fade-in duration-150">
|
||||
<div
|
||||
ref={modalRef}
|
||||
tabIndex={-1}
|
||||
className="w-full max-w-md bg-surface-container-low border border-outline-variant/40 rounded-2xl p-6 shadow-2xl space-y-5 focus:outline-none"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
{/* Modal Header */}
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-primary-container text-[20px]">
|
||||
{action === 'close' ? 'task_alt' : action === 'reopen' ? 'replay' : 'archive'}
|
||||
</span>
|
||||
<h2 id="modal-title" className="text-base font-headline-sm font-bold text-on-surface uppercase">
|
||||
{titles[action]}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-xs font-mono font-bold text-primary-fixed-dim">
|
||||
{caseReference}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="text-outline hover:text-on-surface p-1 rounded-lg hover:bg-surface-container transition-colors"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[18px]">close</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-on-surface-variant leading-relaxed">
|
||||
{descriptions[action]}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-lg bg-error/10 border border-error/30 text-error text-xs flex items-center gap-2">
|
||||
<span className="material-symbols-outlined text-[16px]">error</span>
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{action === 'close' && (
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label htmlFor="modal-resolution" className="block text-xs font-semibold text-on-surface">
|
||||
Resolution Summary <span className="text-error">*</span>
|
||||
</label>
|
||||
<span className="text-[10px] font-mono text-outline">
|
||||
{resolution.length} / 4000
|
||||
</span>
|
||||
</div>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id="modal-resolution"
|
||||
required
|
||||
rows={4}
|
||||
maxLength={4000}
|
||||
value={resolution}
|
||||
onChange={(e) => setResolution(e.target.value)}
|
||||
placeholder="Explain the root cause and resolution applied..."
|
||||
className="w-full bg-surface-container px-3.5 py-2 rounded-lg border border-outline-variant/40 text-xs text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none resize-y"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal Actions */}
|
||||
<div className="flex items-center justify-end gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSubmitting}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface-variant hover:text-on-surface transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={`flex items-center gap-1.5 px-4 py-2 rounded-lg text-xs font-bold shadow-md transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none disabled:opacity-50 ${
|
||||
action === 'archive'
|
||||
? 'bg-error text-on-error hover:bg-error/90'
|
||||
: 'bg-primary-container text-on-primary-container hover:bg-primary-container/90'
|
||||
}`}
|
||||
>
|
||||
{isSubmitting && (
|
||||
<span className="material-symbols-outlined text-[16px] animate-spin">
|
||||
progress_activity
|
||||
</span>
|
||||
)}
|
||||
<span>
|
||||
{action === 'close'
|
||||
? 'Confirm & Close'
|
||||
: action === 'reopen'
|
||||
? 'Confirm Reopen'
|
||||
: 'Confirm Archive'}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,436 @@
|
|||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
SafeIncidentSummary,
|
||||
IncidentSeverity,
|
||||
IncidentStatus,
|
||||
OperationsRole,
|
||||
} from './types';
|
||||
|
||||
interface OperationsIncidentsListProps {
|
||||
role: OperationsRole;
|
||||
onSelectIncident: (caseRef: string) => void;
|
||||
onCreateIncident: () => void;
|
||||
}
|
||||
|
||||
export function OperationsIncidentsList({
|
||||
role,
|
||||
onSelectIncident,
|
||||
onCreateIncident,
|
||||
}: OperationsIncidentsListProps) {
|
||||
const [incidents, setIncidents] = useState<SafeIncidentSummary[]>([]);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||
|
||||
// Filters
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [severityFilter, setSeverityFilter] = useState<string>('all');
|
||||
const [caseSearch, setCaseSearch] = useState<string>('');
|
||||
|
||||
async function fetchIncidents(cursor?: string) {
|
||||
if (cursor) {
|
||||
setIsLoadingMore(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
setErrorMessage(null);
|
||||
}
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (statusFilter !== 'all') params.set('status', statusFilter);
|
||||
if (severityFilter !== 'all') params.set('severity', severityFilter);
|
||||
if (caseSearch.trim()) params.set('case_reference', caseSearch.trim());
|
||||
if (cursor) params.set('cursor', cursor);
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/ops/incidents?${params.toString()}`, {
|
||||
method: 'GET',
|
||||
headers: { accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (response.status === 401) {
|
||||
setErrorMessage('Your Operations session has expired.');
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
if (response.status === 403) {
|
||||
setErrorMessage('You do not have authority to view incidents.');
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
if (!response.ok) {
|
||||
setErrorMessage('Failed to load operational incidents.');
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const items: SafeIncidentSummary[] = Array.isArray(data?.items) ? data.items : [];
|
||||
|
||||
if (cursor) {
|
||||
setIncidents((prev) => [...prev, ...items]);
|
||||
} else {
|
||||
setIncidents(items);
|
||||
}
|
||||
setNextCursor(data?.next_cursor ?? null);
|
||||
} catch {
|
||||
setErrorMessage('Unable to connect to Operations platform.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsLoadingMore(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchIncidents();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [statusFilter, severityFilter]);
|
||||
|
||||
function handleSearchSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
fetchIncidents();
|
||||
}
|
||||
|
||||
function getSeverityBadge(severity: IncidentSeverity) {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-error/15 text-error border border-error/30">
|
||||
CRITICAL
|
||||
</span>
|
||||
);
|
||||
case 'high':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-bold tracking-wider bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
||||
HIGH
|
||||
</span>
|
||||
);
|
||||
case 'medium':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-primary-container/15 text-primary-container border border-primary-container/30">
|
||||
MEDIUM
|
||||
</span>
|
||||
);
|
||||
case 'low':
|
||||
default:
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container text-on-surface-variant border border-outline-variant/30">
|
||||
LOW
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: IncidentStatus) {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-blue-500/15 text-blue-400 border border-blue-500/30">
|
||||
OPEN
|
||||
</span>
|
||||
);
|
||||
case 'investigating':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-amber-500/15 text-amber-300 border border-amber-500/30">
|
||||
INVESTIGATING
|
||||
</span>
|
||||
);
|
||||
case 'monitoring':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-purple-500/15 text-purple-300 border border-purple-500/30">
|
||||
MONITORING
|
||||
</span>
|
||||
);
|
||||
case 'resolved':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-semibold tracking-wider bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
||||
RESOLVED
|
||||
</span>
|
||||
);
|
||||
case 'closed':
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-high text-outline border border-outline-variant/30">
|
||||
CLOSED
|
||||
</span>
|
||||
);
|
||||
case 'archived':
|
||||
default:
|
||||
return (
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase font-medium tracking-wider bg-surface-container-highest/60 text-outline border border-outline-variant/20">
|
||||
ARCHIVED
|
||||
</span>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
} catch {
|
||||
return iso;
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-b border-outline-variant/20 pb-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-headline-sm font-bold tracking-tight text-on-surface uppercase">
|
||||
Operational Incidents
|
||||
</h1>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-mono uppercase tracking-wider bg-primary-container/10 text-primary-container border border-primary-container/20">
|
||||
OPS-4
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-on-surface-variant mt-0.5">
|
||||
Isolated reliability & incident tracking with immutable case references.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchIncidents()}
|
||||
disabled={isLoading}
|
||||
className="flex items-center gap-1.5 px-3 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-medium text-on-surface-variant hover:text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
title="Refresh incidents list"
|
||||
>
|
||||
<span className={`material-symbols-outlined text-[16px] ${isLoading ? 'animate-spin' : ''}`}>
|
||||
refresh
|
||||
</span>
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateIncident}
|
||||
className="flex items-center gap-1.5 px-3.5 py-2 rounded-lg bg-primary-container text-on-primary-container text-xs font-bold shadow-md hover:bg-primary-container/90 transition-all focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[16px]">add_circle</span>
|
||||
<span>New Incident</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter Bar */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-3 bg-surface-container-low p-3.5 rounded-xl border border-outline-variant/20">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
{/* Status Filter */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-outline font-medium">Status:</span>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
className="bg-surface-container px-2.5 py-1.5 rounded-lg border border-outline-variant/30 text-xs text-on-surface font-medium focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
<option value="all">All Statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="investigating">Investigating</option>
|
||||
<option value="monitoring">Monitoring</option>
|
||||
<option value="resolved">Resolved</option>
|
||||
<option value="closed">Closed</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Severity Filter */}
|
||||
<div className="flex items-center gap-1.5 text-xs">
|
||||
<span className="text-outline font-medium">Severity:</span>
|
||||
<select
|
||||
value={severityFilter}
|
||||
onChange={(e) => setSeverityFilter(e.target.value)}
|
||||
className="bg-surface-container px-2.5 py-1.5 rounded-lg border border-outline-variant/30 text-xs text-on-surface font-medium focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
<option value="all">All Severities</option>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Case Reference Search */}
|
||||
<form onSubmit={handleSearchSubmit} className="flex items-center gap-2">
|
||||
<div className="relative flex-1 md:w-56">
|
||||
<input
|
||||
type="text"
|
||||
value={caseSearch}
|
||||
onChange={(e) => setCaseSearch(e.target.value)}
|
||||
placeholder="Search Case Reference..."
|
||||
className="w-full bg-surface-container px-3 py-1.5 pr-8 rounded-lg border border-outline-variant/30 text-xs font-mono text-on-surface placeholder:text-outline focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
/>
|
||||
{caseSearch && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setCaseSearch('');
|
||||
fetchIncidents();
|
||||
}}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-outline hover:text-on-surface"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">close</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-3 py-1.5 bg-surface-container-high border border-outline-variant/40 rounded-lg text-xs font-medium text-on-surface hover:bg-surface-container-highest transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
Filter
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Error Banner */}
|
||||
{errorMessage && (
|
||||
<div className="p-4 rounded-xl bg-error/10 border border-error/30 text-error flex items-center gap-3 text-xs">
|
||||
<span className="material-symbols-outlined text-[18px]">error</span>
|
||||
<span>{errorMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Incidents Content: Desktop Table & Mobile Cards */}
|
||||
{isLoading ? (
|
||||
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
||||
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
|
||||
progress_activity
|
||||
</span>
|
||||
<span className="text-xs font-medium">Loading operational incidents...</span>
|
||||
</div>
|
||||
) : incidents.length === 0 ? (
|
||||
<div className="p-12 text-center text-outline bg-surface-container-lowest rounded-xl border border-outline-variant/20 flex flex-col items-center justify-center gap-3">
|
||||
<span className="material-symbols-outlined text-3xl text-outline-variant">
|
||||
assignment_turned_in
|
||||
</span>
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-semibold text-on-surface">No operational incidents found</p>
|
||||
<p className="text-[11px] text-outline">
|
||||
{statusFilter !== 'all' || severityFilter !== 'all' || caseSearch
|
||||
? 'No incidents match the selected filter criteria.'
|
||||
: 'No incidents recorded in the Operations platform.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCreateIncident}
|
||||
className="mt-2 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-primary-container text-on-primary-container text-xs font-semibold hover:bg-primary-container/90 transition-all"
|
||||
>
|
||||
<span className="material-symbols-outlined text-[14px]">add</span>
|
||||
<span>Create First Incident</span>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Desktop Table View (hidden on small screens) */}
|
||||
<div className="hidden md:block overflow-hidden rounded-xl border border-outline-variant/30 bg-surface-container-lowest shadow-sm">
|
||||
<table className="w-full text-left text-xs border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-outline-variant/20 bg-surface-container-low text-[11px] font-mono text-outline uppercase tracking-wider">
|
||||
<th className="py-3 px-4 font-semibold">CASE</th>
|
||||
<th className="py-3 px-4 font-semibold">TITLE</th>
|
||||
<th className="py-3 px-4 font-semibold">SEVERITY</th>
|
||||
<th className="py-3 px-4 font-semibold">STATUS</th>
|
||||
<th className="py-3 px-4 font-semibold">UPDATED</th>
|
||||
<th className="py-3 px-4 font-semibold">CREATED</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-outline-variant/10 text-on-surface">
|
||||
{incidents.map((incident) => (
|
||||
<tr
|
||||
key={incident.case_reference}
|
||||
onClick={() => onSelectIncident(incident.case_reference)}
|
||||
className="hover:bg-surface-container/50 cursor-pointer transition-colors group focus-within:bg-surface-container"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onSelectIncident(incident.case_reference);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<td className="py-3 px-4 font-mono font-bold text-primary-container group-hover:underline">
|
||||
{incident.case_reference}
|
||||
</td>
|
||||
<td className="py-3 px-4 font-medium text-on-surface max-w-xs truncate">
|
||||
{incident.title}
|
||||
</td>
|
||||
<td className="py-3 px-4">{getSeverityBadge(incident.severity)}</td>
|
||||
<td className="py-3 px-4">{getStatusBadge(incident.status)}</td>
|
||||
<td className="py-3 px-4 text-[11px] font-mono text-on-surface-variant">
|
||||
{formatDate(incident.updated_at)}
|
||||
</td>
|
||||
<td className="py-3 px-4 text-[11px] font-mono text-outline">
|
||||
{formatDate(incident.created_at)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Mobile Stacked Card View (shown only on small screens) */}
|
||||
<div className="md:hidden space-y-3">
|
||||
{incidents.map((incident) => (
|
||||
<button
|
||||
key={incident.case_reference}
|
||||
type="button"
|
||||
onClick={() => onSelectIncident(incident.case_reference)}
|
||||
className="w-full text-left p-4 rounded-xl border border-outline-variant/20 bg-surface-container-lowest hover:bg-surface-container-low transition-colors shadow-sm focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none space-y-2.5"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-mono font-bold text-xs text-primary-container">
|
||||
{incident.case_reference}
|
||||
</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getSeverityBadge(incident.severity)}
|
||||
{getStatusBadge(incident.status)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs font-semibold text-on-surface line-clamp-2">
|
||||
{incident.title}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between text-[10px] font-mono text-outline pt-1 border-t border-outline-variant/10">
|
||||
<span>Updated: {formatDate(incident.updated_at)}</span>
|
||||
<span className="text-primary-fixed-dim">View Details →</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cursor Pagination */}
|
||||
{nextCursor && (
|
||||
<div className="pt-2 text-center">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => fetchIncidents(nextCursor)}
|
||||
disabled={isLoadingMore}
|
||||
className="px-4 py-2 rounded-lg bg-surface-container border border-outline-variant/30 text-xs font-semibold text-on-surface hover:bg-surface-container-high transition-colors focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none"
|
||||
>
|
||||
{isLoadingMore ? 'Loading More...' : 'Load More Incidents'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ import { OperationsOverview } from './operations-overview';
|
|||
import { OperationsAudit } from './operations-audit';
|
||||
import { OperationsOperators } from './operations-operators';
|
||||
import { OperationsSessionView } from './operations-session';
|
||||
import { OperationsIncidentsList } from './operations-incidents-list';
|
||||
import { OperationsCreateIncident } from './operations-create-incident';
|
||||
import { OperationsIncidentDetail } from './operations-incident-detail';
|
||||
|
||||
interface OperationsShellProps {
|
||||
initialSession: OperatorSession;
|
||||
|
|
@ -23,6 +26,7 @@ interface OperationsShellProps {
|
|||
total?: number;
|
||||
};
|
||||
initialNav?: OperationsNavId;
|
||||
initialCaseRef?: string | null;
|
||||
}
|
||||
|
||||
export function OperationsShell({
|
||||
|
|
@ -30,11 +34,18 @@ export function OperationsShell({
|
|||
initialHealth,
|
||||
initialAudit,
|
||||
initialNav = 'overview',
|
||||
initialCaseRef = null,
|
||||
}: OperationsShellProps) {
|
||||
const [session, setSession] = useState<OperatorSession>(initialSession);
|
||||
const [activeNav, setActiveNav] = useState<OperationsNavId>(initialNav);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
|
||||
// Incidents navigation sub-state
|
||||
const [incidentView, setIncidentView] = useState<'list' | 'create' | 'detail'>(
|
||||
initialCaseRef ? 'detail' : 'list'
|
||||
);
|
||||
const [selectedCaseRef, setSelectedCaseRef] = useState<string | null>(initialCaseRef);
|
||||
|
||||
// Health state
|
||||
const [health, setHealth] = useState<OperationsHealth>(initialHealth);
|
||||
const [isRefreshingHealth, setIsRefreshingHealth] = useState(false);
|
||||
|
|
@ -163,6 +174,42 @@ export function OperationsShell({
|
|||
/>
|
||||
)}
|
||||
|
||||
{activeNav === 'incidents' && (
|
||||
<>
|
||||
{incidentView === 'list' && (
|
||||
<OperationsIncidentsList
|
||||
role={session.role}
|
||||
onSelectIncident={(caseRef) => {
|
||||
setSelectedCaseRef(caseRef);
|
||||
setIncidentView('detail');
|
||||
}}
|
||||
onCreateIncident={() => setIncidentView('create')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{incidentView === 'create' && (
|
||||
<OperationsCreateIncident
|
||||
onIncidentCreated={(caseRef) => {
|
||||
setSelectedCaseRef(caseRef);
|
||||
setIncidentView('detail');
|
||||
}}
|
||||
onCancel={() => setIncidentView('list')}
|
||||
/>
|
||||
)}
|
||||
|
||||
{incidentView === 'detail' && selectedCaseRef && (
|
||||
<OperationsIncidentDetail
|
||||
caseReference={selectedCaseRef}
|
||||
role={session.role}
|
||||
onBack={() => {
|
||||
setSelectedCaseRef(null);
|
||||
setIncidentView('list');
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{activeNav === 'audit' && (
|
||||
<OperationsAudit
|
||||
role={session.role}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
'use client';
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { OperationsNavId, OperationsRole } from './types';
|
||||
|
|
@ -32,6 +32,11 @@ export function OperationsSidebar({
|
|||
label: 'Overview',
|
||||
icon: 'dashboard',
|
||||
},
|
||||
{
|
||||
id: 'incidents',
|
||||
label: 'Incidents',
|
||||
icon: 'assignment',
|
||||
},
|
||||
{
|
||||
id: 'audit',
|
||||
label: 'Audit',
|
||||
|
|
|
|||
|
|
@ -50,4 +50,69 @@ export interface OperatorSession {
|
|||
environment?: string;
|
||||
}
|
||||
|
||||
export type OperationsNavId = 'overview' | 'audit' | 'operators' | 'session';
|
||||
export type OperationsNavId =
|
||||
| 'overview'
|
||||
| 'incidents'
|
||||
| 'audit'
|
||||
| 'operators'
|
||||
| 'session';
|
||||
|
||||
export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical';
|
||||
|
||||
export type IncidentStatus =
|
||||
| 'open'
|
||||
| 'investigating'
|
||||
| 'monitoring'
|
||||
| 'resolved'
|
||||
| 'closed'
|
||||
| 'archived';
|
||||
|
||||
export interface SafeIncidentSummary {
|
||||
case_reference: string;
|
||||
title: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface SafeIncidentDetail {
|
||||
case_reference: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
severity: IncidentSeverity;
|
||||
status: IncidentStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at?: string | null;
|
||||
resolution?: string | null;
|
||||
created_by_identity: string;
|
||||
closed_by_identity?: string | null;
|
||||
}
|
||||
|
||||
export interface SafeIncidentNote {
|
||||
created_at: string;
|
||||
created_by_identity: string;
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface IncidentListResponse {
|
||||
items: SafeIncidentSummary[];
|
||||
next_cursor?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateIncidentInput {
|
||||
title: string;
|
||||
summary: string;
|
||||
severity: IncidentSeverity;
|
||||
}
|
||||
|
||||
export interface UpdateIncidentInput {
|
||||
severity?: IncidentSeverity;
|
||||
status?: IncidentStatus;
|
||||
resolution?: string;
|
||||
}
|
||||
|
||||
export interface AddIncidentNoteInput {
|
||||
body: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -212,3 +212,137 @@ export function sanitizeAuditEvent(raw: unknown): SafeAuditEvent | null {
|
|||
metadata,
|
||||
};
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Incident Sanitizers & Validators (OPS-4)
|
||||
// ============================================================
|
||||
|
||||
export function isValidIncidentSeverity(severity: unknown): severity is import('../components/ops/types').IncidentSeverity {
|
||||
return (
|
||||
severity === 'low' ||
|
||||
severity === 'medium' ||
|
||||
severity === 'high' ||
|
||||
severity === 'critical'
|
||||
);
|
||||
}
|
||||
|
||||
export function isValidIncidentStatus(status: unknown): status is import('../components/ops/types').IncidentStatus {
|
||||
return (
|
||||
status === 'open' ||
|
||||
status === 'investigating' ||
|
||||
status === 'monitoring' ||
|
||||
status === 'resolved' ||
|
||||
status === 'closed' ||
|
||||
status === 'archived'
|
||||
);
|
||||
}
|
||||
|
||||
const ALLOWED_INCIDENT_TRANSITIONS: Record<string, Set<string>> = {
|
||||
open: new Set(['investigating', 'resolved', 'closed']),
|
||||
investigating: new Set(['monitoring', 'resolved', 'closed']),
|
||||
monitoring: new Set(['investigating', 'resolved', 'closed']),
|
||||
resolved: new Set(['closed', 'investigating']),
|
||||
closed: new Set(['investigating']), // Reopen (Owner only)
|
||||
archived: new Set(), // Terminal
|
||||
};
|
||||
|
||||
export function isValidStatusTransition(
|
||||
current: import('../components/ops/types').IncidentStatus,
|
||||
target: import('../components/ops/types').IncidentStatus,
|
||||
role: import('../components/ops/types').OperationsRole
|
||||
): boolean {
|
||||
if (current === target) return false;
|
||||
if (current === 'archived') return false; // Terminal
|
||||
if (target === 'archived') return role === 'owner'; // Owner only
|
||||
if (current === 'closed' && target === 'investigating') return role === 'owner'; // Owner only
|
||||
const allowed = ALLOWED_INCIDENT_TRANSITIONS[current];
|
||||
return allowed ? allowed.has(target) : false;
|
||||
}
|
||||
|
||||
export function sanitizeIncidentSummary(raw: unknown): import('../components/ops/types').SafeIncidentSummary | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.case_reference !== 'string' || !obj.case_reference.trim()) return null;
|
||||
const case_reference = obj.case_reference.trim();
|
||||
|
||||
if (typeof obj.title !== 'string' || !obj.title.trim()) return null;
|
||||
const title = obj.title.trim();
|
||||
|
||||
if (!isValidIncidentSeverity(obj.severity)) return null;
|
||||
const severity = obj.severity;
|
||||
|
||||
if (!isValidIncidentStatus(obj.status)) return null;
|
||||
const status = obj.status;
|
||||
|
||||
if (typeof obj.created_at !== 'string' || !obj.created_at.trim()) return null;
|
||||
const created_at = obj.created_at.trim();
|
||||
|
||||
const updated_at = typeof obj.updated_at === 'string' && obj.updated_at.trim()
|
||||
? obj.updated_at.trim()
|
||||
: created_at;
|
||||
|
||||
return {
|
||||
case_reference,
|
||||
title,
|
||||
severity,
|
||||
status,
|
||||
created_at,
|
||||
updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeIncidentDetail(raw: unknown): import('../components/ops/types').SafeIncidentDetail | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
const summaryData = sanitizeIncidentSummary(raw);
|
||||
if (!summaryData) return null;
|
||||
|
||||
if (typeof obj.summary !== 'string' || !obj.summary.trim()) return null;
|
||||
const summary = obj.summary.trim();
|
||||
|
||||
if (typeof obj.created_by_identity !== 'string' || !obj.created_by_identity.trim()) return null;
|
||||
const created_by_identity = obj.created_by_identity.trim();
|
||||
|
||||
const closed_at = typeof obj.closed_at === 'string' && obj.closed_at.trim()
|
||||
? obj.closed_at.trim()
|
||||
: null;
|
||||
|
||||
const resolution = typeof obj.resolution === 'string' && obj.resolution.trim()
|
||||
? obj.resolution.trim()
|
||||
: null;
|
||||
|
||||
const closed_by_identity = typeof obj.closed_by_identity === 'string' && obj.closed_by_identity.trim()
|
||||
? obj.closed_by_identity.trim()
|
||||
: null;
|
||||
|
||||
return {
|
||||
...summaryData,
|
||||
summary,
|
||||
created_by_identity,
|
||||
closed_at,
|
||||
resolution,
|
||||
closed_by_identity,
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeIncidentNote(raw: unknown): import('../components/ops/types').SafeIncidentNote | null {
|
||||
if (!raw || typeof raw !== 'object') return null;
|
||||
const obj = raw as Record<string, unknown>;
|
||||
|
||||
if (typeof obj.created_at !== 'string' || !obj.created_at.trim()) return null;
|
||||
const created_at = obj.created_at.trim();
|
||||
|
||||
if (typeof obj.created_by_identity !== 'string' || !obj.created_by_identity.trim()) return null;
|
||||
const created_by_identity = obj.created_by_identity.trim();
|
||||
|
||||
if (typeof obj.body !== 'string' || !obj.body.trim()) return null;
|
||||
const body = obj.body.trim();
|
||||
|
||||
return {
|
||||
created_at,
|
||||
created_by_identity,
|
||||
body,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
|
||||
export function hasValidOrigin(request: NextRequest): boolean {
|
||||
const origin = request.headers.get('origin');
|
||||
const host = request.headers.get('x-forwarded-host')?.split(',')[0].trim()
|
||||
?? request.headers.get('host');
|
||||
const protocol = request.headers.get('x-forwarded-proto')?.split(',')[0].trim()
|
||||
?? request.nextUrl.protocol.slice(0, -1);
|
||||
if (!origin || !host) return false;
|
||||
try {
|
||||
const parsed = new URL(origin);
|
||||
return parsed.protocol === `${protocol}:` && parsed.host === host;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -38,7 +38,7 @@ function loadTsModule(relativePath) {
|
|||
return m.exports;
|
||||
}
|
||||
|
||||
console.log('--- STARTING OPS-3C, OPS-3C.1 & OPS-3C.2 OPERATIONS TEST SUITE ---');
|
||||
console.log('--- STARTING OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3 & OPS-4 OPERATIONS TEST SUITE ---');
|
||||
|
||||
// Load modules
|
||||
const opsData = loadTsModule('../lib/ops-data.ts');
|
||||
|
|
@ -48,6 +48,12 @@ const {
|
|||
isValidHealthStatus,
|
||||
HEALTH_COMPONENT_METADATA,
|
||||
APPROVED_AUDIT_METADATA_KEYS,
|
||||
isValidIncidentSeverity,
|
||||
isValidIncidentStatus,
|
||||
isValidStatusTransition,
|
||||
sanitizeIncidentSummary,
|
||||
sanitizeIncidentDetail,
|
||||
sanitizeIncidentNote,
|
||||
} = opsData;
|
||||
|
||||
// ============================================================
|
||||
|
|
@ -332,7 +338,7 @@ for (const file of allOpsFiles) {
|
|||
assert.ok(!content.includes('/api/v1/admin'), `${file} must not reference /api/v1/admin`);
|
||||
assert.ok(!content.includes('AdminConsole'), `${file} must not reference AdminConsole`);
|
||||
assert.ok(!content.includes('rider_id'), `${file} must not reference rider_id`);
|
||||
assert.ok(!content.includes('vin'), `${file} must not reference vin`);
|
||||
assert.ok(!/\bvin\b/i.test(content), `${file} must not reference VIN`);
|
||||
assert.ok(!content.includes('telemetry_points'), `${file} must not reference telemetry_points`);
|
||||
}
|
||||
console.log(' PASS: All Operations components free of retired admin APIs and rider data.');
|
||||
|
|
@ -431,9 +437,9 @@ console.log(' PASS: Responsive grid and drawer navigation adapt across 1440px,
|
|||
|
||||
// ============================================================
|
||||
// 20. SECURITY UX & RUNTIME AUDIT
|
||||
// ============================================================
|
||||
console.log('Testing 20: Security UX & Third-Party Runtime Audit...');
|
||||
for (const file of allOpsFiles) {
|
||||
const currentOpsFiles = fs.readdirSync(opsDir);
|
||||
for (const file of currentOpsFiles) {
|
||||
const content = fs.readFileSync(path.join(opsDir, file), 'utf8');
|
||||
assert.ok(!content.includes('localStorage'), `${file} must not use localStorage`);
|
||||
assert.ok(!content.includes('sessionStorage'), `${file} must not use sessionStorage`);
|
||||
|
|
@ -443,4 +449,392 @@ for (const file of allOpsFiles) {
|
|||
}
|
||||
console.log(' PASS: Zero localStorage/sessionStorage auth, zero dangerouslySetInnerHTML, zero third-party CDNs.');
|
||||
|
||||
console.log('--- ALL OPS-3C, OPS-3C.1 & OPS-3C.2 TESTS PASSED (20/20) ---');
|
||||
// ============================================================
|
||||
// 21. INCIDENTS NAVIGATION PLACEMENT & ROLE VISIBILITY (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 21: Incidents Navigation Placement & Role Accessibility...');
|
||||
const sidebarSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-sidebar.tsx'), 'utf8');
|
||||
assert.ok(
|
||||
sidebarSource.includes("id: 'incidents'") && sidebarSource.includes("label: 'Incidents'"),
|
||||
'OperationsSidebar must include Incidents navigation item'
|
||||
);
|
||||
|
||||
// Order check: Overview -> Incidents -> Audit -> Operators -> My Session
|
||||
const overviewIdx = sidebarSource.indexOf("id: 'overview'");
|
||||
const incidentsIdx = sidebarSource.indexOf("id: 'incidents'");
|
||||
const auditIdx = sidebarSource.indexOf("id: 'audit'");
|
||||
const operatorsIdx = sidebarSource.indexOf("id: 'operators'");
|
||||
const sessionIdx = sidebarSource.indexOf("id: 'session'");
|
||||
assert.ok(
|
||||
overviewIdx < incidentsIdx && incidentsIdx < auditIdx && auditIdx < operatorsIdx && operatorsIdx < sessionIdx,
|
||||
'Navigation items must be strictly ordered: Overview -> Incidents -> Audit -> Operators -> My Session'
|
||||
);
|
||||
|
||||
// Incidents has no role gate badge in sidebar (accessible to both operator and owner)
|
||||
const incidentsSection = sidebarSource.substring(incidentsIdx, auditIdx);
|
||||
assert.ok(
|
||||
!incidentsSection.includes("badge: isOwner ? 'OWNER' : 'RESTRICTED'"),
|
||||
'Incidents must be accessible to both operator and owner without restricted badge'
|
||||
);
|
||||
console.log(' PASS: Incidents placed at position 2, accessible to operator and owner.');
|
||||
|
||||
// ============================================================
|
||||
// 22. CASE REFERENCE FORMAT & IMMUTABILITY (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 22: Case Reference Server Generation & Pattern Validation...');
|
||||
const sampleCaseRef = 'OPS-2026-000001';
|
||||
const caseRefRegex = /^OPS-\d{4}-\d{6}$/;
|
||||
assert.ok(caseRefRegex.test(sampleCaseRef), 'Case reference must match OPS-YYYY-NNNNNN');
|
||||
assert.ok(!caseRefRegex.test('INC-1234'), 'Invalid format must be rejected');
|
||||
assert.ok(!caseRefRegex.test('OPS-2026-1'), 'Unpadded sequence must be rejected');
|
||||
|
||||
// Ensure Create Incident form does not provide a case reference input field
|
||||
const createIncidentSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-create-incident.tsx'), 'utf8');
|
||||
assert.ok(
|
||||
!createIncidentSource.includes('case_reference') || !createIncidentSource.includes('input type="text" name="case_reference"'),
|
||||
'Create Incident form must NOT accept a client-generated case reference'
|
||||
);
|
||||
console.log(' PASS: Case references strictly conform to OPS-YYYY-NNNNNN immutable pattern.');
|
||||
|
||||
// ============================================================
|
||||
// 23. INTERNAL UUID ISOLATION (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 23: Internal Database UUID Isolation in Frontend & Sanitizers...');
|
||||
const rawBackendIncident = {
|
||||
id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
case_reference: 'OPS-2026-000042',
|
||||
title: 'Database connection pool saturation',
|
||||
summary: 'Connection exhaustion under high concurrent load',
|
||||
severity: 'high',
|
||||
status: 'investigating',
|
||||
created_at: '2026-09-10T08:00:00Z',
|
||||
updated_at: '2026-09-10T08:15:00Z',
|
||||
created_by_operator_id: 'e4d9b1c2-3f4a-5b6c-7d8e-9f0a1b2c3d4e',
|
||||
created_by_identity: 'operator@motorsignal.com',
|
||||
closed_at: null,
|
||||
closed_by_operator_id: null,
|
||||
closed_by_identity: null,
|
||||
resolution: null,
|
||||
};
|
||||
|
||||
const sanitizedSummary = sanitizeIncidentSummary(rawBackendIncident);
|
||||
assert.ok(sanitizedSummary !== null);
|
||||
assert.equal(sanitizedSummary.case_reference, 'OPS-2026-000042');
|
||||
assert.equal(sanitizedSummary.title, 'Database connection pool saturation');
|
||||
assert.equal(sanitizedSummary.severity, 'high');
|
||||
assert.equal(sanitizedSummary.status, 'investigating');
|
||||
assert.equal(sanitizedSummary.id, undefined, 'Summary DTO must not contain database UUID');
|
||||
|
||||
const sanitizedDetail = sanitizeIncidentDetail(rawBackendIncident);
|
||||
assert.ok(sanitizedDetail !== null);
|
||||
assert.equal(sanitizedDetail.case_reference, 'OPS-2026-000042');
|
||||
assert.equal(sanitizedDetail.created_by_identity, 'operator@motorsignal.com');
|
||||
assert.equal(sanitizedDetail.id, undefined, 'Detail DTO must not contain database UUID');
|
||||
assert.equal(sanitizedDetail.created_by_operator_id, undefined, 'Detail DTO must not contain operator UUID');
|
||||
console.log(' PASS: Database UUIDs strictly stripped from all Incident DTOs.');
|
||||
|
||||
// ============================================================
|
||||
// 24. INCIDENT SEVERITY ENUMS (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 24: Incident Severity Enums...');
|
||||
assert.equal(isValidIncidentSeverity('low'), true);
|
||||
assert.equal(isValidIncidentSeverity('medium'), true);
|
||||
assert.equal(isValidIncidentSeverity('high'), true);
|
||||
assert.equal(isValidIncidentSeverity('critical'), true);
|
||||
assert.equal(isValidIncidentSeverity('p0'), false);
|
||||
assert.equal(isValidIncidentSeverity('urgent'), false);
|
||||
assert.equal(isValidIncidentSeverity('blocker'), false);
|
||||
console.log(' PASS: Incident severity strictly locked to low, medium, high, critical.');
|
||||
|
||||
// ============================================================
|
||||
// 25. INCIDENT STATUS ENUMS (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 25: Incident Status Enums...');
|
||||
assert.equal(isValidIncidentStatus('open'), true);
|
||||
assert.equal(isValidIncidentStatus('investigating'), true);
|
||||
assert.equal(isValidIncidentStatus('monitoring'), true);
|
||||
assert.equal(isValidIncidentStatus('resolved'), true);
|
||||
assert.equal(isValidIncidentStatus('closed'), true);
|
||||
assert.equal(isValidIncidentStatus('archived'), true);
|
||||
assert.equal(isValidIncidentStatus('pending'), false);
|
||||
assert.equal(isValidIncidentStatus('in_progress'), false);
|
||||
assert.equal(isValidIncidentStatus('deleted'), false);
|
||||
console.log(' PASS: Incident status strictly locked to 6 approved lifecycle states.');
|
||||
|
||||
// ============================================================
|
||||
// 26. STATUS TRANSITION STATE MACHINE & ROLE MATRIX (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 26: Status Transition State Machine & Role Restrictions...');
|
||||
|
||||
// Operator transitions
|
||||
assert.equal(isValidStatusTransition('open', 'investigating', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('open', 'resolved', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('open', 'closed', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('investigating', 'monitoring', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('investigating', 'resolved', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('investigating', 'closed', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('monitoring', 'investigating', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('monitoring', 'resolved', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('monitoring', 'closed', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('resolved', 'investigating', 'operator'), true);
|
||||
assert.equal(isValidStatusTransition('resolved', 'closed', 'operator'), true);
|
||||
|
||||
// Forbidden operator transitions
|
||||
assert.equal(isValidStatusTransition('closed', 'investigating', 'operator'), false, 'Operator cannot reopen closed incident');
|
||||
assert.equal(isValidStatusTransition('open', 'archived', 'operator'), false, 'Operator cannot archive incident');
|
||||
assert.equal(isValidStatusTransition('investigating', 'archived', 'operator'), false, 'Operator cannot archive incident');
|
||||
assert.equal(isValidStatusTransition('closed', 'archived', 'operator'), false, 'Operator cannot archive incident');
|
||||
|
||||
// Owner capabilities
|
||||
assert.equal(isValidStatusTransition('closed', 'investigating', 'owner'), true, 'Owner can reopen closed incident');
|
||||
assert.equal(isValidStatusTransition('open', 'archived', 'owner'), true, 'Owner can archive incident');
|
||||
assert.equal(isValidStatusTransition('investigating', 'archived', 'owner'), true, 'Owner can archive incident');
|
||||
assert.equal(isValidStatusTransition('closed', 'archived', 'owner'), true, 'Owner can archive incident');
|
||||
|
||||
// Terminal archived state
|
||||
assert.equal(isValidStatusTransition('archived', 'open', 'owner'), false, 'Archived incident cannot transition to open');
|
||||
assert.equal(isValidStatusTransition('archived', 'investigating', 'owner'), false, 'Archived incident cannot transition to investigating');
|
||||
assert.equal(isValidStatusTransition('archived', 'closed', 'owner'), false, 'Archived incident cannot transition to closed');
|
||||
console.log(' PASS: State machine guarantees strict role boundaries (Reopen/Archive = Owner Only).');
|
||||
|
||||
// ============================================================
|
||||
// 27. APPEND-ONLY NOTES VALIDATION (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 27: Append-Only Notes Sanitization & Plain Text Contract...');
|
||||
const rawNote = {
|
||||
id: '7b8c9d0e-1f2a-3b4c-5d6e-7f8a9b0c1d2e',
|
||||
incident_id: '3fa85f64-5717-4562-b3fc-2c963f66afa6',
|
||||
created_at: '2026-09-10T08:20:00Z',
|
||||
created_by_operator_id: 'e4d9b1c2-3f4a-5b6c-7d8e-9f0a1b2c3d4e',
|
||||
created_by_identity: 'operator@motorsignal.com',
|
||||
body: 'Scaled up replica count from 2 to 6. Ingress latency normalized to 14ms.',
|
||||
};
|
||||
|
||||
const sanitizedNote = sanitizeIncidentNote(rawNote);
|
||||
assert.ok(sanitizedNote !== null);
|
||||
assert.equal(sanitizedNote.created_by_identity, 'operator@motorsignal.com');
|
||||
assert.equal(sanitizedNote.body, 'Scaled up replica count from 2 to 6. Ingress latency normalized to 14ms.');
|
||||
assert.equal(sanitizedNote.id, undefined, 'Note DTO must not expose internal note UUID');
|
||||
assert.equal(sanitizedNote.incident_id, undefined, 'Note DTO must not expose incident UUID');
|
||||
assert.equal(sanitizedNote.created_by_operator_id, undefined, 'Note DTO must not expose operator UUID');
|
||||
|
||||
// Fail closed on invalid note
|
||||
assert.equal(sanitizeIncidentNote(null), null);
|
||||
assert.equal(sanitizeIncidentNote({ body: 'test' }), null, 'Missing created_by_identity must fail closed');
|
||||
assert.equal(sanitizeIncidentNote({ created_by_identity: 'op', body: '' }), null, 'Empty body must fail closed');
|
||||
console.log(' PASS: Notes DTOs strictly validated and internal UUIDs sanitized.');
|
||||
|
||||
// ============================================================
|
||||
// 28. NO EDIT / NO DELETE FOR NOTES (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 28: No Note Edit or Deletion Endpoints in Codebase...');
|
||||
const incidentNotesRoutePath = path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/notes/route.ts');
|
||||
const incidentNotesRouteSource = fs.readFileSync(incidentNotesRoutePath, 'utf8');
|
||||
|
||||
assert.ok(
|
||||
incidentNotesRouteSource.includes('export async function GET') &&
|
||||
incidentNotesRouteSource.includes('export async function POST'),
|
||||
'Notes route must implement GET and POST'
|
||||
);
|
||||
assert.ok(
|
||||
!incidentNotesRouteSource.includes('export async function PUT') &&
|
||||
!incidentNotesRouteSource.includes('export async function PATCH') &&
|
||||
!incidentNotesRouteSource.includes('export async function DELETE'),
|
||||
'Notes route must strictly FORBID PUT, PATCH, and DELETE (append-only contract)'
|
||||
);
|
||||
console.log(' PASS: Notes are strictly append-only with zero edit or delete endpoints.');
|
||||
|
||||
// ============================================================
|
||||
// 29. NEXT.JS PROXY ROUTES AUTH BOUNDARY (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 29: Next.js API Incidents Proxy Routes Auth Enforcement...');
|
||||
const incidentsRouteSource = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/route.ts'), 'utf8');
|
||||
const incidentDetailRouteSource = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/route.ts'), 'utf8');
|
||||
|
||||
assert.ok(
|
||||
incidentsRouteSource.includes('__Host-motorsignal_ops') &&
|
||||
incidentDetailRouteSource.includes('__Host-motorsignal_ops') &&
|
||||
incidentNotesRouteSource.includes('__Host-motorsignal_ops'),
|
||||
'All incidents API proxy routes must enforce __Host-motorsignal_ops session token'
|
||||
);
|
||||
assert.ok(
|
||||
!incidentsRouteSource.includes('preview=') &&
|
||||
!incidentDetailRouteSource.includes('preview='),
|
||||
'Proxy routes must have zero preview query authentication bypass'
|
||||
);
|
||||
console.log(' PASS: All incident proxy routes require verified Operations host session.');
|
||||
|
||||
// ============================================================
|
||||
// 30. ACCESSIBLE CONFIRMATION MODAL CONTRACT (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 30: Incident Modal Accessibility & Resolution Validation...');
|
||||
const modalSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-incident-modal.tsx'), 'utf8');
|
||||
assert.ok(modalSource.includes('aria-modal="true"'), 'Modal must declare aria-modal="true"');
|
||||
assert.ok(modalSource.includes('role="dialog"'), 'Modal must declare role="dialog"');
|
||||
assert.ok(modalSource.includes("e.key === 'Escape'"), 'Modal must handle Escape key');
|
||||
assert.ok(
|
||||
modalSource.includes('caseReference'),
|
||||
'Modal must render case reference string'
|
||||
);
|
||||
assert.ok(
|
||||
modalSource.includes('resolution.trim()'),
|
||||
'Modal must validate that resolution text is non-empty before close'
|
||||
);
|
||||
console.log(' PASS: Modal provides accessible dialog semantics, Escape key handling, and resolution validation.');
|
||||
|
||||
// ============================================================
|
||||
// 31. DENSE INCIDENT LIST UI CONTRACT (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 31: Dense Incident List Table & Mobile Stacked Cards...');
|
||||
const listSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-incidents-list.tsx'), 'utf8');
|
||||
assert.ok(listSource.includes('hidden md:block'), 'List must render table on desktop');
|
||||
assert.ok(listSource.includes('md:hidden'), 'List must render stacked cards on mobile');
|
||||
assert.ok(listSource.includes('CASE'), 'Table must contain CASE column');
|
||||
assert.ok(listSource.includes('TITLE'), 'Table must contain TITLE column');
|
||||
assert.ok(listSource.includes('SEVERITY'), 'Table must contain SEVERITY column');
|
||||
assert.ok(listSource.includes('STATUS'), 'Table must contain STATUS column');
|
||||
assert.ok(listSource.includes('UPDATED'), 'Table must contain UPDATED column');
|
||||
assert.ok(listSource.includes('CREATED'), 'Table must contain CREATED column');
|
||||
console.log(' PASS: List provides dense desktop table and mobile stacked card layout.');
|
||||
|
||||
// ============================================================
|
||||
// 32. INCIDENT DETAIL UI SECTIONS (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 32: Incident Detail UI Structure & Plain-Text Notes...');
|
||||
const detailSource = fs.readFileSync(path.resolve(__dirname, '../components/ops/operations-incident-detail.tsx'), 'utf8');
|
||||
assert.ok(detailSource.includes('Operational Timeline'), 'Detail must include Operational Timeline');
|
||||
assert.ok(detailSource.includes('Operator Notes'), 'Detail must include Operator Notes');
|
||||
assert.ok(detailSource.includes('Incident Summary'), 'Detail must include Incident Summary');
|
||||
assert.ok(detailSource.includes('Resolution Record') || detailSource.includes('resolution'), 'Detail must support resolution section');
|
||||
assert.ok(detailSource.includes('whitespace-pre-wrap'), 'Notes and summaries must preserve plain text formatting');
|
||||
console.log(' PASS: Incident Detail UI includes all required operational sections.');
|
||||
|
||||
// ============================================================
|
||||
// 33. ZERO POLLING VERIFICATION (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 33: Zero Polling Verification Across Operations UI...');
|
||||
for (const file of currentOpsFiles) {
|
||||
const content = fs.readFileSync(path.join(opsDir, file), 'utf8');
|
||||
assert.ok(!content.includes('setInterval'), `${file} must NOT contain setInterval polling`);
|
||||
assert.ok(!content.includes('setTimeout(') || content.includes('setTimeout(() => setMutationSuccess') || content.includes('setTimeout(() =>'), `${file} must only use setTimeout for UI transitions, not polling`);
|
||||
}
|
||||
console.log(' PASS: Zero automatic background polling across all Operations components.');
|
||||
|
||||
// ============================================================
|
||||
// 34. ZERO RIDER DATA CROSSOVER (OPS-4)
|
||||
// ============================================================
|
||||
console.log('Testing 34: Zero Rider Data Crossover in Operations Incidents...');
|
||||
for (const file of currentOpsFiles) {
|
||||
const content = fs.readFileSync(path.join(opsDir, file), 'utf8');
|
||||
assert.ok(!content.includes('telemetry_points'), `${file} must not reference telemetry`);
|
||||
assert.ok(!content.includes('user_id'), `${file} must not expose consumer user_id`);
|
||||
assert.ok(!content.includes('vehicle_id'), `${file} must not reference vehicle_id`);
|
||||
assert.ok(!/\bvin\b/i.test(content), `${file} must not reference VIN`);
|
||||
assert.ok(!content.includes('/api/v1/rides') && !content.includes('/api/rides'), `${file} must not query consumer rides`);
|
||||
}
|
||||
console.log(' PASS: Operations Incident system strictly isolated from rider and vehicle data.');
|
||||
|
||||
// ============================================================
|
||||
// ============================================================
|
||||
// 36. CSRF & SAME-ORIGIN VALIDATION ON MUTATIONS (OPS-4.2)
|
||||
// ============================================================
|
||||
console.log('Testing 36: CSRF & Same-Origin Enforcement on State-Changing Incident Proxy Routes (OPS-4.2)...');
|
||||
const incidentsRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/route.ts'), 'utf8');
|
||||
const incidentDetailRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/route.ts'), 'utf8');
|
||||
const incidentNotesRouteSrc = fs.readFileSync(path.resolve(__dirname, '../app/api/ops/incidents/[caseRef]/notes/route.ts'), 'utf8');
|
||||
|
||||
assert.ok(
|
||||
incidentsRouteSrc.includes('hasValidOrigin(request)') && incidentsRouteSrc.includes("detail: 'Invalid request origin.'"),
|
||||
'POST /api/ops/incidents must enforce hasValidOrigin and reject with 403 on invalid origin'
|
||||
);
|
||||
assert.ok(
|
||||
incidentDetailRouteSrc.includes('hasValidOrigin(request)') && incidentDetailRouteSrc.includes("detail: 'Invalid request origin.'"),
|
||||
'PATCH /api/ops/incidents/[caseRef] must enforce hasValidOrigin and reject with 403 on invalid origin'
|
||||
);
|
||||
assert.ok(
|
||||
incidentNotesRouteSrc.includes('hasValidOrigin(request)') && incidentNotesRouteSrc.includes("detail: 'Invalid request origin.'"),
|
||||
'POST /api/ops/incidents/[caseRef]/notes must enforce hasValidOrigin and reject with 403 on invalid origin'
|
||||
);
|
||||
console.log(' PASS: All 3 state-changing incident proxy routes enforce same-origin CSRF protection.');
|
||||
|
||||
// ============================================================
|
||||
// 37. SAME-ORIGIN HELPER BEHAVIOR FOR OPS MUTATIONS (OPS-4.2)
|
||||
// ============================================================
|
||||
console.log('Testing 37: hasValidOrigin Security Matrix (OPS-4.2)...');
|
||||
const requestOriginModule = loadTsModule('../lib/request-origin.ts');
|
||||
const { hasValidOrigin } = requestOriginModule;
|
||||
|
||||
// Mock NextRequest objects
|
||||
function createMockRequest(headers) {
|
||||
return {
|
||||
headers: {
|
||||
get: (k) => headers[k.toLowerCase()] || null,
|
||||
},
|
||||
nextUrl: {
|
||||
protocol: 'https:',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Same origin
|
||||
assert.equal(
|
||||
hasValidOrigin(createMockRequest({
|
||||
origin: 'https://motorsignal.app',
|
||||
host: 'motorsignal.app',
|
||||
'x-forwarded-proto': 'https',
|
||||
})),
|
||||
true,
|
||||
'Valid same-origin request must pass'
|
||||
);
|
||||
|
||||
// Cross origin attacker
|
||||
assert.equal(
|
||||
hasValidOrigin(createMockRequest({
|
||||
origin: 'https://evil-attacker.com',
|
||||
host: 'motorsignal.app',
|
||||
'x-forwarded-proto': 'https',
|
||||
})),
|
||||
false,
|
||||
'Cross-origin attacker origin must be rejected'
|
||||
);
|
||||
|
||||
// Missing origin header
|
||||
assert.equal(
|
||||
hasValidOrigin(createMockRequest({
|
||||
host: 'motorsignal.app',
|
||||
'x-forwarded-proto': 'https',
|
||||
})),
|
||||
false,
|
||||
'Missing origin header must be rejected on mutations'
|
||||
);
|
||||
|
||||
// Protocol mismatch (http origin to https host)
|
||||
assert.equal(
|
||||
hasValidOrigin(createMockRequest({
|
||||
origin: 'http://motorsignal.app',
|
||||
host: 'motorsignal.app',
|
||||
'x-forwarded-proto': 'https',
|
||||
})),
|
||||
false,
|
||||
'Protocol downgrade/mismatch must be rejected'
|
||||
);
|
||||
|
||||
console.log(' PASS: hasValidOrigin rejects cross-origin, missing-origin, and protocol-mismatched requests.');
|
||||
|
||||
// ============================================================
|
||||
// 38. GET PROXY ROUTES UNAFFECTED (OPS-4.2)
|
||||
// ============================================================
|
||||
console.log('Testing 38: GET Proxy Routes Unaffected by Origin Barrier (OPS-4.2)...');
|
||||
// Ensure GET handlers do not block on origin
|
||||
const getIncidentMatch = incidentsRouteSrc.match(/export async function GET[\s\S]*?export async function POST/);
|
||||
assert.ok(getIncidentMatch && !getIncidentMatch[0].includes('hasValidOrigin'), 'GET incidents must not require origin');
|
||||
|
||||
const getDetailMatch = incidentDetailRouteSrc.match(/export async function GET[\s\S]*?export async function PATCH/);
|
||||
assert.ok(getDetailMatch && !getDetailMatch[0].includes('hasValidOrigin'), 'GET incident detail must not require origin');
|
||||
|
||||
const getNotesMatch = incidentNotesRouteSrc.match(/export async function GET[\s\S]*?export async function POST/);
|
||||
assert.ok(getNotesMatch && !getNotesMatch[0].includes('hasValidOrigin'), 'GET notes must not require origin');
|
||||
|
||||
console.log(' PASS: Read-only GET operations are unaffected while mutations require valid origin.');
|
||||
|
||||
console.log('--- ALL OPS-3C, OPS-3C.1, OPS-3C.2, OPS-3C.3, OPS-4 & OPS-4.2 TESTS PASSED (38/38) ---');
|
||||
|
|
|
|||
Loading…
Reference in New Issue