# PRD — ui-testing

Evidence date: 2026-09-30.

Source: `git@bitbucket.org:overdrive/ui-testing.git`, `main` at `bcdafbf` (2025-05-24).

There is no prior PRD in Silicon Ark. The source `README.md` and `docs/` describe setup and a pseudo-code design. Requirements below are recovered from those files, the controllers, the Selenium migration, the dashboard view, and git history. Provenance is labelled. Secret values are omitted.

## Product purpose

**Documented.** `README.md` describes an "Admin Overdrive Starter" with authentication, authorisation, user registration, and menu management. `docs/selenium-controllers.md` describes a Selenium test runner: upload a `.side` file, execute it in a browser, record steps, and schedule runs. `env` sets the site title to `UI Testing`.

**Implemented.** A CodeIgniter 4 app with login, role menus, and Selenium tables plus JSON controllers and a dashboard view.

**Discrepancy.** `docs/overview.md` calls the product Transactflow and says PHP `^8.0`. `composer.json` requires PHP `^8.3` and describes the package as Transactflow. `README.md` still lists the AWS SDK, which is not a Composer requirement.

**Unknown / Requires Confirmation:** the audience, the sites under test, and whether the runner is in use.

## Users and roles

Seeded in `app/Database/Seeds/UserRole.php`:

| Role id (insert order) | Name | Seeded menu access |
|---|---|---|
| 1 | Developer | `UserAccess` grants are written only for `role_id` 1 |
| 2 | Admin | No access rows in the seed |
| 3 | Company Admin | No access rows in the seed |
| 4 | Company User | No access rows in the seed |

The users seed inserts one account, username `admin@overdrive.co.za`, role 1, active, full name "Developer Tester". The password is `password_hash` of a short literal in `app/Database/Seeds/Users.php`. The literal is not copied here. **Requires Confirmation:** rotate it before any shared environment.

`Home::index` treats role ids 1, 2, 3, and 7 as "dev admins" and role 5 as "designer". Roles 5 and 7 are not in the role seed. Provenance: **Implemented** leftover. **Unknown** what those ids meant in the donor app.

`users.2fa_enabled` defaults to 1 in the users migration. Login in `Welcome` sends the user to `2fa-validation` when that flag is set and the email-code check fails.

## Actors

| Actor | Evidence | Status |
|---|---|---|
| Signed-in operator | Session `isLoggedIn`. Dashboard at `seleniumDashboard` | **Implemented** |
| Developer role | Only role with seeded menu rows | **Implemented** |
| Anonymous visitor | Login, forgot-password, reset-password, 2FA form, tracking pixel, `cronJobs` | **Implemented** as filter exceptions |
| Pipeline | Bitbucket default pipeline | **Implemented** |
| Scheduled runner | No process reads due schedules | **Not found** |

## Systems the product talks to

1. MySQL schema `uitesting` on localhost, user `uitesting`, empty password in the committed `env`. Provenance: **Documented** in `env`. Whether production uses other credentials is **Unknown**.
2. Local Firefox via `geckodriver` and local Chrome via `chromedriver`, started by `SeleniumController` with Symfony Process. Provenance: **Implemented**.
3. The URL inside an uploaded `.side` file, through WebDriver `open`. Provenance: **Implemented**, only if execution reaches that command.
4. SMTP settings exist as commented keys in `env`. `Welcome` password reset and 2FA call user-model mail helpers. The mail transport that is actually configured in a deployment is **Unknown**.
5. SonarQube and detect-secrets from Bitbucket Pipelines. Mailgun send step is defined and not wired into `pipelines.default`.
6. `public/deploy.php` is entirely inside a PHP comment, including a token check, an IP list, and `shell_exec` of `git pull`. It does not deploy. Provenance: **Implemented** as inert file. The token is not copied here.

No AWS, OpenAI, or Slack call site was found under `app/`. A commit message records removal of a SAWIS Slack integration.

## Major workflows

### WF-1 — Install and sign in

**Trigger:** operator follows `README.md`.

**Inputs:** copy `env` to `.env`, `composer install`, `php spark db:create`, `php spark migrate`, `php spark db:seed Users`, `php spark key:generate`, `php spark serve`. LAMP and browser driver steps are in `docs/lamp_setup.md` and `docs/selenium_setup.md`.

**Behaviour:** `GET /` renders login (`Welcome::index`). `POST GetLogin` checks the user, then either sets `isLoggedIn` or redirects to `2fa-validation`. A blocked IP is recorded in the `Welcome` constructor via `BlockedUsersModel` before login.

