# IMS — Security Test Plan (QA)

For: QA / manual testers verifying the security remediation described in
`doc/security/SOURCE_CODE_SECURITY_AUDIT_REPORT.md`.
You do **not** need a security background to run this — every test tells you
exactly what to send and exactly what a PASS looks like. If a result doesn't
match "Expected," stop, capture the response (screenshot or copy the JSON),
and file it against the finding ID shown — don't try to diagnose it yourself.

**How to use this document:**
- Run **Section 0** once per environment before anything else (it sets up the
  accounts/IDs every other test references).
- Section 1 (**Smoke**) is the minimum bar — run it on every build.
- Sections 2–9 are the full pass — run before any release and after touching
  auth, payments, file upload, or any public-facing route.
- Section 10 is a plain **regression checklist** — confirming the security
  fixes didn't break normal, everyday use of the app.
- Record PASS/FAIL and the date in the last column of each table. A FAIL on
  anything marked **[BLOCKER]** stops the release.

**Tools you'll need:**
- A REST client — Postman, Insomnia, or plain `curl` in a terminal (examples
  below use `curl`; paste the same request into Postman if you prefer).
- A browser with DevTools (Network + Application/Storage tabs).
- Two separate browser profiles (or one normal + one incognito) so you can be
  logged in as two different accounts at once.

---

## 0. Test environment setup (do this first)

Fill in these values once per environment — every test below refers back to
them.

| Variable | How to get it | Your value |
|---|---|---|
| `$BASE` | The API base URL, e.g. `https://api-staging.example.com/api/v1` | |
| `$SITE` | The API's **domain root**, no path — e.g. `https://api-staging.example.com` (used only for the `.git`/`.env` checks in Section 8, which live outside `/api/v1`) | |
| `$WEB` | The frontend URL, e.g. `https://app-staging.example.com` | |
| `STUDENT_A_TOKEN` | Log in as **Student A** (via the UI or `POST $BASE/auth/login`), copy the `accessToken` from the response | |
| `STUDENT_A_ID`, `STUDENT_A_REGNO` | Student A's own id / registration number, visible on their profile page | |
| `STUDENT_B_TOKEN` | Same, for a **second, different** student account | |
| `STUDENT_B_ID` | Student B's id | |
| `STAFF_LOWPRIV_TOKEN` | Log in as a staff/lecturer account that does **not** have admin permissions | |
| `ADMIN_TOKEN` | Log in as a full admin account | |
| `KNOWN_INVOICE_ITEM_ID` | An invoice item (fee) belonging to Student A, from their payments page | |
| `KNOWN_ATTENDANCE_SESSION_ID` | A class session id Student A is enrolled in, currently running or upcoming | |

> Never run any test in this document against a **production** environment
> with real student/financial data unless a test specifically says "safe on
> production" — most of these tests are read-only and safe, but the payment
> and write-endpoint tests (Section 4, 8) must only be run on staging/test
> data.

`curl` shorthand used throughout:
```bash
AUTH_A=(-H "Authorization: Bearer $STUDENT_A_TOKEN")
AUTH_B=(-H "Authorization: Bearer $STUDENT_B_TOKEN")
AUTH_ADMIN=(-H "Authorization: Bearer $ADMIN_TOKEN")
```

---

## 1. Smoke tests — run on every build [BLOCKER if any fail]

| ID | Test | Steps | Expected | Result |
|---|---|---|---|---|
| SMOKE-01 | Login works | `POST $BASE/auth/login` with valid Student A credentials | `200`, response has `accessToken` + `refreshToken` | |
| SMOKE-02 | Protected route requires a token | `curl $BASE/admin/students` (no `Authorization` header) | `401` | |
| SMOKE-03 | A page loads with no console errors | Open `$WEB` in a browser, open DevTools console, log in, visit the dashboard | No red errors in console; CSP violation warnings are OK (expected, see TC-702) | |
| SMOKE-04 | File download still works | As Student A, open any admit card / attachment / bank slip you have on file | File opens/downloads normally | |
| SMOKE-05 | Payment flow reaches the gateway | Start a real fee payment as Student A on staging | You are redirected to the payment gateway page (do not need to complete payment) | |

---

