# Application Test Report

**Date:** 18 August 2026
**Tested against:** the running site at `http://localhost:8000` (PHP dev server, MySQL `client_admin_portal_v1`)
**Result:** **260 automated checks, 260 passed, 0 failed**, plus manual probes of the live downloaders.
**Updated:** 20 August 2026 — search results moved to their own page (`/viewer`), which loads and fetches
the query itself; 27 further checks added.

Every check was made over HTTP — the same way a person or a crawler reaches the site — rather than by
calling classes directly, so what is recorded below is what the application actually serves.

A snapshot (`pre_full_review`) was taken before testing. Afterwards every table was compared against it:
**row counts and key values are identical**, and `public/` holds only its original five entries. Nothing
this review created was left behind.

---

## Summary by area

| Area | Checks | Result |
|---|---:|---|
| Authentication & access control | 14 | pass |
| Admin screens render | 20 | pass |
| Content CRUD (pages, footer pages, blogs, notes, redirects, languages, messages) | 45 | pass |
| Settings screens (10 single-row screens) | 20 | pass |
| Per-language settings | 6 | pass |
| Chrome extension builder | 6 | pass |
| Indexing & SEO | 53 | pass |
| Public site | 12 | pass |
| Blog (public) | 10 | pass |
| Language switching | 5 | pass |
| Downloader endpoints | 13 | pass |
| Search results page (`/viewer`) | 32 | pass |
| Security | 22 | pass |

Also run: **PHP lint across every file in the project** — no syntax errors anywhere.

---

## What was tested, page by page

### Sign in — `/login`

- Page renders and carries a CSRF token; marked `noindex, nofollow`.
- A wrong password is refused; an unknown email address is refused; both are written to the log.
- A POST with no CSRF token is refused.
- Correct credentials sign in and land on the dashboard.

### Access control — every `/admin/*` URL

- All 16 sampled admin URLs redirect a signed-out visitor to the login.
- A signed-in POST carrying a stale token is refused **and nothing is written to the database**.
- An unknown admin URL 404s for a signed-in admin, but asks a signed-out visitor to sign in — so the
  panel never reveals which admin URLs exist.
- The whole panel is `noindex, nofollow`.

### Admin screens — all 31

Dashboard · Languages · Pages (list, create, edit, duplicate) · Footer Pages (list, create, edit) ·
Blogs (list, create, edit) · Notes (list, create, edit) · Menu · Redirects (list, create) · Messages ·
Verification Files · History ×3 (Instagram, YouTube, TikTok) · Bottom Footer · Settings ×11 (General,
Language Switcher, Header, Banner, Blog Page, Custom Scripts, Custom CSS, SEO, Sitemap, Robots, Ads,
Chrome Extension).

- Each returns 200 and renders its own content — no blank screens, no PHP errors, no "Server Error".
- Every record-driven edit screen opens.
- An edit screen for a record that no longer exists redirects with a message instead of crashing.
- The sidebar links to all twelve content and settings areas.

### Pages — `/admin/pages`

- Create → the page is stored, answers publicly, and its banner drives the chosen downloader
  (`data-downloader="youtube"` was verified on the created page).
- Edit → the change is stored and served.
- **Validation:** a duplicate slug, a malformed slug (`Not A Slug!`), and a page with no feature
  description are each refused with the correct field message.
- **Published = No** → the page 404s for visitors but is still listed and filterable in the panel.
- Delete → gone from the database; the public URL 404s.

### Footer Pages — `/admin/footer-pages`

- Create → answers publicly and appears in the site footer.
- "Add contact form" → the form renders on the public page.
- **Published = No** → 404s publicly *and* drops out of the footer link row.
- Delete → removed.

### Blogs — `/admin/blogs`

- Create → article answers at `/blog/{slug}` and appears on the index.
- The **Excerpt** field is what the card shows.
- **Featured** → the article becomes the hero, exactly one article stays featured per language, and the
  hero is **not** repeated in the grid below (checked by counting its links: exactly one).
- **Published = No** → the article 404s.
- Delete → removed.

### Notes — `/admin/notes`

- Create → stored and rendered under the banner on the public home page.
- Delete → removed.

### Redirects — `/admin/menu-redirects`

- Create → the old path returns **301** to the chosen destination.
- Delete → the redirect stops applying.

### Languages — `/admin/languages`

- Create → stored; a duplicate language attribute is refused.
- Delete → removed.

### Messages — `/admin/messages`

- The **public contact form** accepts a message and it reaches the panel.
- The **honeypot** works: a submission with the hidden field filled is silently discarded.
- Mark as read → stored. Delete → removed.

### Settings — the ten single-row screens

General · Header · Banner · Language Switcher · SEO · Sitemap · Robots · Ads · Custom CSS · Custom Scripts

For each: the screen was read, posted back **unchanged**, and every column compared before and after.

- Each screen saves without complaint.
- **No screen loses a field when re-saved** — the failure these tables are most prone to, since they
  write every column on every save.