**Output:** session with `isLoggedIn`, and a redirect to `2fa-validation` or a logged-in session. The success redirect target after a normal login was not fully traced; `setLoginSession` sets the flag. **Requires Confirmation** of the post-login landing page in a running app.

**Errors:** unknown user, bad password, blocked IP, 2FA code failure. Exact messages live in `Welcome.php`.

### WF-2 — Open the Selenium dashboard

**Trigger:** menu URL `seleniumDashboard` (`UserMenu` seed, General category).

**Behaviour:** `SeleniumTestController::dashboard` loads menu categories for the session role and renders `app/Views/test/seleniumDashboard.php`.

**Auth:** `isLoggedIn` applies. The `isGranted` filter excepts `seleniumTestController` and `seleniumTestController/*`, but the dashboard route is `seleniumDashboard`, which is **not** in that except list. Authorisation therefore looks up a menu whose URL is `seleniumDashboard`. The seed creates that menu. Role 1's access seed includes `menu_id` 7. The menu seed inserts six rows, so id 7 does not match the Selenium menu if ids are 1–6. **Requires Confirmation** after migrate/seed which menu id the Selenium row receives, and whether role 1 can open the page.

### WF-3 — Upload a `.side` file

**Trigger:** dashboard modal submit.

**View sends:** `POST seleniumTestController/create` as multipart form, fields `name`, `description`, file input `sidefile`, plus a CSRF field.

**Controller expects:** AJAX, session `user_id`, validation rules on `name` and uploaded file `side_file` (extension `side` or `json`, max 10240 KB). It checks JSON shape in `isValidSideFile`, then inserts `file_contents`, `file_size`, `created_by`, `updated_by`, `is_active` = 1.

**Discrepancy (Implemented):** the file input name is `sidefile`. The validation rule looks for `side_file`. A submit from this view fails validation unless something else renames the field. The controller does not set `target_url` or `expected_runtime`. `calculateExpectedRuntime` exists and is not called from `create`.

Duplicate-name check exists on `update`, not on `create`.

### WF-4 — Run one test

**Trigger:** dashboard run button.

**View sends:** `POST seleniumController/execute/{testId}` with a CSRF field in the form body. It does not send JSON, `test_id`, or `browser`.

**Controller `execute` requires:** an AJAX request, a JSON body with `test_id`, a browser of `firefox`, `chrome`, or `both`, and it reads `$test['test_file']`. The column is `file_contents`. There is no `test_file` field on the model. It then calls `executeTestSteps` and updates `$testLog['id']` with `status` `completed` and `end_time`. The log model allows `completed_at`, not `end_time`. `executeTestSteps` uses `$logId`, which is not defined in that method.

**Documented design** in `docs/selenium-controllers.md` shows `execute(logId)`, a running-status check, per-browser execution, and cancellation inside the step loop. The cancellation helper `isTestCancelled` exists and is not called from the step loop.

**Supported commands inside `executeWebDriverCommand`:** `open`, `click`, `type`, `select` (by visible text), `assert`, `verify`, `waitForElementPresent`, `waitForElementVisible`, `waitForElementNotVisible`. Anything else throws "Unsupported command".

**Discrepancy:** `assert` and `verify` call `handleAssertion`, which then switches on `assertText`, `verifyText`, `assertValue`, `verifyValue`, `assertElementPresent`, `verifyElementPresent`, `assertElementNotPresent`, `verifyElementNotPresent`. A real Selenium IDE command is usually `assertText`, which hits the outer default and is rejected. A command literally named `assert` hits the inner default ("Unsupported assertion") unless `target` starts with `title`, in which case the page title is compared to `value`.

Locators: prefixes `id=`, `name=`, `css=`, `xpath=`, `linkText=`. No prefix means CSS. A failed step stops the loop. Screenshots go to `writable/screenshots/` when the command is `assert`, `verify`, or `waitForElementPresent`, and also on failure.

Browser `both` is accepted by `execute` and then rejected by `initializeWebDriver`, which only starts Firefox or Chrome.

`startLog` on `SeleniumTestLog` refuses a second `running` row for the same test. `execute` does not call `startLog`.

### WF-5 — Run all tests

**Trigger:** "Run All Tests" on the dashboard.

**View sends:** `POST seleniumController/executeAll`.

**Implemented:** no `executeAll` method exists on `SeleniumController`. With `setAutoRoute(true)` this becomes a missing-method 404. Provenance: **Not implemented**.

### WF-6 — View or delete a log

