Author SHA1 Message Date
abiba-bot 1f15ca5457 docs: production hardening & review checklist for demo->prod transition
Grounded in hands-on review of the live scottdenya deployment and main.
P0 security blockers, P1 operational hardening, WhatsApp wiring runbook,
no-mistakes review items, and production Definition-of-Done.

Prepared by Mumuni (Syslog Falcon) 2026-08-03.
2026-08-03 08:05:26 +00:00
11 changed files with 217 additions and 225 deletions
-5
View File
@@ -94,11 +94,6 @@ Frontend: Alpine.js (CDN) + Tailwind CSS (CDN). Auth state in localStorage. Role
## Ticket System (Sprint 2)
- `reported_at` (nullable DateTime, alembic `d5e0f2a1c3b4`) records a ticket's original reported date;
`TicketCreate.reported_at` lets Admin/Wahab enter backdated tickets that stay active. It defaults to
now when omitted (migration backfilled existing rows from `created_at`). SLA deadlines run from
`created_at`, not `reported_at` — backfilling history never instantly breaches a ticket.
### Status Lifecycle (16 statuses)
New → Logged → Triage → Assigned → Accepted → Travelling → On Site → In Progress → Waiting Parts → Escalated → Completed → On-Field Verification → Wahab Review → Closed → Reopened → Cancelled
+214
View File
@@ -0,0 +1,214 @@
# Denya OneCare — Production Hardening & Review Checklist
> **Audience:** Abiba (and any agent working the Denya OneCare repo)
> **Status:** Demo → Production hardening
> **Context:** WhatsApp integration lands within a week (once Denya provides credentials).
> We are moving past "demo" toward the final product. This doc is the concrete,
> ordered punch-list to get there. Each item is grounded in the current codebase
> (verified against `main` and the live deployment on `scottdenya`).
> **How to use:** work top-down. P0 items are hard blockers for any real data.
> When a P0/P1 item is done, mark it `[x]` and PR it with a `no-mistakes(review)` pass.
---
## 0. Current state (verified 2026-08-03)
- **Working & verified:** auth (JWT 30m/7d, bcrypt, RBAC via `require_roles`), ticket
CRUD with 16-status `VALID_TRANSITIONS` state machine, SLA engine, photo uploads,
category/unit hierarchy, 3 role dashboards (CS/FM/CEO), Alembic migrations with
legacy-schema self-heal. **32 pytest tests pass.**
- **Live:** container `denya-onecare` on LXC `scottdenya` (192.168.68.75:8000),
image built 2026-08-02, `restart: unless-stopped`.
- **Known demo-only posture (must change):** `SECRET_KEY=change-me-in-production`,
`CORS_ORIGINS=*`, open `/api/auth/register`, SQLite backend, WhatsApp webhook
code is present but **no real credentials wired**.
---
## 1. P0 — Security blockers (do these FIRST, before any real data)
### P0.1 Hardcode-safe secrets; never ship the default key
- **Files:** `docker-compose.yml`, `app/core/config.py`
- Replace the hardcoded `SECRET_KEY=change-me-in-production` default with a
fail-closed default: if `SECRET_KEY` is unset/empty or still the well-known
placeholder string, refuse to boot (raise in `Settings` validation or lifespan).
- `docker-compose.yml` must NOT carry a literal secret. Reference an `.env`
(git-ignored) or a runtime secret source. Add `SECRET_KEY` + `WHATSAPP_*` to `.gitignore`.
- **Acceptance:** starting the app without a real key fails loudly; container env
contains a strong random key (≥32 bytes, e.g. `openssl rand -hex 32`).
### P0.2 Lock down CORS
- **Files:** `app/main.py`, `docker-compose.yml`
- `CORS_ORIGINS=*` + `allow_credentials=True` is an invalid/unsafe combo
(browsers reject `*` with credentials anyway). Replace with an explicit
origin allow-list of the real web origins (e.g. `https://denya.sysloggh.net`,
your NetBird/nomad domain + localhost for dev).
- If credentials are used, origins MUST be explicit — never `*`.
- **Acceptance:** `settings.CORS_ORIGINS` is a comma-separated explicit list; the
middleware builds an allow-list, not `["*"]`.
### P0.3 Gate user registration
- **File:** `app/routers/auth.py` (`POST /api/auth/register`)
- Today anyone on the network can self-register. Decide the model:
- **Recommended:** require an admin-issued invitation token, or restrict
registration to a seed/allowed list, or remove the open route and create
users only via seed/admin.
- If a public self-service resident/tenant signup is genuinely required
(Phase 2 QR/self-service), it must be a SEPARATE endpoint with a **role
default of the least-privilege role** and rate-limiting — never able to mint
admin/FM roles.
- **Acceptance:** a raw, unauthenticated register call can no longer mint an
`Admin/*` or `Director` account.
### P0.4 Reconsider SQLite for the final product
- **Files:** `docker-compose.yml`, `app/core/database.py`, `app/core/config.py`, PRD §16
- PRD Phase 1 calls for PostgreSQL. SQLite is fine for POC but is a write-lock
bottleneck and a data-integrity risk under concurrent FM/CS/WhatsApp writes.
- **Recommended:** switch `DATABASE_URL` to Postgres via async driver
(`postgresql+asyncpg://`). SQLAlchemy 2.0 + SQLAlchemy models are portable —
the migration is mostly: new driver dependency, `DATABASE_URL`, and re-running
Alembic against Postgres. Keep SQLite as the default for local dev/tests only.
- **Acceptance:** `pytest` green against Postgres (tests param via conftest),
Alembic applies cleanly on a fresh Postgres DB.
### P0.5 WhatsApp webhook auth + hardening (finish wiring, then lock it)
- **File:** `app/routers/whatsapp.py`
- The handler exists but no credentials are set. When wiring this week:
- Verify the `hub.verify_token` check is constant-time (compare with
`secrets.compare_digest`). **The GET verification path currently returns
`{"error": ...}` with HTTP 200** — flip to `403` on token mismatch.
- Validate **inbound messages only from Meta** — the webhook MUST authenticate
Meta's request signature (X-Hub-Signature-256 HMAC over the raw body with your
app secret) before processing, otherwise anyone who discovers the endpoint can
forge tickets. This is the single most important WhatsApp hardening item.
- Add per-sender rate limiting / dedupe on `wa_message_id` (webhook retries can
double-create tickets). Create an idempotency guard keyed on `wa_message_id`.
- Never log the raw access token; redact in `send_whatsapp_reply` error paths.
- **Acceptance:** a forged POST without the Meta signature is rejected; duplicate
`wa_message_id` does not create a second ticket; verify-token mismatch returns 403.
---
## 2. P1 — Operational hardening (before/just after go-live)
### P1.1 Secrets handling & git hygiene
- Ensure `SECRET_KEY`, `WHATSAPP_*`, and any DB credentials are **not** in the repo
or in the committed `docker-compose.yml`. `.env` is git-ignored.
- On this fleet: align with Syslog's key-off-disk doctrine — inject secrets at
runtime (Infisical) rather than baking into image or compose if feasible.
- Rotate the seed demo users' `denya123` password before production. `seed_users`
is idempotent but the default password is in `app/services/seed.py` — forced-rotate
on first prod login or at seed time.
### P1.2 Reverse proxy + TLS
- Do not expose the raw uvicorn :8000 behind `CORS_ORIGINS=*` on the WAN.
Terminate TLS at a reverse proxy (Caddy/Traefik/nginx) with a proper domain
(e.g. `denya.sysloggh.net`).
- Configure gunicorn/workers + `--proxy-headers` (or keep uvicorn but behind TLS).
- **Acceptance:** `https://denya.sysloggh.net` serves the app with a valid cert;
`:8000` is not directly reachable from the internet.
### P1.3 DB backups & persistence
- Postgres change (P0.4) enables sane backups. Wire nightly `pg_dump` (or PBS /
Syslog backup cron) of the persistent volume. The compose already mounts
`app-data` volume — make sure it's on backed-up storage.
- Add an Alembic upgrade step to the deploy runbook (never rely only on
`Base.metadata.create_all` + self-heal for schema changes in prod).
### P1.4 Logging & observability
- Add structured request logging; route to a location you can actually check
(stdout + a file/volume). Correlate with `ticket_number`.
- Add a minimal `/health` readiness that checks DB connectivity (currently it
returns OK without touching the DB).
### P1.5 Photo upload hardening
- **File:** `app/routers/tickets.py`
- Uploads already validate MIME + extension and use UUID filenames — good.
- Add: max file-size limit (e.g. 10 MB) and content sniffing (validate magic
bytes, not just `content_type` which is client-supplied).
- Ensure uploaded files are never executable and are served with
`X-Content-Type-Options: nosniff`.
### P1.6 API hardening & rate limiting
- Add rate limiting on `POST /api/auth/login` (brute-force) — per-IP/IP+account.
- Consider rate limits on ticket creation (spam / mass-creation).
- Normalize/validate `page_size` (already capped `le=200`) and pagination
tie-breaker (`id DESC` present — good).
---
## 3. WhatsApp integration (this week) — concrete wiring runbook
Assumes Denya provides: **phone number ID, access token, verify token, app secret.**
1. **Add env vars** (`WHATSAPP_PHONE_NUMBER_ID`, `WHATSAPP_ACCESS_TOKEN`,
`WHATSAPP_VERIFY_TOKEN`, `WHATSAPP_APP_SECRET`, `META_GRAPH_BASE`) to `.env`
(git-ignored) and inject at runtime. Never commit.
2. **Webhook handshake:** in Meta dashboard point the webhook URL at
`<domain>/api/whatsapp/webhook`. The GET verify path currently echoes
`hub.challenge` when the verify token matches — confirm this works, then apply
P0.5 (403 on mismatch, HMAC signature validation).
3. **Verify incoming signature** (P0.5) — use `X-Hub-Signature-256` = HMAC-SHA256
of the raw body with your app secret, compared with `compare_digest`.
4. **Reply flow:** confirm `send_whatsapp_reply` posts correctly to
`graph.facebook.com/v18.0/<PHONE_NUMBER_ID>/messages`. The reply template
currently builds a JS string manually — prefer sending the nested object as a
proper JSON body rather than a hand-built string (`{\"body\":\"...\"}`) to avoid
escaping bugs. Test with the Meta "send a test message" tool.
5. **Idempotency:** guard ticket creation on `wa_message_id` (P0.5) to prevent
double-creation on retries.
6. **Standalone test:** use the `mock-log` endpoint to confirm webhook → ticket →
auto-reply path end-to-end in the demo env before pointing Meta's production
webhook at it.
---
## 4. Review recommendations (for the `no-mistakes(review)` pass and final QA)
- **RBAC coverage:** audit every route for the correct dependency. Currently:
- `POST /api/tickets`, `PATCH`, `POST /{id}/status`, `POST /{id}/photos` → any
authenticated user. Confirm role intent (should a CS Rep push a ticket to
"On-Field Verification"? or only FM/Tech?).
- `GET /api/tickets`, `GET /{id}`, `/transitions`, `/sla`, `/photos` are
**unauthenticated**. For a facilities tool this may be intentional (resident
view), but confirm you're comfortable with public reads of ticket details
(which include reporter/phone). If not, add auth.
- **Phone/tenant data exposure:** ticket detail returns `phone`. Decide who can
see phone numbers and enforce at the API, not just the UI.
- **Test coverage gaps to add:**
- Auth: expired token, malformed token, RBAC denial per role
- WhatsApp: signature validation (valid/invalid/forged), verify-token mismatch,
duplicate `wa_message_id` idempotency
- Pagination boundary: page > last page returns empty items, tie-breaker stable
- Photo upload: bad MIME spoofing, oversize file, `is_before` flag
- SLA: breach boundary exactly at deadline (not just past it)
- **Schema/migration hygiene:** the `ensure_legacy_schema` self-heal in
`app/main.py` exists because of create_all DBs. Once you move to Alembic-only
(P1.3), this becomes dead weight — plan a deprecation.
- **Concurrency:** ticket-number generation reads `max()` then `+1` — fine at
current scale, but under concurrent Postgres writes this can race. If tickets
ever originate from WhatsApp + web + dashboard simultaneously at volume, move to
a sequenced/unique constraint approach.
---
## 5. Definition of Done (production-ready)
- [ ] No default `SECRET_KEY`; app fails closed without a real key
- [ ] CORS is an explicit origin allow-list
- [ ] Self-registration cannot mint privileged roles (or is admin-gated/removed)
- [ ] Postgres backend; Alembic applies cleanly on fresh DB; nightly backups
- [ ] TLS-terminated reverse proxy with real domain; no raw :8000 on WAN
- [ ] WhatsApp webhook: Meta signature validated, verify-token mismatch → 403,
idempotent on `wa_message_id`, real credentials injected at runtime
- [ ] Login/ticket rate limiting in place
- [ ] Photo uploads size-limited and content-sniffed
- [ ] RBAC audited per-route; phone data access controlled
- [ ] Expanded test suite (auth, WhatsApp, SLA boundary, uploads) — all green
- [ ] Secrets out of repo; demo password rotated
- [ ] Structured logs + DB-aware health check
---
*Prepared by Mumuni (Syslog Falcon) — 2026-08-03, from a hands-on review of the
denya-onecare repo and the live scottdenya deployment.*
@@ -1,47 +0,0 @@
"""tickets.reported_at column — original reported date for backdated tickets
Revision ID: d5e0f2a1c3b4
Revises: c4e8f1a2d3b4
Create Date: 2026-08-03 00:00:00.000000
Backdated-ticket support (Wahab demo): one schema change.
* Add ``tickets.reported_at`` (nullable DateTime) so historical/backfilled
tickets keep their true report date instead of inheriting today's
``created_at``.
* Backfill existing rows with their ``created_at`` value so no ticket shows
an empty reported date after the upgrade. The service layer also defaults
new tickets without a ``reported_at`` to now, so the column is effectively
always populated from here on.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'd5e0f2a1c3b4'
down_revision: Union[str, Sequence[str], None] = 'c4e8f1a2d3b4'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Add tickets.reported_at (nullable) and backfill from created_at."""
op.add_column(
'tickets',
sa.Column(
'reported_at',
sa.DateTime(),
nullable=True,
comment='Original reported date; backdated/backfilled tickets keep their true report date',
),
)
# Backfill: every existing ticket was reported when it was created.
op.execute('UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL')
def downgrade() -> None:
"""Drop tickets.reported_at."""
op.drop_column('tickets', 'reported_at')
-8
View File
@@ -38,14 +38,6 @@ async def ensure_legacy_schema(conn) -> None:
text("ALTER TABLE tickets ADD COLUMN phone VARCHAR(50)")
)
logger.info("Added missing tickets.phone column (legacy database)")
if "reported_at" not in ticket_columns:
await conn.execute(
text("ALTER TABLE tickets ADD COLUMN reported_at DATETIME")
)
await conn.execute(
text("UPDATE tickets SET reported_at = created_at WHERE reported_at IS NULL")
)
logger.info("Added missing tickets.reported_at column (legacy database)")
result = await conn.execute(
text(
"UPDATE categories SET name = 'Missing Item' "
-5
View File
@@ -55,11 +55,6 @@ class Ticket(Base):
customer_rating: Mapped[int | None] = mapped_column(Integer, nullable=True)
reopen_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
reported_at: Mapped[datetime | None] = mapped_column(
DateTime,
nullable=True,
comment="Original reported date. Backdated/backfilled tickets keep their true report date; NULL falls back to created_at.",
)
created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False)
updated_at: Mapped[datetime] = mapped_column(
DateTime,
-2
View File
@@ -46,7 +46,6 @@ class TicketCreate(BaseModel):
assigned_to: int | None = None
customer_name: str | None = None
phone: str | None = None
reported_at: datetime | None = None # original report date for backdated/backfilled tickets; defaults to now when omitted
class TicketUpdate(BaseModel):
@@ -101,7 +100,6 @@ class TicketBrief(BaseModel):
description: str | None = None
sla_deadline: datetime | None = None
reopen_count: int = 0
reported_at: datetime | None = None
created_at: datetime
updated_at: datetime
-5
View File
@@ -114,10 +114,6 @@ async def create_ticket(
ticket_number = await _generate_ticket_number(db)
priority = data.get("priority")
sla_deadline = compute_sla_deadline(priority) if priority else None
# Original report date: backdated/backfilled tickets keep their true date;
# when omitted the ticket is considered reported right now. The SLA clock
# is unchanged — deadlines run from creation time, not the reported date.
reported_at = data.get("reported_at") or datetime.now(timezone.utc)
ticket = Ticket(
ticket_number=ticket_number,
@@ -130,7 +126,6 @@ async def create_ticket(
reported_via=data.get("reported_via"),
description=data.get("description"),
assigned_to=data.get("assigned_to"),
reported_at=reported_at,
sla_deadline=sla_deadline,
)
db.add(ticket)
+1 -9
View File
@@ -23,11 +23,7 @@
<span class="px-3 py-1 rounded-full text-sm font-medium" :class="statusClass(ticket.status)" x-text="ticket.status"></span>
<span class="px-3 py-1 rounded text-sm font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span>
</div>
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span>
<template x-if="ticket.reported_at && formatDateShort(ticket.reported_at) !== formatDateShort(ticket.created_at)">
<span> · Reported <span class="font-medium text-gray-600" x-text="formatDateShort(ticket.reported_at)"></span></span>
</template>
</p>
<p class="text-gray-500 mt-1">Created <span x-text="formatDate(ticket.created_at)"></span></p>
</div>
<div class="flex items-center space-x-2">
<button @click="showStatusModal = true" class="px-4 py-2 bg-denya-600 text-white rounded-lg hover:bg-denya-700 transition text-sm font-medium">Update Status</button>
@@ -133,10 +129,6 @@
<dt class="text-sm text-gray-500">Reported Via</dt>
<dd class="text-sm font-medium text-gray-900 capitalize" x-text="ticket.reported_via || '—'"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Reported</dt>
<dd class="text-sm font-medium text-gray-900" x-text="ticket.reported_at ? formatDateShort(ticket.reported_at) : formatDateShort(ticket.created_at)"></dd>
</div>
<div class="flex justify-between">
<dt class="text-sm text-gray-500">Priority</dt>
<dd><span class="px-2 py-0.5 rounded text-xs font-medium" :class="priorityClass(ticket.priority)" x-text="priorityBadge(ticket.priority)"></span></dd>
+1 -5
View File
@@ -130,7 +130,6 @@
<th class="px-5 py-3 text-left cursor-pointer hover:text-gray-900" @click="sortBy('created_at')">
Created <span x-show="sortField === 'created_at'" x-text="sortDir === 'asc' ? '↑' : '↓'"></span>
</th>
<th class="px-5 py-3 text-left">Reported</th>
<th class="px-5 py-3 text-left">SLA</th>
</tr>
</thead>
@@ -143,7 +142,6 @@
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || ticket.created_at)"></td>
<td class="px-5 py-3">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
@@ -153,7 +151,7 @@
</tr>
</template>
<tr x-show="!tickets.length && !loading">
<td colspan="8" class="px-5 py-16 text-center text-gray-400">
<td colspan="7" class="px-5 py-16 text-center text-gray-400">
<svg class="w-12 h-12 mx-auto text-gray-300 mb-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"/></svg>
No tickets match your filters
</td>
@@ -180,7 +178,6 @@
<th class="px-5 py-3 text-left">Description</th>
<th class="px-5 py-3 text-left">Assigned To</th>
<th class="px-5 py-3 text-left">Created</th>
<th class="px-5 py-3 text-left">Reported</th>
<th class="px-5 py-3 text-left">SLA</th>
</tr>
</thead>
@@ -193,7 +190,6 @@
<td class="px-5 py-3 text-gray-600 max-w-xs truncate" x-text="ticket.description || ''"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="ticket.assigned_technician_name || '—'"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDate(ticket.created_at)"></td>
<td class="px-5 py-3 text-gray-500 text-xs" x-text="formatDateShort(ticket.reported_at || ticket.created_at)"></td>
<td class="px-5 py-3">
<span x-show="ticket.sla_deadline" class="text-xs" :class="new Date(ticket.sla_deadline) < new Date() && !['Closed','Completed','Cancelled'].includes(ticket.status) ? 'text-red-600 font-medium' : 'text-gray-400'">
<span x-text="formatDateShort(ticket.sla_deadline)"></span>
+1 -21
View File
@@ -154,15 +154,6 @@
</div>
</div>
<!-- Reported date (backdating support) -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Reported Date</label>
<input type="date" x-model="form.reported_date" :max="todayStr" class="w-full px-4 py-2.5 rounded-lg border border-gray-300 focus:ring-2 focus:ring-denya-500 focus:border-transparent outline-none">
<p class="mt-1 text-xs text-gray-400">Defaults to today. Use a past date when entering an old/backlogged issue — it stays active in the normal workflow.</p>
</div>
</div>
<!-- Photo Upload -->
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">Photos (Before)</label>
@@ -214,10 +205,8 @@
priority: '',
description: '',
reporter: '',
reported_via: '',
reported_date: ''
reported_via: ''
},
todayStr: '',
priorityAuto: false,
categories: [],
subCategories: [],
@@ -234,14 +223,6 @@
async init() {
await this.loadCategories();
await this.loadUnits();
// Default reported date to today (local), allow backdating via the date picker
this.todayStr = this.localDateStr(new Date());
if (!this.form.reported_date) this.form.reported_date = this.todayStr;
},
localDateStr(d) {
const offset = d.getTimezoneOffset();
return new Date(d.getTime() - offset * 60000).toISOString().slice(0, 10);
},
// ── Report mode ──────────────────────────────────────────
@@ -403,7 +384,6 @@
unit_id: this.form.unit.id,
customer_name: this.form.customer_name || null,
phone: this.form.phone || null,
reported_at: this.form.reported_date || null,
};
const ticket = await app().apiPost('/api/tickets', payload);
-118
View File
@@ -1,118 +0,0 @@
"""Tests for backdated reported-date support (Wahab demo).
Anchors:
* A ticket created with a past ``reported_at`` persists that date and it is
exposed on list + detail responses — this is how Wahab enters old tickets
that stay active in the normal workflow.
* A ticket created without ``reported_at`` defaults to "now", so existing
create behavior is unchanged.
* The reported date is metadata only: SLA deadlines still run from creation
time and no age/backdate restriction kicks in.
"""
from __future__ import annotations
from datetime import datetime
import pytest
pytestmark = pytest.mark.asyncio
def _naive(iso: str) -> datetime:
"""Parse an ISO datetime and strip any tz offset for safe comparison."""
dt = datetime.fromisoformat(iso)
return dt.replace(tzinfo=None) if dt.tzinfo is not None else dt
async def _login(client, email="wahab@denya.com", password="denya123") -> str:
resp = await client.post(
"/api/auth/login",
json={"email": email, "password": password},
)
assert resp.status_code == 200, resp.text
return resp.json()["access_token"]
async def _create_ticket(client, token: str, **overrides) -> dict:
payload = {
"unit_id": 2,
"category_id": 3,
"priority": "medium",
"reporter": "Backdate Test",
"reported_via": "walk-in",
"description": "backdate test ticket",
**overrides,
}
resp = await client.post(
"/api/tickets",
json=payload,
headers={"Authorization": f"Bearer {token}"},
)
assert resp.status_code == 201, resp.text
return resp.json()
async def test_create_with_backdated_reported_at_persists(client):
"""Wahab (Admin/Wahab) can enter an old ticket and its date sticks."""
token = await _login(client)
ticket = await _create_ticket(
client,
token,
reported_at="2026-07-20",
description="Old plumbing issue reported weeks ago",
)
assert ticket["reported_at"] is not None
assert ticket["reported_at"].startswith("2026-07-20")
# Still an active ticket in the normal workflow — no age restriction.
assert ticket["status"] in {"New", "Logged"}
# Detail endpoint exposes the reported date.
detail = await client.get(f"/api/tickets/{ticket['id']}")
assert detail.status_code == 200
assert detail.json()["reported_at"].startswith("2026-07-20")
# List endpoint exposes it too.
listing = await client.get("/api/tickets")
assert listing.status_code == 200
listed = next(t for t in listing.json()["items"] if t["id"] == ticket["id"])
assert listed["reported_at"].startswith("2026-07-20")
async def test_create_without_reported_at_defaults_to_now(client):
"""Omitting reported_at behaves exactly as before: reported == created."""
token = await _login(client)
ticket = await _create_ticket(client, token, description="normal today ticket")
assert ticket["reported_at"] is not None
reported = _naive(ticket["reported_at"])
created = _naive(ticket["created_at"])
assert abs((reported - created).total_seconds()) < 60
async def test_reported_at_does_not_shift_sla_deadline(client):
"""SLA computation is unchanged: deadlines run from creation time."""
token = await _login(client)
ticket = await _create_ticket(
client,
token,
priority="urgent",
reported_at="2026-01-01",
description="old urgent ticket",
)
assert ticket["sla_deadline"] is not None
created = _naive(ticket["created_at"])
deadline = _naive(ticket["sla_deadline"])
hours = (deadline - created).total_seconds() / 3600
assert 3.5 <= hours <= 4.5 # urgent → 4 h resolution window from creation
async def test_reported_at_round_trips_full_datetime(client):
"""A precise datetime (not just a date) survives the round trip."""
token = await _login(client)
reported = "2026-07-20T14:30:00"
ticket = await _create_ticket(client, token, reported_at=reported, description="datetime round trip")
assert ticket["reported_at"] is not None
parsed = _naive(ticket["reported_at"])
assert parsed.date().isoformat() == "2026-07-20"
assert parsed.hour == 14 and parsed.minute == 30