### Bottom Footer — `/admin/bottom-footer`

- Text saved for two different languages is kept apart; each language keeps its own.
- Visibility and colours are shared across languages, as designed.

### Blog Page — `/admin/settings/blog-page`

- Details saved per language; `/blog` shows the English heading and `/ar/blog` the Arabic one, with no
  leakage between them.

### Chrome Extension — `/admin/settings/chrome-extension`

- Details save; the download returns a real ZIP (`PK` signature), `Content-Type: application/zip`, and a
  filename built from the site name.
- The archive was separately unpacked with Windows' own Expand-Archive: valid Manifest V3 JSON, correct
  UTF-8, all six files present, and `icon.png` byte-identical to the uploaded Site Logo.

### Verification Files — `/admin/verification-files`

- An allowed file uploads and is then **served from the site root**.
- Refused, with nothing written: a `.php` file, a `../../.env` traversal filename, a `.htaccess` name, an
  oversized file, and attempts to shadow `robots.txt`, `sitemap.xml` and `index.php`.
- Delete works; `index.php` and `server.php` survive traversal and direct delete attempts.
- After the suite, `public/` holds only `.htaccess`, `assets`, `index.php`, `server.php`, `uploads`.

### Public site

- Every published page in both languages is served (6 pages).
- Header renders, footer renders, banner search box renders, stylesheet is cache-busted (`?v=…`).
- **No broken links**: every header menu link and every footer link was followed and returned < 400.

### Blog (public) — `/blog`, `/blog/{slug}`

- Index served; the grid shows every published article **except** the hero.
- All six articles (3 English, 3 Arabic) are reachable at their own URLs.
- An unknown article 404s; a draft article 404s.

### Search results — `/viewer`

Added after the first review: a search no longer opens under the banner, it opens on its own page, and
that page shows the results and nothing else — no banner of its own. 32 automated checks over HTTP plus
36 checks that run the **shipped** search script against DOM stubs.

**The flow.** A search from a downloader page leaves for `/viewer` the moment it is submitted — no waiting
on the page that was typed into. The results page carries the query in its address, prints the loading card
with the page itself (so the spinner is on screen before a line of JavaScript runs), fetches through the
same endpoint the search has always used, and replaces the card with the results. When the service has
nothing to give, the same card says so. A `/viewer` address reached any other way — a shared link, a
refresh, the address typed in — behaves identically, which is what makes the page shareable.

Over HTTP:

- A downloader page's form points at `/viewer` (`action` and `data-viewer-path`), so the search still
  works with JavaScript off, and the results markup is **no longer** on the downloader page.
- `/viewer?q=…&type=…` is served, holds the grid and the preview lightbox, and names the service it was
  given.
- **No banner band on the page.** The search box is there but out of sight — it is what the script reads
  the service, the address and the query from — so the line that would sit under it is left out and the
  state card carries the message instead.
- The card ships with both icons (spinner and ghost) and a "Try Another Search" button back to the
  downloader. With a query it is printed already saying "Fetching your media", spinner showing; with no
  query it says nothing has been searched for yet and the spinner stays off.
- "Download Another" leads back to the downloader the search came from, since there is no field on show
  to type the next link into.
- **Viewer Page Ads** (Settings > Ads, below Banner Ads) print at the top of the page, above the results,
  and disappear with Enable Ads set to No. The ads row was restored byte-for-byte afterwards.
- `/ar/viewer` is served and keeps its prefix for the next search; `/en/viewer` 301s to `/viewer`,
  query string intact.
- The page is `noindex, nofollow` and is absent from `sitemap.xml` — one visitor's search is not a page
  of the site.

In the browser (DOM stubs against the real script):

| Behaviour | Result |
|---|---|
| Search from a downloader page | opens `/viewer?q=…&type=…` at once, fetching nothing and drawing nothing itself |
| Non-default language | opens `/ar/viewer?…` |
| `/viewer` on load | card up with the spinner and "Fetching your media", then the results replace it |
| Nobody found | card stays up with the ghost, "User Not Found", the service's own sentence, and the button |
| Any other failure | same card, headed "Something Went Wrong" |
| `/viewer` with no query | fetches nothing, leaves the card as the page printed it |
| Second search on `/viewer` | pushes the new address, re-runs the search, does not reload the page |
| Browser **Back** | restores the earlier query and its results |
| "Download Another" | returns to the downloader page |
| YouTube keyword search | still goes to `/api/youtube/search`, still draws its list |

The search API, its parameters, the answers it gives and the renderers that draw them are unchanged —
the results markup moved into a shared partial, the fetch moved into a named function the results page can
call on load, and the choice of renderer moved into another.

### Language switching

- Each visible language declares its own `lang` and `dir` on the `<html>` tag.
- A language with "display on UI" off does not appear in the switcher.

### Downloaders — `/api/*`

Endpoint behaviour (13 automated checks): every endpoint answers with JSON, none returns a 5xx, a missing
query is handled rather than crashing, and the media relay **refuses a host that is not on the allowlist**.