## 2. Authentication & session tests

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-201 | AUTH-2 | Login is rate-limited | `POST $BASE/auth/login` with a wrong password, 6 times in a row within 1 minute, same account | 6th attempt (or earlier) returns `429 Too Many Requests` | |
| TC-202 | AUTH-3 | Logout actually invalidates the token | 1) Log in, save the `accessToken`. 2) Call `POST $BASE/auth/logout` with it. 3) Immediately call `GET $BASE/auth/profile` with the **same** token | Step 3 returns `401` (not `200`) | |
| TC-203 | AUTH-3 | Refresh token rotates | 1) Log in, save `refreshToken` as `R1`. 2) `POST $BASE/auth/refresh` with `R1`, save the new `refreshToken` as `R2` (must differ from `R1`). 3) `POST $BASE/auth/refresh` again with `R1` (the old one) | Step 3 returns `401` — a used refresh token cannot be reused | |
| TC-204 | AUTH-7 | Wrong OTP is rate-limited | Trigger a password-reset OTP for a test account, then submit the wrong 6-digit code 6 times in a row | After 5 wrong attempts, the response says the OTP is locked / to request a new one — not just "invalid code" forever | |
| TC-205 | AUTH-7 | Password-reset lookup doesn't leak personal data | `POST $BASE/auth/forgot-password/validate-username` with a **real** username/NIC you know | Response shows a **masked** email/phone (e.g. `j***e@e***e.com`) — never the full email/phone, and no `full_name`, `nic_no`, `dob`, or `passport` field at all | |
| TC-206 | AUTH-1b | Entrance-exam login needs date of birth | On the entrance-exam login page, enter only the NIC/phone/admission number, leave date of birth blank, submit | Form blocks submission / API returns a validation error — login must not succeed without DOB | |
| TC-207 | AUTH-1b | Wrong DOB is rejected | Enter a real candidate's NIC/phone but the **wrong** date of birth | Login fails with a generic error (not "DOB incorrect" specifically — should not confirm which field was wrong) | |

---

## 3. Cross-account access (IDOR) tests

These confirm one user cannot see or modify another user's data. **Always use
two real, different test accounts (A and B) — never test this against your
own account.**

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-301 | AUTHZ-1 | Student cannot read another student's full record | As Student A: `curl "${AUTH_A[@]}" $BASE/student/$STUDENT_B_ID` | `403` (not Student B's data) | |
| TC-302 | AUTHZ-1 | Student **can** read their own record | As Student A: `curl "${AUTH_A[@]}" $BASE/student/$STUDENT_A_ID` | `200` with Student A's own data | |
| TC-303 | AUTHZ-1 | Student cannot submit answers to another student's exam session | Start an academic MCQ exam as Student A, note the `session_id` from the network tab. As Student B, `POST $BASE/student/academic-exams/submit` with Student A's `session_id` and any answer | `403` / `404` — must not be accepted | |
| TC-304 | AUTHZ-3 | A student token cannot reach admin write endpoints | As Student A: `curl "${AUTH_A[@]}" -X POST $BASE/admin/students -d '{}'` (or any admin POST/PUT/DELETE route) | `403` | |
| TC-305 | AUTHZ-3 | A student token cannot list all students | As Student A: `curl "${AUTH_A[@]}" $BASE/admin/students/all` | `403` | |
| TC-306 | AUTHZ-3 | Legitimate student self-service still works | As Student A, in the app UI: view your own class schedule, check in to a live class session, view your own attendance | All succeed normally (this is the regression check for the AUTHZ-3 fix) | |
| TC-307 | AUTHZ-8 | Public "find student" lookup returns masked data only | `POST $BASE/student/find-by-identifier` with a real student's phone or NIC (no login required) | Response has a masked name/email/phone (or none) and **no** `nic_no`, `passport`, `dob`, `gender`, `whatsapp_num` field | |
| TC-308 | AUTHZ-8 | Online registration doesn't auto-fill personal data | On the public registration page, search using a phone number/NIC that already exists in the system | The form does **not** silently pre-fill name/NIC/DOB/passport — at most shows a generic "existing record found" message | |
| TC-309 | AUTHZ-5 | Staff cannot self-approve their own inventory/purchase-order/discount request | As a low-privilege staff account, create an inventory transaction / purchase order / discount and try to include an "approved by / approved at" value in the request payload (Postman: add `approved_by`, `approved_at` to the JSON body) | The created record's approval fields are **empty/null**, not the value you sent — approval only happens via the dedicated "Approve" button/action | |

---