**View calls:** `GET seleniumTestLogController/view/{id}` and `GET seleniumTestLogController/getResults/{id}`.

**Routes declare** those paths. **Controller methods** are `index`, `create`, `show`, `delete`. There is no `view` or `getResults`. Provenance: **Discrepancy** between the view, `Routes.php`, and the controller.

Delete is `DELETE seleniumTestLogController/delete/{id}`, which matches a real method. The model `delete` sets `is_active` to 0 unless purge is requested. The controller's delete path was not line-traced for purge. **Inferred:** deletes from the model default are soft.

### WF-7 — Schedule a test

**Routes:** `seleniumScheduleController` index, view, create, update, delete, `changeStatus`.

**Controller create** validates `test_id`, `browsers` (`firefox|chrome|both`), `frequency` (`once|daily|weekly|monthly`), `start_time`, `end_time`, `timezone`, `is_active`, and calls `SeleniumSchedule::hasConflict`.

**Model and migration** require `name`, `cron_expression`, `browsers`, `timezone`, `created_by`, and store `start_date` / `end_date`. There is no `frequency`, `start_time`, or `end_time` column, and no `hasConflict` method. `changeStatus` and `view` are not methods on the controller.

**Inferred:** creating a schedule through this controller fails model validation or inserts an incomplete row. Nothing in the tree launches a due schedule. `CronJobs` is a separate inherited runner (WF-9).

### WF-8 — Manage users and menus

**Implemented** routes under `users/*` and `menuManagement` for roles, users, and menu permissions. Menu rows drive the sidebar through helpers `getMenu` and `getSubMenu`. Developer screens: `menuManagement`, `crudGenerator`.

Registration route `Welcome::register` exists. Whether self-registration is enabled in the view was not confirmed beyond the route and filter exception. **Requires Confirmation.**

### WF-9 — Inherited cron HTTP endpoint

**Trigger:** `GET cronJobs`, excepted from login and from menu checks.

**Behaviour in `CronJobs::index`:** exit immediately on Saturday, Sunday, or when the hour is before 08:00 or from 23:00 onward. In `CI_ENVIRONMENT=production`, require query parameter `key` to match a hardcoded constant. Then load `cronjob_actions` and `cronjob_log`. Those tables have no migration in this repository. The class imports `LabelModel`, `OrdersModel`, `InvoicesModel`, and `FaultFinder`, which are not in `app/`.

**Inferred:** this endpoint is leftover from another Overdrive app and cannot run its job list here. The security constant is not copied into this PRD.

## Functional requirements

### FR-001 — Authenticated console

The system requires `session.isLoggedIn` before application routes, except the login, password-reset, 2FA, tracking-pixel, 404, organisations, and `cronJobs` paths listed in `app/Config/Filters.php`.

- Who: any visitor.
- Trigger: HTTP request.
- Failure: redirect to `/`.
- Implementation: **Implemented** in `Authentication`.
- Evidence: `app/Filters/Authentication.php`, `app/Config/Filters.php`.

### FR-002 — Menu grant

For a first URI segment that is not in the `isGranted` except list, a `user_menu` row must exist for that URL and the session role must have `user_access` for that menu. AJAX failures return JSON 404 ("Menu not found") or 403 ("Access denied"). Other requests redirect to `/` or `blocked`.

- Implementation: **Implemented**.
- Evidence: `app/Filters/Authorization.php`.
- Note: Selenium controller prefixes are excepted, so those JSON routes skip the menu check. They still require login.

### FR-003 — Store a Selenium IDE file

A signed-in user can store a `.side` or `.json` file (max 10240 KB) with a name of 3–255 characters. The file body is kept in `selenium_tests.file_contents`. Empty files and invalid side JSON are rejected.

- Implementation: **Implemented** on `SeleniumTestController::create`, with the field-name mismatch in WF-3.
- Evidence: `SeleniumTestController.php`, migration `create_selenium_tables`.

### FR-004 — List tests and logs

`SeleniumTestController::index` and `SeleniumTestLogController::index` return JSON for DataTables when the request is AJAX. Non-AJAX receives HTTP 400.

- Implementation: **Implemented**.
- Evidence: those controllers and `seleniumDashboard.php`.

### FR-005 — Execute a side file in Firefox or Chrome

The runner starts `geckodriver` or `chromedriver` on the first free port at or above 4444, opens a 1920×1080 window, and walks `tests[0].commands` of the side JSON. Page load timeout 30s, script timeout 30s, implicit wait 10s, explicit wait 10s. A failed step stops the run. Screenshots are PNG files under `writable/screenshots/`.