Live probes against the real services:

| Probe | Result |
|---|---|
| YouTube by URL | 200 — 2 items in 1.5s |
| YouTube keyword search | 200 — results in 6.8s |
| YouTube suggestions | 200 — 1.7s |
| Instagram profile | 200 — posts + stories in 6.8s |
| TikTok profile | 200 — 1 item in 5.5s |
| **Full file download through the relay** | 200 — 41,017 bytes in 2.0s, a valid JPEG, correct `Content-Disposition` filename |
| Download history | written correctly: `tiktok / download / download_completed` |

---

## Indexing & SEO — 53 checks

This was tested with the site-wide switch in **both** positions, then restored to how it was.

**Settings › General › Search Engine Visibility**

- Discouraged → every page is `noindex, nofollow`, the `X-Robots-Tag` header says the same, and no
  canonical is advertised.
- Allowed → pages are `index, follow` with no forced header.

**Per-page metadata** (with indexing allowed)

- Canonical link, `og:title`, `og:url`, Twitter card, meta description, and JSON-LD structured data that
  **parses as valid JSON**.

**A page's own Allow Search Indexing**

- Set to No → the page becomes `noindex` *and* drops out of `sitemap.xml`.
- Set back → both return.

**Published / draft**

- A draft page drops out of `sitemap.xml` and 404s for visitors; publishing restores both.

**sitemap.xml**

- Served as XML and **well formed** (parsed with an XML parser).
- Every published, indexable page appears — counted against the database, exactly matching.
- Both footer pages appear.
- `/admin` and `/login` never appear.

**robots.txt**

- Served as `text/plain`, names a user-agent, points at the sitemap, and disallows `/admin`.

**Canonical URLs**

- `/en` → **301** to `/`; `/en/blog` → **301** to `/blog`; the query string (`?page=2`) survives the redirect.
- A non-default language keeps its prefix and serves 200.

**hreflang**

- A page that exists in more than one language emits multiple `rel="alternate"` links including `x-default`.

**Blog SEO**

- Index has its own title, meta description and canonical.
- An article uses its own meta title and meta description, and carries a canonical.

**404**

- Unknown URLs return a real **404** status, are `noindex`, and keep the site header and footer.

---

## Security — 22 checks

- **Uploads into the web root** — allowlist holds: `.php` refused, traversal refused, dotfile names
  refused, oversized refused, `robots.txt`/`sitemap.xml`/`index.php` protected.
- **Image uploads** — a PHP file renamed `.png` is refused (MIME is detected, not trusted from the name),
  and no `.php` file exists anywhere under `public/uploads/`.
- **Escaping** — a `<script>` tag saved in a note title is escaped in both the panel and the public site.
- **Files outside the web root** — `/.env`, `/app/Core/DB.php`, `/config/app.php`, `/storage/logs/app.log`,
  `/database/schema.sql` and `/bootstrap.php` are all unreachable over HTTP.
- **Account provisioning API** — `/api/setup-users` 404s without the key and with a wrong key.
- **CSRF** — enforced on login and on admin writes.

---

## Findings

No bugs were found in the application. Three things are worth your attention, none of them defects:

1. **No social share image is configured.** Neither the pages nor Settings › SEO › OpenGraph Image has an
   image, so `og:image` is omitted and links shared to WhatsApp, Facebook or X preview without artwork.
   The mechanism itself works — setting an image was verified to make the tag appear immediately.
   *Fix: upload an OpenGraph Image on the SEO screen, or a Featured Image per page.*

2. **Search Engine Visibility is currently set to "discourage".** Every page therefore goes out
   `noindex, nofollow` and Google will not index the site. That is correct for a site still in
   development — remember to turn it off before launch.

3. **Site Name reads "Site Name"** on Settings › SEO, which looks like placeholder text saved by
   accident. It feeds the browser title, the Chrome extension's download filename and the popup button
   label.

Two operational notes carried over from earlier work:

- **Verification files are untracked by git**, so a deploy that rebuilds `public/` will not carry them.
  Commit them or re-upload after each deploy.
- **The document root must point at `public/`.** The Verification Files screen now checks this on
  whatever server it runs on and warns if it is wrong, since a misconfigured root silently breaks
  verification without breaking anything visible.

---

## How to run these tests again

The suite is committed with the project, in `tests/`:

```
php tests/run.php              # everything
php tests/run.php seo security # one or more suites
```

Run it from the project root with the site running.

The search flow in the browser is checked separately, by running the shipped script against DOM stubs:

```
node tests/viewer_flow_test.js resources/views/layouts/client.php
```

The editor's links are checked the same way — which tab a link opens in is decided by the admin
script, not by Quill's own format:

```
node tests/editor_link_test.js public/assets/js/app.js
```

Suites: `auth`, `screens`, `crud`, `settings`, `seo`, `client`, `security`. Each cleans up after itself
and restores anything it changes. Take a snapshot first regardless:

```
php database/snapshot.php save before_testing
```