## 4. Payment security tests — **staging only, never production**

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-401 | API-9b | Payment order creation requires login | `curl -X POST $BASE/payments/genie/create -d '{}'` (no `Authorization` header) | `401` | |
| TC-402 | API-9b | Payment order creation requires login (MyFees) | `curl -X POST $BASE/payments/myfees/create -d '{}'` (no `Authorization` header) | `401` | |
| TC-403 | API-9b | Fake webhook cannot mark an order paid | Pick any order id (real or made up). `curl -X POST $BASE/payments/myfees/webhook -d '{"merchantReference":"<id>","status":"SUCCESS"}'` with no special headers | `401`/`403` — must be rejected. It must **not** appear as paid afterward | |
| TC-404 | API-9b | Underpaying does not settle the order | Create a real staging order for a known amount, but arrange for the confirmed/paid amount from the gateway sandbox to be less than the order amount (or ask a developer to simulate this) | The order's status becomes something other than `paid` (e.g. `amount_mismatch`) — it must not show as fully paid | |
| TC-405 | — (business logic, see report §4.1 API-9b) | **Known open issue — expected to currently FAIL, log it as already-tracked, do not re-report as new** | On staging, attempt to pay a very small amount (e.g. the minimum allowed) against a fee item worth much more, and check whether the fee item shows as "completed"/fully paid afterward | This is the one item still marked open in the audit report — if it shows as prematurely settled, that confirms the known finding; do not file a duplicate bug, reference `doc/security/payment-integrity-finding.md` instead | |

---

## 5. File upload & media tests

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-501 | INJ-3 | Renamed HTML file is rejected on upload | Take a plain-text file containing `<script>alert(1)</script>`, rename it to `photo.jpg`, upload it anywhere the app accepts an image (profile photo, learning material, etc.) | Upload is rejected, or the file is stored but never rendered as HTML | |
| TC-502 | INJ-3 | Learning-material upload only accepts allowed types | As a lecturer/admin, try to upload a `.html`, `.svg`, or `.exe` file as a "learning material" | Upload rejected with a validation error listing the allowed file types | |
| TC-503 | INJ-3 | Normal uploads still work | Upload a real `.pdf`, `.docx`, `.jpg` as a learning material / profile photo / document | Succeeds normally | |
| TC-504 | AUTHZ-7 | Media proxy cannot be used to read arbitrary server files | `curl "$BASE/media/proxy?path=../../../../etc/passwd&disk=public"` (or on Windows-hosted staging, try a path like `..%2f..%2f.env`) | `400` or `404` — must never return file contents | |
| TC-505 | AUTHZ-7 | Media proxy cannot read from the private disk | `curl "$BASE/media/proxy?path=firebase/firebase-credentials.json&disk=local"` | `400` ("Unsupported disk") | |
| TC-506 | AUTHZ-7 | Media proxy never serves HTML/JS as a live page | Find any file path served through `/media/proxy` that ends in `.html` or `.svg` (if one exists) and open its proxy URL directly in a browser tab | The browser downloads it as a file (attachment), it does **not** render as a webpage / execute any script | |
| TC-507 | DATA-8 | Backup download requires admin login | Log in as admin, go to Database Backups, copy a backup's download link/action. Try the same download action logged out or as a non-admin | Non-admin/logged-out attempt is refused (`401`/`403`); admin download still works and gives a valid `.sql` file | |

---

## 6. Input validation / XSS tests

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-601 | INJ-2 | Rich-text fields neutralise scripts | In any rich-text field the app renders back to another user — assignment description, exam instructions, student submission text, communication/email body — enter: `<img src=x onerror=alert('XSS')>` and save | When the content is viewed (by you or another role, e.g. a lecturer grading a student's submission), **no alert box appears**; the broken image icon may show, that's fine | |
| TC-602 | INJ-2 | Plain formatting still renders | In the same fields, enter normal formatted text (bold, a link, a bullet list) via the rich-text editor | Displays correctly, formatting preserved | |
| TC-603 | INJ-6 | Excel/CSV export doesn't execute formulas | Set a field that appears in an export (e.g. a student's name, remarks, or bank name on a payment) to: `=cmd|'/c calc'!A1` — save it, then export the report containing it (Excel and/or CSV) and open the downloaded file | The cell shows the literal text (e.g. starting with `'=cmd...`), **no popup or command runs** when opening the file | |
| TC-604 | INJ-1 | Search fields don't break on SQL-like input | In any search box (student search, admin list filters), type: `' OR 1=1 --` and search | Returns a normal "no results" or filtered result — no error page, no server error, no unexpected data dump | |

---

## 7. Rate limiting & abuse tests

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-701 | AUTH-2 | OTP request is rate-limited | Request a password-reset OTP for the same account 4 times within a minute | 4th request (or earlier) is throttled (`429`) | |
| TC-702 | CFG-1 | Security headers are present | `curl -sI $WEB` and `curl -sI $BASE/auth/login` | Response headers include `X-Frame-Options`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`; a `Content-Security-Policy` or `Content-Security-Policy-Report-Only` header is present | |

---

## 8. Configuration & information-disclosure tests

| ID | Finding | Test | Steps | Expected | Result |
|---|---|---|---|---|---|
| TC-801 | CFG-4 | `.git` is not accessible | `curl -o /dev/null -w '%{http_code}\n' $SITE/.git/config` | `403` or `404` | |
| TC-802 | CFG-4 / API-8 | `.env` is not accessible | `curl -o /dev/null -w '%{http_code}\n' $SITE/.env` | `403` or `404` | |
| TC-803 | API-8 | Errors don't leak stack traces | Trigger an error on purpose (e.g. send garbage JSON to any POST endpoint, or an invalid id to a detail endpoint) | Response is a clean JSON error message — no file paths, no "at line", no SQL text, no stack trace | |
| TC-804 | DATA-3 | Public settings endpoint doesn't leak secrets | `curl $BASE/admin/settings/public` and `curl $BASE/admin/settings/group/signature` (no login needed) | Response contains only branding-type values (app name, logo, colours); nothing containing "secret", "password", "token", "key", or a long random-looking string | |
| TC-805 | API-9 | Webhook endpoints reject unsigned requests | `curl -X POST $BASE/facebook/webhook -d '{}'` and the same for `/tiktok/webhook`, `/webhooks/whatsapp`, `/webhook/fringer/attendance` (no signature headers) | Each returns an error/rejection (`401`/`403`) rather than `200 success` | |

---

## 9. Deployment sanity (ask a developer to confirm the environment first)

These aren't things QA fixes, but they gate whether the environment is even
testable/safe — check with the dev/DevOps team before a release sign-off:

- [ ] `APP_DEBUG=false` and `APP_ENV=production` are set on the production
      backend `.env`.
- [ ] `CORS_ALLOWED_ORIGINS` is set to the real frontend domain(s), not empty
      and not `*`.
- [ ] Webhook secrets (`WHATSAPP_APP_SECRET`, `FRINGER_WEBHOOK_SECRET`,
      `MYFEES_WEBHOOK_SECRET`, Zoom's secret token) are configured — ask a
      developer to confirm via server logs that no "UNAUTHENTICATED webhook"
      warning is appearing.

---

## 10. Regression checklist — confirm nothing normal broke

Run this full click-through as **three different roles** (Student, Staff/Lecturer, Admin) after any security-related release:

- [ ] Login and logout work; logging back in after logout works.
- [ ] Session doesn't unexpectedly expire mid-task (test something that takes
      a few minutes, like filling a long form).
- [ ] Dashboard loads with correct data for that role.
- [ ] File uploads (profile photo, documents, learning materials, bank
      transfer slip) succeed with normal file types.
- [ ] File/image downloads and previews (admit card, attachments, invoices,
      exported reports) open correctly.
- [ ] Excel/CSV/PDF exports open without warnings and contain correct data.
- [ ] Password reset (forgot password) flow completes end-to-end with a real
      OTP.
- [ ] A real payment can be started and completed on staging (sandbox mode).
- [ ] Student: attendance self check-in/check-out on a live class session
      works.
- [ ] Student: taking and submitting an academic MCQ exam works end to end.
- [ ] Admin: creating/approving an inventory transaction, purchase order, and
      discount works, and shows the correct approver name (not blank, not
      wrong) after approval.
- [ ] Admin: viewing, creating, and downloading a database backup works.
- [ ] Public pages (About Us, News, Contact, entrance-exam login, online
      registration) load without errors for a logged-out visitor.

---

## Sign-off

| Environment | Tested by | Date | Sections completed | Overall result |
|---|---|---|---|---|
| | | | | ☐ Pass ☐ Pass with notes ☐ Fail |

**If anything fails:** note the Test ID, the Finding ID next to it, what you
sent, and exactly what came back (status code + response body). Send that to
the developer — do not attempt to fix or reclassify the severity yourself.