- Implementation: **Implemented** inside `SeleniumController`, and **not reachable as specified** from the dashboard (WF-4).
- Evidence: `SeleniumController.php`, `docs/selenium_setup.md`.

### FR-006 — One running instance per test

`SeleniumTestLog::startLog` throws if a row already has `status=running` and `is_active=1` for that `test_id`.

- Implementation: **Implemented** on the model. **Not called** by `SeleniumController::execute`.
- Evidence: `app/Models/SeleniumTestLog.php`.

### FR-007 — Schedule record

A schedule row stores a test id, name, five-field cron expression, browser enum, timezone, optional start and end datetimes, and active flag. Model callbacks reject a cron string that fails `validateCronExpression`.

- Implementation: **Implemented** on the model and migration.
- The HTTP create action does not supply those fields (WF-7). Provenance: **Discrepancy**.

### FR-008 — Same-URL schedule overlap

`SeleniumTest::canScheduleAt` treats two active schedules as conflicting when their tests share `target_url` and the datetime ranges overlap. A test with an empty `target_url` is treated as having no conflicts.

- Implementation: **Implemented** on the model. No controller was found that calls `canScheduleAt`. Upload does not set `target_url`.
- Evidence: `app/Models/SeleniumTest.php`.

### FR-009 — Two-factor email code

When `users.2fa_enabled` is on, login can require a code checked by `UserModel::confirm2faEmailConfirmationCode` before `isLoggedIn` is set. Codes are stored on the two-factor table from migration `AddTwoFactorAuthenticationTable`.

- Implementation: **Implemented** in `Welcome`. Mail delivery depends on environment SMTP, which is commented out in `env`.
- Evidence: `Welcome.php`, users migration, 2FA migration.

### FR-010 — Pipeline scan

The default Bitbucket pipeline runs a SonarQube scan (`sonarsource/sonarqube-scan:3.0.2`) and a detect-secrets scan. It does not run PHPUnit, migrations, or the Selenium browsers.

- Implementation: **Implemented**.
- Evidence: `bitbucket-pipelines.yml`, `sonar-project.properties`.

## Constraints

- PHP 8.3 and the extensions in `README.md`.
- Session driver in `env` is the database handler, cookie `ci_session`, `sessionMatchIP` true, regenerate-destroy true.
- CSRF filter is global. Selenium JSON posts are not in the CSRF except list. The dashboard adds a CSRF field on some calls.
- Auto-routing is on (`setAutoRoute(true)`), so public controller methods are reachable in addition to explicit routes.
- Screenshots are written with directory mode `0777`.
- `SeleniumController` step code references `$logId` without defining it. That is a defect in the current implementation, not a requirement to copy the bug forward.

## Integrations

| System | Direction | Auth | Failure |
|---|---|---|---|
| MySQL `uitesting` | App reads and writes | `env` database keys | CodeIgniter database errors. Not separately handled in the Selenium controllers beyond try/catch JSON 500 |
| geckodriver / chromedriver | App starts a local process | None. Binaries expected on `PATH` per the setup doc (`/usr/bin/geckodriver`, `/usr/bin/chromedriver`) | Exception logged; execute returns JSON 500 |
| Target site in the `.side` file | Browser navigates | Whatever the side file types into the page | Step status `failed`, screenshot attempted |
| SonarQube | Pipeline pushes analysis | `SONAR_TOKEN`, `SONAR_HOST_URL` | Pipeline step failure. Not handled in the app |
| detect-secrets | Pipeline writes JSON artifacts | None in-repo | Step uses `\|\| true` on the scan command, so a scan error does not fail the step |
| Mailgun | Defined, not in the default pipeline | `MAILGUN_API_KEY` | Not on the running pipeline |
| SMTP | Password reset and 2FA | Commented `SMTP_*` in `env` | **Unknown** if mail is sent |

## Important constraints that are not product decisions

The following are behaviours in code. They are not confirmed business rules:

- Cron HTTP endpoint refuses to run on weekends and outside 08:00–22:59. It does not run Selenium.
- Role names include Company Admin and Company User, with no company table migration in this repository. `CompanyModel` points at `companies`.
- Reports views still mention invoices and orders. No invoice or order migration is in this repository.

## Assumptions (explicitly not facts)

- That an operator is an Overdrive developer, because the seeded role is Developer and the seeded mailbox is `@overdrive.co.za`. **Inferred.**
- That `https://uitesting.php8` is the intended base URL, because that string is in `env`. **Documented** as a template value. Liveness is **Unknown**.
