feat(ops): add disabled action foundation and support report creation (#15)
* feat(ops): add truthful disabled action plane and support modal * fix(ops): align support report types with backend contract * fix(ops): address action audit and support validation review * test(website): add behavioral coverage for support reports --------- Co-authored-by: Developer <dev@example.com>
This commit is contained in:
parent
7efd29a214
commit
0344691194
|
|
@ -42,4 +42,4 @@ jobs:
|
||||||
|
|
||||||
- name: Run isolated API and Operations unit tests
|
- name: Run isolated API and Operations unit tests
|
||||||
run: |
|
run: |
|
||||||
pytest tests/test_ops_support.py tests/test_ops_incidents.py tests/test_legacy_admin_retirement.py -v
|
pytest tests/test_ops_support.py tests/test_ops_incidents.py tests/test_legacy_admin_retirement.py tests/test_ops_actions.py -v
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,9 @@ jobs:
|
||||||
MOTORSIGNAL_GIT_SHA: ${{ github.sha }}
|
MOTORSIGNAL_GIT_SHA: ${{ github.sha }}
|
||||||
run: npm run build
|
run: npm run build
|
||||||
|
|
||||||
|
- name: Run React component tests
|
||||||
|
run: npm run test:components
|
||||||
|
|
||||||
- name: Run Founder Portal tests
|
- name: Run Founder Portal tests
|
||||||
run: node tests/founder-portal.test.cjs
|
run: node tests/founder-portal.test.cjs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
"""add ops actions
|
||||||
|
|
||||||
|
Revision ID: f1a2b3c4d5e7
|
||||||
|
Revises: f1a2b3c4d5e6
|
||||||
|
Create Date: 2026-09-20
|
||||||
|
"""
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = 'f1a2b3c4d5e7'
|
||||||
|
down_revision = 'f1a2b3c4d5e6'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'ops_actions',
|
||||||
|
sa.Column('id', UUID(as_uuid=True), primary_key=True),
|
||||||
|
sa.Column('action_type', sa.String(255), nullable=False),
|
||||||
|
sa.Column('requested_by_id', UUID(as_uuid=True), sa.ForeignKey('ops.operators.id', ondelete='RESTRICT'), nullable=False),
|
||||||
|
sa.Column('request_timestamp', sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||||
|
sa.Column('start_timestamp', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('completion_timestamp', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('state', sa.String(50), server_default='queued', nullable=False),
|
||||||
|
sa.Column('status_message', sa.Text(), nullable=True),
|
||||||
|
sa.Column('deployed_revision', sa.String(255), nullable=True),
|
||||||
|
sa.Column('target_revision', sa.String(255), nullable=True),
|
||||||
|
sa.Column('preflight_result', sa.Text(), nullable=True),
|
||||||
|
sa.Column('health_result', sa.Text(), nullable=True),
|
||||||
|
sa.Column('rollback_status', sa.Text(), nullable=True),
|
||||||
|
sa.Column('failure_code', sa.String(255), nullable=True),
|
||||||
|
sa.Column('idempotency_key', sa.String(255), nullable=True),
|
||||||
|
schema='ops'
|
||||||
|
)
|
||||||
|
# Ensure idempotency_key is unique to prevent duplicate processing
|
||||||
|
op.create_index(
|
||||||
|
'ix_ops_actions_idempotency_key',
|
||||||
|
'ops_actions',
|
||||||
|
['requested_by_id', 'action_type', 'idempotency_key'],
|
||||||
|
unique=True,
|
||||||
|
schema='ops',
|
||||||
|
postgresql_where=sa.text('idempotency_key IS NOT NULL')
|
||||||
|
)
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_ops_actions_idempotency_key', table_name='ops_actions', schema='ops')
|
||||||
|
op.drop_table('ops_actions', schema='ops')
|
||||||
|
|
@ -10,7 +10,7 @@ from typing import Literal
|
||||||
|
|
||||||
from email_validator import EmailNotValidError, validate_email
|
from email_validator import EmailNotValidError, validate_email
|
||||||
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
from fastapi import Depends, FastAPI, Header, HTTPException, Query, Request, status
|
||||||
from pydantic import BaseModel, ConfigDict, Field
|
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
||||||
from sqlalchemy import func, or_, select, text
|
from sqlalchemy import func, or_, select, text
|
||||||
from sqlalchemy.orm import Session as DbSession
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
|
||||||
|
|
@ -29,6 +29,7 @@ from auth import (
|
||||||
)
|
)
|
||||||
from db import check_database, engine
|
from db import check_database, engine
|
||||||
from models import (
|
from models import (
|
||||||
|
OpsAction,
|
||||||
AuditEvent,
|
AuditEvent,
|
||||||
AuthRateLimit,
|
AuthRateLimit,
|
||||||
EmailVerificationToken,
|
EmailVerificationToken,
|
||||||
|
|
@ -1078,6 +1079,30 @@ class OpsSupportDiagnosticBundle(BaseModel):
|
||||||
submitted_at: str
|
submitted_at: str
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class OpsActionCreate(BaseModel):
|
||||||
|
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
|
||||||
|
action_type: Literal["deploy_website", "restart_website", "restart_api", "reboot_server"]
|
||||||
|
idempotency_key: str | None = Field(default=None, min_length=8, max_length=128)
|
||||||
|
confirmation_text: str | None = Field(default=None, max_length=64)
|
||||||
|
|
||||||
|
class OpsActionRead(BaseModel):
|
||||||
|
id: uuid.UUID
|
||||||
|
action_type: str
|
||||||
|
requested_by_id: uuid.UUID
|
||||||
|
request_timestamp: datetime
|
||||||
|
start_timestamp: datetime | None
|
||||||
|
completion_timestamp: datetime | None
|
||||||
|
state: str
|
||||||
|
status_message: str | None
|
||||||
|
deployed_revision: str | None
|
||||||
|
target_revision: str | None
|
||||||
|
preflight_result: str | None
|
||||||
|
health_result: str | None
|
||||||
|
rollback_status: str | None
|
||||||
|
failure_code: str | None
|
||||||
|
idempotency_key: str | None
|
||||||
|
|
||||||
class OpsSupportTicketCreate(BaseModel):
|
class OpsSupportTicketCreate(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
ticket_type: str = Field(default="general")
|
ticket_type: str = Field(default="general")
|
||||||
|
|
@ -1087,6 +1112,22 @@ class OpsSupportTicketCreate(BaseModel):
|
||||||
severity: str = Field(default="medium")
|
severity: str = Field(default="medium")
|
||||||
diagnostic_bundle: OpsSupportDiagnosticBundle | dict | None = None
|
diagnostic_bundle: OpsSupportDiagnosticBundle | dict | None = None
|
||||||
|
|
||||||
|
@field_validator("reporter_email")
|
||||||
|
@classmethod
|
||||||
|
def validate_reporter_email(cls, v: str) -> str:
|
||||||
|
s = v.strip()
|
||||||
|
if len(s) < 3:
|
||||||
|
raise ValueError("Reporter email must not be empty or whitespace only.")
|
||||||
|
return s
|
||||||
|
|
||||||
|
@field_validator("subject", "description")
|
||||||
|
@classmethod
|
||||||
|
def validate_non_whitespace(cls, v: str) -> str:
|
||||||
|
s = v.strip()
|
||||||
|
if not s:
|
||||||
|
raise ValueError("Field cannot be empty or contain only whitespace.")
|
||||||
|
return s
|
||||||
|
|
||||||
|
|
||||||
class OpsSupportTicketPatch(BaseModel):
|
class OpsSupportTicketPatch(BaseModel):
|
||||||
model_config = ConfigDict(extra="forbid")
|
model_config = ConfigDict(extra="forbid")
|
||||||
|
|
@ -2425,6 +2466,54 @@ def ops_get_support_ticket(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@app.post("/api/v1/ops/actions", response_model=OpsActionRead)
|
||||||
|
def ops_create_action(
|
||||||
|
req: OpsActionCreate,
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("owner")),
|
||||||
|
):
|
||||||
|
operator, session = auth
|
||||||
|
|
||||||
|
if req.action_type in ("reboot_server", "restart_api"):
|
||||||
|
expected = "REBOOT MOTORSIGNAL SERVER" if req.action_type == "reboot_server" else "RESTART API"
|
||||||
|
if req.confirmation_text != expected:
|
||||||
|
raise HTTPException(status_code=422, detail="Invalid confirmation text.")
|
||||||
|
|
||||||
|
# We explicitly implement a disabled control plane per the instructions
|
||||||
|
# Check control-plane availability before performing any idempotency lookup.
|
||||||
|
audit = OpsAuditEvent(
|
||||||
|
action="action_rejected",
|
||||||
|
actor_identity=operator.identity,
|
||||||
|
actor_role=operator.role,
|
||||||
|
target_resource="unavailable_control_plane",
|
||||||
|
result="denied",
|
||||||
|
metadata_json={"action_type": req.action_type, "status": "rejected_unavailable"},
|
||||||
|
)
|
||||||
|
db.add(audit)
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=503, detail="Action control plane is currently unconfigured. Missing ops-action-helper.sh.")
|
||||||
|
|
||||||
|
@app.get("/api/v1/ops/actions", response_model=list[OpsActionRead])
|
||||||
|
def ops_list_actions(
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||||
|
limit: int = Query(default=50, ge=1, le=100),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
):
|
||||||
|
return db.scalars(select(OpsAction).order_by(OpsAction.request_timestamp.desc(), OpsAction.id.desc()).offset(offset).limit(limit)).all()
|
||||||
|
|
||||||
|
@app.get("/api/v1/ops/actions/{action_id}", response_model=OpsActionRead)
|
||||||
|
def ops_get_action(
|
||||||
|
action_id: uuid.UUID,
|
||||||
|
db: DbSession = Depends(get_db),
|
||||||
|
auth: tuple[Operator, OpsSession] = Depends(require_ops_roles("operator", "owner")),
|
||||||
|
):
|
||||||
|
action = db.get(OpsAction, action_id)
|
||||||
|
if not action:
|
||||||
|
raise HTTPException(status_code=404, detail="Action not found")
|
||||||
|
return action
|
||||||
|
|
||||||
@app.post("/api/v1/ops/support/tickets", status_code=201)
|
@app.post("/api/v1/ops/support/tickets", status_code=201)
|
||||||
def ops_create_support_ticket(
|
def ops_create_support_ticket(
|
||||||
request: OpsSupportTicketCreate,
|
request: OpsSupportTicketCreate,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import uuid
|
import uuid
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String
|
from sqlalchemy import JSON, Index, Boolean, DateTime, ForeignKey, String, func, Text, text
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship
|
||||||
|
|
||||||
|
|
@ -378,3 +378,31 @@ class OpsIncidentNote(Base):
|
||||||
body: Mapped[str] = mapped_column(String, nullable=False)
|
body: Mapped[str] = mapped_column(String, nullable=False)
|
||||||
|
|
||||||
incident: Mapped["OpsIncident"] = relationship(back_populates="notes")
|
incident: Mapped["OpsIncident"] = relationship(back_populates="notes")
|
||||||
|
|
||||||
|
class OpsAction(Base):
|
||||||
|
__tablename__ = "ops_actions"
|
||||||
|
__table_args__ = (
|
||||||
|
Index(
|
||||||
|
"ix_ops_actions_idempotency_key",
|
||||||
|
"requested_by_id", "action_type", "idempotency_key",
|
||||||
|
unique=True,
|
||||||
|
postgresql_where=text("idempotency_key IS NOT NULL"),
|
||||||
|
),
|
||||||
|
{"schema": "ops"},
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
|
action_type: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||||
|
requested_by_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("ops.operators.id", ondelete="RESTRICT"), nullable=False)
|
||||||
|
request_timestamp: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
||||||
|
start_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
completion_timestamp: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
|
state: Mapped[str] = mapped_column(String(50), server_default="queued", nullable=False)
|
||||||
|
status_message: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
deployed_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
target_revision: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
preflight_result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
health_result: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
rollback_status: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
failure_code: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
idempotency_key: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,296 @@
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
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
|
||||||
|
from sqlalchemy.orm import Session as DbSession
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
import main
|
||||||
|
from main import app, get_db
|
||||||
|
from models import Base, Operator, OpsAuditEvent, OpsSession, OpsAction
|
||||||
|
|
||||||
|
def hash_token(raw_token: str) -> str:
|
||||||
|
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||||
|
|
||||||
|
class TestOpsActions(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls):
|
||||||
|
cls.db_path = os.path.join(ROOT, "tests", "test_ops_actions.db")
|
||||||
|
cls.ops_db_path = os.path.join(ROOT, "tests", "test_ops_actions_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}")
|
||||||
|
|
||||||
|
@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")
|
||||||
|
|
||||||
|
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):
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
db.query(OpsAuditEvent).delete()
|
||||||
|
db.query(OpsAction).delete()
|
||||||
|
db.query(OpsSession).delete()
|
||||||
|
db.query(Operator).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.op_id = uuid.uuid4()
|
||||||
|
self.op_identity = "owner@motorsignal.internal"
|
||||||
|
self.op_token = "valid-owner-token-123"
|
||||||
|
op = Operator(
|
||||||
|
id=self.op_id,
|
||||||
|
identity=self.op_identity,
|
||||||
|
role="owner",
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.expired_owner_token = "expired-owner-token-123"
|
||||||
|
op_session_expired = OpsSession(
|
||||||
|
operator_id=self.op_id,
|
||||||
|
session_token_hash=hash_token(self.expired_owner_token),
|
||||||
|
expires_at=datetime.now(timezone.utc) - timedelta(hours=8),
|
||||||
|
created_at=datetime.now(timezone.utc) - timedelta(hours=16),
|
||||||
|
)
|
||||||
|
|
||||||
|
self.operator_id = uuid.uuid4()
|
||||||
|
self.operator_identity = "operator@motorsignal.internal"
|
||||||
|
self.operator_token = "valid-operator-token-123"
|
||||||
|
op_reg = Operator(
|
||||||
|
id=self.operator_id,
|
||||||
|
identity=self.operator_identity,
|
||||||
|
role="operator",
|
||||||
|
status="active",
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
op_session_reg = OpsSession(
|
||||||
|
operator_id=self.operator_id,
|
||||||
|
session_token_hash=hash_token(self.operator_token),
|
||||||
|
expires_at=datetime.now(timezone.utc) + timedelta(hours=8),
|
||||||
|
created_at=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
|
||||||
|
db.add(op)
|
||||||
|
db.add(op_session)
|
||||||
|
db.add(op_session_expired)
|
||||||
|
db.add(op_reg)
|
||||||
|
db.add(op_session_reg)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
self.owner_headers = {"Authorization": f"Bearer {self.op_token}"}
|
||||||
|
self.expired_owner_headers = {"Authorization": f"Bearer {self.expired_owner_token}"}
|
||||||
|
self.operator_headers = {"Authorization": f"Bearer {self.operator_token}"}
|
||||||
|
|
||||||
|
def test_unauthenticated_rejection(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"})
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_expired_session_rejection(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.expired_owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 401)
|
||||||
|
|
||||||
|
def test_operator_rejection(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.operator_headers)
|
||||||
|
self.assertEqual(resp.status_code, 403)
|
||||||
|
|
||||||
|
def test_owner_access(self):
|
||||||
|
resp = self.client.get("/api/v1/ops/actions", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
|
||||||
|
def test_unknown_action_rejection(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "invalid_type"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_unexpected_field_rejection(self):
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/actions",
|
||||||
|
json={"action_type": "deploy_website", "extra_field": "test"},
|
||||||
|
headers=self.owner_headers
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_idempotency_key_bounds(self):
|
||||||
|
# Too short
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": "1234567"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
# Too long
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": "a" * 129}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_confirmation_text_bounds(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api", "confirmation_text": "a" * 65}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_exact_api_restart_confirmation(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "restart_api", "confirmation_text": "RESTART API"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 503)
|
||||||
|
|
||||||
|
def test_exact_reboot_confirmation(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "reboot_server"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "reboot_server", "confirmation_text": "REBOOT MOTORSIGNAL SERVER"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 503)
|
||||||
|
|
||||||
|
def test_unavailable_control_plane_all_actions(self):
|
||||||
|
actions_with_confirm = [
|
||||||
|
("deploy_website", None),
|
||||||
|
("restart_website", None),
|
||||||
|
("restart_api", "RESTART API"),
|
||||||
|
("reboot_server", "REBOOT MOTORSIGNAL SERVER"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for act, conf in actions_with_confirm:
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
db.query(OpsAuditEvent).delete()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
payload = {"action_type": act}
|
||||||
|
if conf:
|
||||||
|
payload["confirmation_text"] = conf
|
||||||
|
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json=payload, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 503)
|
||||||
|
self.assertIn("Missing ops-action-helper.sh", resp.json()["detail"])
|
||||||
|
|
||||||
|
# Verify no OpsAction row is created
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
count = db.scalar(select(main.func.count(OpsAction.id)))
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
|
||||||
|
# Verify audit event is created with result="denied" (never successful)
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
audit = db.query(OpsAuditEvent).filter_by(action="action_rejected").first()
|
||||||
|
self.assertIsNotNone(audit)
|
||||||
|
self.assertEqual(audit.actor_role, "owner")
|
||||||
|
self.assertEqual(audit.actor_identity, self.op_identity)
|
||||||
|
self.assertEqual(audit.result, "denied")
|
||||||
|
self.assertNotEqual(audit.result, "success")
|
||||||
|
self.assertEqual(audit.metadata_json.get("action_type"), act)
|
||||||
|
# Ensure no sensitive confirmation text/idempotency keys in audit
|
||||||
|
self.assertNotIn("idempotency_key", audit.metadata_json)
|
||||||
|
self.assertNotIn("confirmation_text", audit.metadata_json)
|
||||||
|
|
||||||
|
def test_idempotent_no_action_created(self):
|
||||||
|
"""
|
||||||
|
Documenting that while the control plane is disabled, unavailable requests
|
||||||
|
create audit events but DO NOT create an idempotent action result.
|
||||||
|
Full idempotent action replay belongs to the configured executor milestone.
|
||||||
|
"""
|
||||||
|
key = "idemp-key-test-1"
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": key}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 503)
|
||||||
|
|
||||||
|
# Another request with the same key
|
||||||
|
resp2 = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website", "idempotency_key": key}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp2.status_code, 503)
|
||||||
|
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
count = db.scalar(select(main.func.count(OpsAction.id)))
|
||||||
|
self.assertEqual(count, 0)
|
||||||
|
audits = db.query(OpsAuditEvent).filter_by(action="action_rejected").all()
|
||||||
|
self.assertGreaterEqual(len(audits), 2)
|
||||||
|
for a in audits:
|
||||||
|
self.assertEqual(a.result, "denied")
|
||||||
|
self.assertNotEqual(a.result, "success")
|
||||||
|
|
||||||
|
def test_action_detail_404(self):
|
||||||
|
resp = self.client.get(f"/api/v1/ops/actions/{uuid.uuid4()}", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
def test_sanitized_response(self):
|
||||||
|
resp = self.client.post("/api/v1/ops/actions", json={"action_type": "deploy_website"}, headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 503)
|
||||||
|
self.assertNotIn("var", resp.json()["detail"])
|
||||||
|
self.assertNotIn("opt", resp.json()["detail"])
|
||||||
|
|
||||||
|
def test_pagination_bounds(self):
|
||||||
|
resp = self.client.get("/api/v1/ops/actions?limit=0", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
resp = self.client.get("/api/v1/ops/actions?limit=101", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
resp = self.client.get("/api/v1/ops/actions?offset=-1", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_deterministic_action_listing_ordering(self):
|
||||||
|
with DbSession(self.engine) as db:
|
||||||
|
t1 = datetime.now(timezone.utc)
|
||||||
|
t2 = t1 + timedelta(seconds=1)
|
||||||
|
|
||||||
|
id1 = uuid.uuid4()
|
||||||
|
id2 = uuid.uuid4()
|
||||||
|
id3 = uuid.uuid4()
|
||||||
|
|
||||||
|
# Action 1 and 2 have identical timestamp. Ordering must resolve by id DESC
|
||||||
|
db.add(OpsAction(id=id1, action_type="deploy_website", requested_by_id=self.op_id, state="queued", request_timestamp=t1))
|
||||||
|
db.add(OpsAction(id=id2, action_type="restart_website", requested_by_id=self.op_id, state="queued", request_timestamp=t1))
|
||||||
|
|
||||||
|
# Action 3 is later
|
||||||
|
db.add(OpsAction(id=id3, action_type="restart_api", requested_by_id=self.op_id, state="queued", request_timestamp=t2))
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
resp = self.client.get("/api/v1/ops/actions?limit=5", headers=self.owner_headers)
|
||||||
|
self.assertEqual(resp.status_code, 200)
|
||||||
|
data = resp.json()
|
||||||
|
|
||||||
|
# Most recent first
|
||||||
|
self.assertEqual(data[0]["id"], str(id3))
|
||||||
|
|
||||||
|
# Then stable secondary sort by id DESC
|
||||||
|
if str(id1) > str(id2):
|
||||||
|
self.assertEqual(data[1]["id"], str(id1))
|
||||||
|
self.assertEqual(data[2]["id"], str(id2))
|
||||||
|
else:
|
||||||
|
self.assertEqual(data[1]["id"], str(id2))
|
||||||
|
self.assertEqual(data[2]["id"], str(id1))
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
unittest.main()
|
||||||
|
|
@ -271,6 +271,70 @@ class TestOpsSupport(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, 404)
|
self.assertEqual(resp.status_code, 404)
|
||||||
|
|
||||||
|
def test_create_ticket_rejects_whitespace_only_subject(self):
|
||||||
|
payload = {
|
||||||
|
"ticket_type": "bug_report",
|
||||||
|
"reporter_email": "testrider@example.com",
|
||||||
|
"subject": " ",
|
||||||
|
"description": "Valid description for the ticket.",
|
||||||
|
"severity": "medium",
|
||||||
|
}
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/support/tickets",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_create_ticket_rejects_whitespace_only_description(self):
|
||||||
|
payload = {
|
||||||
|
"ticket_type": "bug_report",
|
||||||
|
"reporter_email": "testrider@example.com",
|
||||||
|
"subject": "Valid subject",
|
||||||
|
"description": " \t \n ",
|
||||||
|
"severity": "medium",
|
||||||
|
}
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/support/tickets",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_create_ticket_rejects_whitespace_only_reporter_email(self):
|
||||||
|
payload = {
|
||||||
|
"ticket_type": "bug_report",
|
||||||
|
"reporter_email": " ",
|
||||||
|
"subject": "Valid subject",
|
||||||
|
"description": "Valid description",
|
||||||
|
"severity": "medium",
|
||||||
|
}
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/support/tickets",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 422)
|
||||||
|
|
||||||
|
def test_create_ticket_trims_surrounding_whitespace(self):
|
||||||
|
payload = {
|
||||||
|
"ticket_type": "bug_report",
|
||||||
|
"reporter_email": " testrider@example.com ",
|
||||||
|
"subject": " Bluetooth disconnects after 10 mins ",
|
||||||
|
"description": " App drops connection to OBD reader after 10 minutes. ",
|
||||||
|
"severity": "medium",
|
||||||
|
}
|
||||||
|
resp = self.client.post(
|
||||||
|
"/api/v1/ops/support/tickets",
|
||||||
|
json=payload,
|
||||||
|
headers={"Authorization": f"Bearer {self.op_token}"},
|
||||||
|
)
|
||||||
|
self.assertEqual(resp.status_code, 201)
|
||||||
|
data = resp.json()
|
||||||
|
self.assertEqual(data["reporter_email"], "testrider@example.com")
|
||||||
|
self.assertEqual(data["subject"], "Bluetooth disconnects after 10 mins")
|
||||||
|
self.assertEqual(data["description"], "App drops connection to OBD reader after 10 minutes.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
SupportTicketStatus,
|
SupportTicketStatus,
|
||||||
SupportDiagnosticBundle,
|
SupportDiagnosticBundle,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
import { SupportReportModal } from './support-report-modal';
|
||||||
|
|
||||||
interface OperationsSupportProps {
|
interface OperationsSupportProps {
|
||||||
role: OperationsRole;
|
role: OperationsRole;
|
||||||
|
|
@ -22,6 +23,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
||||||
const [totalCount, setTotalCount] = useState<number | null>(null);
|
const [totalCount, setTotalCount] = useState<number | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
const [errorMessage, setErrorMessage] = useState<string | null>(null);
|
||||||
|
|
||||||
// Tab & Search filters
|
// Tab & Search filters
|
||||||
|
|
@ -800,6 +802,14 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
||||||
// Otherwise, render Ticket Inbox (List view)
|
// Otherwise, render Ticket Inbox (List view)
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<SupportReportModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onSuccess={(ticket: SupportTicketSummary) => {
|
||||||
|
fetchTickets(true);
|
||||||
|
handleSelectTicket(ticket.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
{/* Top Title Bar */}
|
{/* Top Title Bar */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
|
||||||
<div>
|
<div>
|
||||||
|
|
@ -815,9 +825,18 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Manual Refresh Only */}
|
{/* Manual Refresh Only */}
|
||||||
<button
|
<div className="flex items-center gap-2">
|
||||||
type="button"
|
<button
|
||||||
onClick={() => fetchTickets(true)}
|
type="button"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-primary/50 bg-primary/10 hover:bg-primary/20 text-xs sm:text-sm font-semibold text-primary transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[18px]">add</span>
|
||||||
|
New Support Report
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => fetchTickets(true)}
|
||||||
disabled={isRefreshing || isLoading}
|
disabled={isRefreshing || isLoading}
|
||||||
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high text-xs sm:text-sm font-semibold text-on-surface transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none self-start sm:self-auto shrink-0 shadow-sm"
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high text-xs sm:text-sm font-semibold text-on-surface transition-colors disabled:opacity-50 focus-visible:ring-2 focus-visible:ring-primary-container focus-visible:outline-none self-start sm:self-auto shrink-0 shadow-sm"
|
||||||
>
|
>
|
||||||
|
|
@ -830,6 +849,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
||||||
</span>
|
</span>
|
||||||
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
|
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
|
||||||
</button>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Tabs & Search Bar */}
|
{/* Filter Tabs & Search Bar */}
|
||||||
|
|
@ -980,7 +1000,7 @@ export function OperationsSupport({ role: _role }: OperationsSupportProps) {
|
||||||
Bundle
|
Bundle
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-outline font-mono text-xs">—</span>
|
<span className="text-outline font-mono text-xs">—</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-5 sm:px-6 font-mono text-outline">{t.notes_count ?? 0}</td>
|
<td className="py-4 px-5 sm:px-6 font-mono text-outline">{t.notes_count ?? 0}</td>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,220 @@
|
||||||
|
'use client';
|
||||||
|
|
||||||
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
|
import { SupportTicketSummary } from './types';
|
||||||
|
|
||||||
|
interface SupportReportModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onSuccess: (ticket: SupportTicketSummary) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SupportReportModal({ isOpen, onClose, onSuccess }: SupportReportModalProps) {
|
||||||
|
const [ticketType, setTicketType] = useState('general');
|
||||||
|
const [reporterEmail, setReporterEmail] = useState('');
|
||||||
|
const [subject, setSubject] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const [severity, setSeverity] = useState('medium');
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const modalRef = useRef<HTMLDivElement>(null);
|
||||||
|
const firstInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setTicketType('general');
|
||||||
|
setReporterEmail('');
|
||||||
|
setSubject('');
|
||||||
|
setDescription('');
|
||||||
|
setSeverity('medium');
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Focus first input after render
|
||||||
|
setTimeout(() => firstInputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
// Keyboard accessibility
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (isSubmitting) return;
|
||||||
|
|
||||||
|
const trimmedEmail = reporterEmail.trim();
|
||||||
|
const trimmedSubject = subject.trim();
|
||||||
|
const trimmedDescription = description.trim();
|
||||||
|
|
||||||
|
if (!trimmedSubject) {
|
||||||
|
setError('Subject cannot be empty or contain only whitespace.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!trimmedDescription) {
|
||||||
|
setError('Description cannot be empty or contain only whitespace.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/ops/support/tickets', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
ticket_type: ticketType,
|
||||||
|
reporter_email: trimmedEmail,
|
||||||
|
subject: trimmedSubject,
|
||||||
|
description: trimmedDescription,
|
||||||
|
severity,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await resp.json().catch(() => null);
|
||||||
|
if (resp.ok) {
|
||||||
|
onSuccess(data);
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
setError(data?.detail || 'Failed to submit report');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Network error during submission');
|
||||||
|
} finally {
|
||||||
|
setIsSubmitting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-in fade-in duration-200" role="dialog" aria-modal="true" aria-labelledby="modal-title">
|
||||||
|
<div
|
||||||
|
ref={modalRef}
|
||||||
|
className="bg-surface-container-high border border-outline-variant/30 w-full max-w-lg rounded-2xl shadow-xl overflow-hidden flex flex-col"
|
||||||
|
>
|
||||||
|
<div className="px-6 py-4 border-b border-outline-variant/20 flex items-center justify-between">
|
||||||
|
<h2 id="modal-title" className="text-lg font-bold text-on-surface">New Support Report</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-1 rounded-full text-outline hover:bg-surface-container-highest hover:text-on-surface transition-colors"
|
||||||
|
aria-label="Close"
|
||||||
|
>
|
||||||
|
<span className="material-symbols-outlined text-[20px]">close</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="flex-1 overflow-y-auto p-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm bg-error/10 text-error border border-error/20 rounded-lg">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="reporter_email" className="block text-sm font-medium text-on-surface">Reporter Email</label>
|
||||||
|
<input
|
||||||
|
id="reporter_email"
|
||||||
|
ref={firstInputRef}
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
minLength={3}
|
||||||
|
maxLength={255}
|
||||||
|
value={reporterEmail}
|
||||||
|
onChange={e => setReporterEmail(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="ticket_type" className="block text-sm font-medium text-on-surface">Ticket Type</label>
|
||||||
|
<select
|
||||||
|
id="ticket_type"
|
||||||
|
value={ticketType}
|
||||||
|
onChange={e => setTicketType(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="general">General</option>
|
||||||
|
<option value="bug_report">Bug Report</option>
|
||||||
|
<option value="telemetry_issue">Telemetry Issue</option>
|
||||||
|
<option value="contact_request">Contact Request</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="severity" className="block text-sm font-medium text-on-surface">Severity</label>
|
||||||
|
<select
|
||||||
|
id="severity"
|
||||||
|
value={severity}
|
||||||
|
onChange={e => setSeverity(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
>
|
||||||
|
<option value="low">Low</option>
|
||||||
|
<option value="medium">Medium</option>
|
||||||
|
<option value="high">High</option>
|
||||||
|
<option value="critical">Critical</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="subject" className="block text-sm font-medium text-on-surface">Subject</label>
|
||||||
|
<input
|
||||||
|
id="subject"
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={255}
|
||||||
|
value={subject}
|
||||||
|
onChange={e => setSubject(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||||
|
placeholder="Brief summary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label htmlFor="description" className="block text-sm font-medium text-on-surface">Description</label>
|
||||||
|
<textarea
|
||||||
|
id="description"
|
||||||
|
required
|
||||||
|
minLength={1}
|
||||||
|
maxLength={10000}
|
||||||
|
value={description}
|
||||||
|
onChange={e => setDescription(e.target.value)}
|
||||||
|
className="w-full px-3 py-2 bg-surface-container-lowest border border-outline-variant/50 rounded-lg text-on-surface focus:outline-none focus:ring-2 focus:ring-primary/50 min-h-[120px]"
|
||||||
|
placeholder="Detailed description..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-4 flex items-center justify-end gap-3 border-t border-outline-variant/20">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-4 py-2 text-sm font-semibold text-on-surface-variant hover:text-on-surface transition-colors"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
className="px-5 py-2 text-sm font-semibold bg-primary text-on-primary rounded-xl hover:bg-primary/90 transition-colors shadow-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{isSubmitting ? 'Submitting...' : 'Create Ticket'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -6,7 +6,8 @@
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint"
|
"lint": "next lint",
|
||||||
|
"test:components": "vitest run"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"firebase": "12.18.0",
|
"firebase": "12.18.0",
|
||||||
|
|
@ -15,9 +16,13 @@
|
||||||
"react-dom": "19.1.1"
|
"react-dom": "19.1.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "16.3.3",
|
||||||
|
"@testing-library/user-event": "14.6.7",
|
||||||
"@types/node": "^24.0.0",
|
"@types/node": "^24.0.0",
|
||||||
"@types/react": "^19.1.0",
|
"@types/react": "^19.1.0",
|
||||||
"@types/react-dom": "^19.1.0",
|
"@types/react-dom": "^19.1.0",
|
||||||
"typescript": "^5.9.0"
|
"jsdom": "29.1.1",
|
||||||
|
"typescript": "^5.9.0",
|
||||||
|
"vitest": "5.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1082,6 +1082,32 @@ assert.ok(backendMainSrc.includes('support.ticket_updated'), 'Backend must audit
|
||||||
assert.ok(backendMainSrc.includes('support.diagnostics_viewed'), 'Backend must audit support.diagnostics_viewed');
|
assert.ok(backendMainSrc.includes('support.diagnostics_viewed'), 'Backend must audit support.diagnostics_viewed');
|
||||||
assert.ok(backendMainSrc.includes('SUP-'), 'Backend must generate SUP-YYYY-NNNNNN case references');
|
assert.ok(backendMainSrc.includes('SUP-'), 'Backend must generate SUP-YYYY-NNNNNN case references');
|
||||||
|
|
||||||
|
|
||||||
|
// 41.5 Support Report Modal static contract verification
|
||||||
|
// Note: Runtime interaction, form validation, duplicate submission protection,
|
||||||
|
// error handling, and success flows are behaviorally exercised in tests/support-report-modal.test.tsx.
|
||||||
|
const supportModalSrc = fs.readFileSync(path.resolve(__dirname, '../components/ops/support-report-modal.tsx'), 'utf8');
|
||||||
|
assert.ok(supportComponentSrc.includes('New Support Report'), 'Support list must provide New Support Report button');
|
||||||
|
assert.ok(supportModalSrc.includes('reporter_email'), 'Support modal must use exact backend field name reporter_email');
|
||||||
|
assert.ok(supportModalSrc.includes('ticket_type'), 'Support modal must use exact backend field name ticket_type');
|
||||||
|
assert.ok(supportModalSrc.includes('subject'), 'Support modal must use exact backend field name subject');
|
||||||
|
assert.ok(supportModalSrc.includes('description'), 'Support modal must use exact backend field name description');
|
||||||
|
assert.ok(supportModalSrc.includes('severity'), 'Support modal must use exact backend field name severity');
|
||||||
|
|
||||||
|
// Verify all four canonical ticket_type values are present and non-canonical values are absent
|
||||||
|
assert.ok(supportModalSrc.includes('value="general">General</option>'), 'Support modal must offer general option');
|
||||||
|
assert.ok(supportModalSrc.includes('value="bug_report">Bug Report</option>'), 'Support modal must offer bug_report option');
|
||||||
|
assert.ok(supportModalSrc.includes('value="telemetry_issue">Telemetry Issue</option>'), 'Support modal must offer telemetry_issue option');
|
||||||
|
assert.ok(supportModalSrc.includes('value="contact_request">Contact Request</option>'), 'Support modal must offer contact_request option');
|
||||||
|
assert.ok(!supportModalSrc.includes('billing'), 'Support modal must not offer billing option');
|
||||||
|
assert.ok(!supportModalSrc.includes('data_deletion'), 'Support modal must not offer data_deletion option');
|
||||||
|
|
||||||
|
// Accessible dialog semantics
|
||||||
|
assert.ok(supportModalSrc.includes('role="dialog"') && supportModalSrc.includes('aria-modal="true"'), 'Support modal must have dialog semantics aria tags');
|
||||||
|
|
||||||
|
// List refresh is verified by the inclusion of onSuccess callback in OperationsSupport
|
||||||
|
assert.ok(supportComponentSrc.includes('onSuccess={('), 'OperationsSupport must provide list refresh callback');
|
||||||
|
assert.ok(supportComponentSrc.includes('handleSelectTicket(ticket.id)'), 'OperationsSupport must provide selection/opening of the created ticket');
|
||||||
console.log(' PASS: Section 3 Support Center backend, proxy routes, navigation, and UI contracts fully verified.');
|
console.log(' PASS: Section 3 Support Center backend, proxy routes, navigation, and UI contracts fully verified.');
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,301 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { SupportReportModal } from '../components/ops/support-report-modal';
|
||||||
|
|
||||||
|
describe('SupportReportModal Component Tests', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// A. Visibility and lifecycle
|
||||||
|
// ============================================================
|
||||||
|
it('does not render dialog when isOpen=false, renders form when isOpen=true, and Escape calls onClose', async () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
|
||||||
|
const { rerender } = render(
|
||||||
|
<SupportReportModal isOpen={false} onClose={onClose} onSuccess={onSuccess} />
|
||||||
|
);
|
||||||
|
expect(screen.queryByRole('dialog')).toBeNull();
|
||||||
|
|
||||||
|
rerender(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(dialog).toBeDefined();
|
||||||
|
expect(screen.getByRole('heading', { name: /new support report/i })).toBeDefined();
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// B. Canonical ticket types
|
||||||
|
// ============================================================
|
||||||
|
it('renders all four canonical ticket types and excludes deprecated billing or data_deletion', () => {
|
||||||
|
render(
|
||||||
|
<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />
|
||||||
|
);
|
||||||
|
|
||||||
|
const select = screen.getByLabelText(/ticket type/i) as HTMLSelectElement;
|
||||||
|
expect(select).toBeDefined();
|
||||||
|
|
||||||
|
const optionValues = Array.from(select.options).map(opt => opt.value);
|
||||||
|
const optionLabels = Array.from(select.options).map(opt => opt.textContent);
|
||||||
|
|
||||||
|
expect(optionValues).toEqual(['general', 'bug_report', 'telemetry_issue', 'contact_request']);
|
||||||
|
expect(optionLabels).toEqual(['General', 'Bug Report', 'Telemetry Issue', 'Contact Request']);
|
||||||
|
|
||||||
|
expect(optionValues).not.toContain('billing');
|
||||||
|
expect(optionValues).not.toContain('data_deletion');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// C. Whitespace validation
|
||||||
|
// ============================================================
|
||||||
|
it('rejects whitespace-only subject without calling fetch and displays validation error', async () => {
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: ' ' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Valid description text' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Subject cannot be empty or contain only whitespace.')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects whitespace-only description without calling fetch and displays validation error', async () => {
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Valid subject title' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: ' \n \t ' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
||||||
|
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByText('Description cannot be empty or contain only whitespace.')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// D. Trimmed payload
|
||||||
|
// ============================================================
|
||||||
|
it('submits trimmed values and canonical ticket_type and severity to the backend API', async () => {
|
||||||
|
let capturedUrl = '';
|
||||||
|
let capturedOptions: RequestInit | undefined;
|
||||||
|
|
||||||
|
global.fetch = vi.fn(async (url: string | URL | Request, options?: RequestInit) => {
|
||||||
|
capturedUrl = url.toString();
|
||||||
|
capturedOptions = options;
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
id: 'sup-12345',
|
||||||
|
case_reference: 'SUP-2026-000001',
|
||||||
|
ticket_type: 'telemetry_issue',
|
||||||
|
status: 'open',
|
||||||
|
severity: 'high',
|
||||||
|
reporter_email: 'driver@example.com',
|
||||||
|
subject: 'Speed sensor intermittent dropout',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
has_diagnostics: false,
|
||||||
|
notes_count: 0,
|
||||||
|
}),
|
||||||
|
} as Response;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), {
|
||||||
|
target: { value: ' driver@example.com ' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/ticket type/i), {
|
||||||
|
target: { value: 'telemetry_issue' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/severity/i), {
|
||||||
|
target: { value: 'high' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), {
|
||||||
|
target: { value: ' Speed sensor intermittent dropout ' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), {
|
||||||
|
target: { value: ' Telemetry packet dropped at 60mph on turn 4 ' },
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(global.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
|
||||||
|
expect(capturedUrl).toBe('/api/ops/support/tickets');
|
||||||
|
expect(capturedOptions?.method).toBe('POST');
|
||||||
|
expect(capturedOptions?.headers).toEqual({ 'Content-Type': 'application/json' });
|
||||||
|
|
||||||
|
const parsedBody = JSON.parse(capturedOptions?.body as string);
|
||||||
|
expect(parsedBody).toEqual({
|
||||||
|
ticket_type: 'telemetry_issue',
|
||||||
|
reporter_email: 'driver@example.com',
|
||||||
|
subject: 'Speed sensor intermittent dropout',
|
||||||
|
description: 'Telemetry packet dropped at 60mph on turn 4',
|
||||||
|
severity: 'high',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// E. Duplicate-submit prevention
|
||||||
|
// ============================================================
|
||||||
|
it('prevents duplicate submissions while a request is pending and disables the submit button', async () => {
|
||||||
|
let resolveFetch!: (value: Response) => void;
|
||||||
|
const fetchPromise = new Promise<Response>(resolve => {
|
||||||
|
resolveFetch = resolve;
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockFetch = vi.fn(() => fetchPromise);
|
||||||
|
global.fetch = mockFetch;
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={vi.fn()} onSuccess={vi.fn()} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'GPS sync failed' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'GPS fix took 45 seconds.' } });
|
||||||
|
|
||||||
|
const submitBtn = screen.getByRole('button', { name: /create ticket/i }) as HTMLButtonElement;
|
||||||
|
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Button should now be disabled and show "Submitting..."
|
||||||
|
expect(submitBtn.disabled).toBe(true);
|
||||||
|
expect(submitBtn.textContent).toContain('Submitting...');
|
||||||
|
|
||||||
|
// Attempt second submission while first is still pending
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
// Resolve the pending fetch
|
||||||
|
resolveFetch({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ id: 'ticket-999' }),
|
||||||
|
} as Response);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// F. Successful submission
|
||||||
|
// ============================================================
|
||||||
|
it('calls onSuccess with the returned ticket summary and closes modal on success', async () => {
|
||||||
|
const ticketSummary = {
|
||||||
|
id: 'ticket-abc-123',
|
||||||
|
case_reference: 'SUP-2026-000100',
|
||||||
|
ticket_type: 'general',
|
||||||
|
status: 'open',
|
||||||
|
severity: 'medium',
|
||||||
|
reporter_email: 'rider@example.com',
|
||||||
|
subject: 'Question on Bluetooth OBD link',
|
||||||
|
created_at: new Date().toISOString(),
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
has_diagnostics: false,
|
||||||
|
notes_count: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ticketSummary,
|
||||||
|
} as Response));
|
||||||
|
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'rider@example.com' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Question on Bluetooth OBD link' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Pairing instructions inquiry.' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
||||||
|
|
||||||
|
await waitFor(() => expect(onSuccess).toHaveBeenCalledWith(ticketSummary));
|
||||||
|
expect(onClose).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// G. API failure
|
||||||
|
// ============================================================
|
||||||
|
it('displays API error detail on non-2xx response, does not call onSuccess, and releases submitting state', async () => {
|
||||||
|
global.fetch = vi.fn(async () => ({
|
||||||
|
ok: false,
|
||||||
|
status: 422,
|
||||||
|
json: async () => ({ detail: 'Invalid ticket type or email address' }),
|
||||||
|
} as Response));
|
||||||
|
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'rider@example.com' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Test subject' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Test description' } });
|
||||||
|
|
||||||
|
const submitBtn = screen.getByRole('button', { name: /create ticket/i }) as HTMLButtonElement;
|
||||||
|
fireEvent.click(submitBtn);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Invalid ticket type or email address')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled();
|
||||||
|
expect(onClose).not.toHaveBeenCalled();
|
||||||
|
expect(submitBtn.disabled).toBe(false);
|
||||||
|
expect(submitBtn.textContent).toContain('Create Ticket');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// H. Network failure
|
||||||
|
// ============================================================
|
||||||
|
it('displays safe user-facing error on network exception and does not leak internal error message', async () => {
|
||||||
|
global.fetch = vi.fn(async () => {
|
||||||
|
throw new Error('ECONNREFUSED 127.0.0.1:8080 internal crash');
|
||||||
|
});
|
||||||
|
|
||||||
|
const onSuccess = vi.fn();
|
||||||
|
const onClose = vi.fn();
|
||||||
|
|
||||||
|
render(<SupportReportModal isOpen={true} onClose={onClose} onSuccess={onSuccess} />);
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText(/reporter email/i), { target: { value: 'user@example.com' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/subject/i), { target: { value: 'Network test' } });
|
||||||
|
fireEvent.change(screen.getByLabelText(/description/i), { target: { value: 'Network error test desc' } });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /create ticket/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('Network error during submission')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByText(/ECONNREFUSED/)).toBeNull();
|
||||||
|
expect(screen.queryByText(/internal crash/)).toBeNull();
|
||||||
|
expect(onSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
globals: true,
|
||||||
|
include: ['tests/**/*.test.tsx', 'tests/**/*.test.ts'],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue