diff --git a/AGENT.md b/AGENT.md index bc6dbbd..5e9d53b 100644 --- a/AGENT.md +++ b/AGENT.md @@ -1,6 +1,6 @@ # Stopp lidelsen - Site built on FolderWeb -Advocacy site for medical cannabis patients in Norway. Built on FolderWeb (minimal file-based CMS). +Advocacy site for medical cannabis patients in Norway. Built on FolderWeb (minimal file-based CMS). Decade-scale maintainability, no volatile dependencies, only essential code. ## Edit instructions All edits to this AGENT.md file should be done in a compressed shortform for LLM consumption. @@ -9,24 +9,21 @@ All edits to this AGENT.md file should be done in a compressed shortform for LLM - `app/` is a **symlink** to the FolderWeb framework (`folderweb/app`). **NEVER modify files in `app/`.** - Only modify files in `custom/`, `content/`, and project root. -- Framework docs: read `app/docs/` when needed for understanding routing, hooks, templates, etc. -- Site-specific docs: read `docs/` in this repo for guidance on this site's features and customizations. +- Framework docs: `../folderweb/docs/04-development/` (architecture, context API, hooks/plugins, rendering, templates, etc.) +- Site-specific docs: `docs/` in this repo. -## Philosophy -Minimal PHP for modern conveniences. Decade-scale maintainability. No volatile dependencies. Only essential code. +## Standards -## Core Constraints -**Stack:** HTML5, PHP 8.4+, CSS. no frameworks, no build tools, no package managers. +**Stack:** HTML5, PHP 8.4+, CSS. No frameworks, no build tools, no package managers. -**Frontend:** Classless semantic HTML5. Modern CSS (nesting, `oklch()`, grid, `clamp()`, logical props). Responsive via fluid typography + flexible layouts. Comments only for major sections. +**PHP:** Arrow functions, null coalescing, match. Type hints where practical. Single-purpose functions, avoid side effects. + +**CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units. `oklch()`, `light-dark()`, logical props. Margin-top only (reset: `* { margin-bottom: 0 }`). + +**HTML/Templates:** Classless semantic HTML5. `` for UGC. `` for pre-rendered HTML. **Security:** Path traversal protection, document root restriction, strict MIME types, escape all UGC, CSV injection prevention. -## Code Style -**PHP:** Arrow functions, null coalescing, match. Type hints where practical. Single-purpose functions. -**CSS:** Variables, native nesting, grid. `clamp()` over `@media`. Relative units. Margin-top only (reset: `* { margin-bottom: 0 }`). -**Templates:** `` for UGC. `` for pre-rendered HTML. - ## Project Structure ``` @@ -43,35 +40,30 @@ app/ -> symlink # FolderWeb framework (DO NOT EDIT) docs/ # Site-specific LLM documentation ``` -## Key Features - -**Content:** Folders = URLs. `metadata.ini` controls titles, slugs, menu order, templates, feeds. -**Languages:** Norwegian (default), English available. Language plugin via `[en]` sections in metadata. -**Templates:** base.php wraps all pages. List templates: list.php, list-card-grid.php, list-faq.php, list-grid.php. Selected via `page_template` in metadata. -**Atom feeds:** Enabled per-section via `feed = true` in metadata. URL: `/{section}/feed.xml`. Feed link auto-added to `` when `$feedUrl` is set. -**Petition system:** GDPR-compliant, CSV-based, double opt-in email confirmation, file locking, rate limiting. See `docs/petition-system.md`. -**Newsletter:** Listmonk integration via public API. Hero and small themes. See `docs/newsletter-plugin.md`. +Content: folders = URLs, numbered file prefixes control order, `cover.jpg` for list item images, `metadata.ini` controls titles/slugs/menu order/templates/feeds. ## Knowledge Base -Read these docs on-demand when working on related areas: +Read on-demand: -| Topic | File | Read When | -|---|---|---| -| Templates & variables | `docs/templates.md` | Modifying templates, understanding available variables | -| Content & metadata | `docs/content-system.md` | Adding/modifying content, metadata fields, feeds | -| Petition system | `docs/petition-system.md` | Modifying petition form, CSV format, email flow | -| Newsletter plugin | `docs/newsletter-plugin.md` | Modifying newsletter signup, Listmonk integration | -| Framework internals | `app/docs/` | Understanding routing, hooks, plugins, rendering pipeline | +| Topic | File | +|---|---| +| Templates & variables | `docs/templates.md` | +| Content & metadata | `docs/content-system.md` | +| Petition system | `docs/petition-system.md` | +| Newsletter plugin | `docs/newsletter-plugin.md` | +| Framework internals | `../folderweb/docs/04-development/` | +| Test suite | `tests/README.md` | **Required before modifying `custom/` or `content/`** — workflow, how to run, how to write tests | + +Site features: Norwegian (default) + English via `[en]` metadata sections. Atom feeds via `feed = true` in metadata. Petition: GDPR, CSV, double opt-in, rate limiting. Newsletter: Listmonk API, hero/small themes. ## Development Environment Host has no PHP. Always use `podman compose` with `compose.yaml`. **Dev server:** `localhost:4040` -**Testing:** `curl localhost:4040/path` to fetch pages; `curl -X POST -d "field=value" localhost:4040/path` for forms -**Container isolation:** ALL test data (CSV, temp files) MUST stay inside container filesystem, never in mounted volumes -**Test data paths:** Use `/tmp/` or `/var/test/` inside container -**Running tests:** `podman exec stopplidelsen.no php /path/to/test.php` or `podman-compose run --rm custom php /tmp/test-script.php` +**Verification:** `curl localhost:4040/path`; `curl -X POST -d "field=value" localhost:4040/path` for forms +**Container isolation:** ALL test data MUST stay inside container filesystem, never in mounted volumes. Use `/tmp/` or `/var/test/`. +**Running scripts:** `podman exec stopplidelsen.no php /path/to/test.php` **Syntax checks:** `podman exec stopplidelsen.no php -l /var/www/custom/file.php` **Cleanup:** `podman-compose down && podman-compose up -d` between test runs diff --git a/Containerfile.test b/Containerfile.test new file mode 100644 index 0000000..bd86862 --- /dev/null +++ b/Containerfile.test @@ -0,0 +1,32 @@ +FROM php:8.4-cli-bookworm + +# System dependencies + PHP sockets extension (required by pest-plugin-browser) +RUN apt-get update && apt-get install -y \ + git unzip curl \ + && rm -rf /var/lib/apt/lists/* \ + && docker-php-ext-install sockets pcntl + +# Node.js 22 LTS (required by pest-plugin-browser / Playwright) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && rm -rf /var/lib/apt/lists/* + +# Composer +RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/local/bin --filename=composer + +# WORKDIR matches where composer.json lives — Pest finds vendor/autoload.php here +# Tests are mounted at /app/tests via compose.test.yaml +WORKDIR /app + +# Install PHP test dependencies +COPY tests/composer.json composer.json +RUN composer install --no-interaction --no-progress + +# Install Playwright npm package (skip post-install browser download — we do it explicitly below) +RUN PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 npm install playwright + +# Install Playwright browsers (Chromium) with OS-level dependencies +ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright +RUN ./node_modules/.bin/playwright install --with-deps chromium + +ENTRYPOINT ["/app/vendor/bin/pest"] diff --git a/compose.test.yaml b/compose.test.yaml new file mode 100644 index 0000000..ff6d176 --- /dev/null +++ b/compose.test.yaml @@ -0,0 +1,24 @@ +services: + app: + build: . + container_name: stopplidelsen.test.app + working_dir: /var/www/html/ + volumes: + - ./app:/var/www/app:z + - ./content:/var/www/html:z + - ./custom:/var/www/custom:z + command: > + bash -c "chown -R www-data:www-data /var/www/custom/data /var/www/custom/assets && apache2-foreground" + + test: + build: + context: . + dockerfile: Containerfile.test + container_name: stopplidelsen.test.runner + volumes: + - ./tests:/app/tests:z + - ./custom:/app/custom:z + depends_on: + - app + environment: + APP_URL: http://app.dns.podman diff --git a/tests/.gitignore b/tests/.gitignore new file mode 100644 index 0000000..d1502b0 --- /dev/null +++ b/tests/.gitignore @@ -0,0 +1,2 @@ +vendor/ +composer.lock diff --git a/tests/Browser/ContentTest.php b/tests/Browser/ContentTest.php new file mode 100644 index 0000000..592949d --- /dev/null +++ b/tests/Browser/ContentTest.php @@ -0,0 +1,46 @@ +visit(BASE_URL . '/om-oss') + ->assertSee('Vår historie'); +}); + +it('about page mentions the organization name', function () { + $this->visit(BASE_URL . '/om-oss') + ->assertSourceHas('Stopp lidelsen'); +}); + +it('privacy page loads with correct heading', function () { + $this->visit(BASE_URL . '/personvern') + ->assertSee('Personvernerklæring'); +}); + +it('contact page loads with content', function () { + $this->visit(BASE_URL . '/kontakt') + ->assertSourceHas('visit(BASE_URL . '/brosjyrer') + ->assertSee('Brosjyrer'); +}); + +it('articles section loads', function () { + $this->visit(BASE_URL . '/artikler') + ->assertSourceHas('visit(BASE_URL . '/artikler/pasientinfo') + ->assertSee('pasienter'); +}); + +it('homepage shows goals section heading', function () { + $this->visit(BASE_URL . '/') + ->assertSee('oppnå'); +}); + +it('homepage shows intro text about the organization', function () { + $this->visit(BASE_URL . '/') + ->assertSee('frivillig'); +}); diff --git a/tests/Browser/FaqTest.php b/tests/Browser/FaqTest.php new file mode 100644 index 0000000..9639511 --- /dev/null +++ b/tests/Browser/FaqTest.php @@ -0,0 +1,26 @@ +visit(BASE_URL . '/faq') + ->assertSee('Ofte stilte spørsmål'); +}); + +it('FAQ listing shows question items', function () { + $this->visit(BASE_URL . '/faq') + ->assertSee('medisinsk cannabis'); +}); + +it('individual FAQ article loads', function () { + $this->visit(BASE_URL . '/faq/Hva-er-MC') + ->assertSourceHas('visit(BASE_URL . '/faq/Hva-er-MC') + ->assertSee('medisinsk cannabis'); +}); + +it('FAQ article about driving license loads', function () { + $this->visit(BASE_URL . '/faq/forerkort') + ->assertSourceHas('visit(BASE_URL . '/') + ->assertSourceHas('Stopp lidelsen'); +}); + +it('shows the news section', function () { + $this->visit(BASE_URL . '/') + ->assertSee('Nyheter'); +}); + +it('shows the petition section', function () { + $this->visit(BASE_URL . '/') + ->assertSee('underskrift'); +}); + +it('shows the newsletter section', function () { + $this->visit(BASE_URL . '/') + ->assertSee('nyhetsbrev'); +}); diff --git a/tests/Browser/LanguageTest.php b/tests/Browser/LanguageTest.php new file mode 100644 index 0000000..8a4d058 --- /dev/null +++ b/tests/Browser/LanguageTest.php @@ -0,0 +1,11 @@ +visit(BASE_URL . '/') + ->assertSourceHas('lang="no"'); +}); + +it('english news section is accessible', function () { + $this->visit(BASE_URL . '/en/nyheter') + ->assertSourceHas('visit(BASE_URL . '/nyheter') + ->assertSee('Nyheter'); +}); + +it('navigates to the FAQ section', function () { + $this->visit(BASE_URL . '/faq') + ->assertSourceHas('visit(BASE_URL . '/om-oss') + ->assertSourceHas('visit(BASE_URL . '/underskriftskampanje') + ->assertSourceHas('visit(BASE_URL . '/kontakt') + ->assertSourceHas('visit(BASE_URL . '/personvern') + ->assertSourceHas('visit(BASE_URL . '/nyheter') + ->assertSourceHas('visit(BASE_URL . '/nyheter') + ->assertSee('Banebrytende'); +}); + +it('individual news article loads', function () { + $this->visit(BASE_URL . '/nyheter/2025-09-26-banebrytende-studie') + ->assertSourceHas('visit(BASE_URL . '/nyheter/2025-09-26-banebrytende-studie') + ->assertSee('medisinsk cannabis'); +}); + +it('news article about podcast loads', function () { + $this->visit(BASE_URL . '/nyheter/2026-03-08-podcast-paradigmepodden') + ->assertSourceHas('visit(BASE_URL . '/nyheter/finnes-ikke') + ->assertSourceHas('404'); +}); diff --git a/tests/Browser/PetitionTest.php b/tests/Browser/PetitionTest.php new file mode 100644 index 0000000..a55d57d --- /dev/null +++ b/tests/Browser/PetitionTest.php @@ -0,0 +1,19 @@ +visit(BASE_URL . '/underskriftskampanje/medisinsk-cannabis-pa-resept/') + ->assertPresent('input[name="firstname"]'); +}); + +it('shows required form fields', function () { + $this->visit(BASE_URL . '/underskriftskampanje/medisinsk-cannabis-pa-resept/') + ->assertPresent('input[name="firstname"]') + ->assertPresent('input[name="email"]') + ->assertPresent('button[type="submit"]'); +}); + +it('does not submit empty form', function () { + $this->visit(BASE_URL . '/underskriftskampanje/medisinsk-cannabis-pa-resept/') + ->press('button[type="submit"]') + ->assertPresent('input[name="firstname"]'); +}); diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..b23bcd4 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,10 @@ +group('unit')->in('Unit'); diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..aea1d04 --- /dev/null +++ b/tests/README.md @@ -0,0 +1,98 @@ +# Test Suite + +Browser tests using [Pest](https://pestphp.com/) + [pest-plugin-browser](https://github.com/pestphp/pest-plugin-browser) (Playwright/Chromium). + +## Run tests + +```bash +podman compose -f compose.test.yaml run --rm test +``` + +The `app` service (the site) starts automatically via `depends_on`. Tests run against `http://app.dns.podman` inside the Podman network. + +## Structure + +``` +tests/ + composer.json # pestphp/pest ^4, pestphp/pest-plugin-browser ^4 + Pest.php # defines BASE_URL and CUSTOM_DIR constants; groups unit tests + Browser/ # browser tests (require running app + Playwright) + HomepageTest.php + NavigationTest.php + LanguageTest.php + PetitionTest.php + Screenshots/ # auto-saved on failure (gitignored) + Unit/ # unit tests (pure PHP, no browser needed) + PetitionFormTest.php +``` + +## Writing unit tests + +Place tests in `tests/Unit/`. The constant `CUSTOM_DIR` points to `custom/` on the host. + +Since `custom/` plugins are written as framework plugins (they call `Hooks::add()` and reference `Context`), stub those classes before requiring the file: + +```php +toBe('expected'); +}); +``` + +Only test functions from `custom/` — `app/` has its own test suite. + +## Writing browser tests + +All browser tests use `$this` for the Playwright page object via pest-plugin-browser. + +```php +it('describes behavior', function () { + $this->visit(BASE_URL . '/path') + ->assertSee('text') // visible text + ->assertSourceHas('html snippet') // raw HTML/source + ->assertPresent('css selector') // element exists in DOM + ->click('css selector') + ->press('input[type="submit"]') // click button/submit + ->assertSee('expected result'); +}); +``` + +Place new test files in `tests/Browser/`. No registration needed — Pest autodiscovers `*Test.php` files. + +**Reference:** [pestphp.com/docs/browser-testing](https://pestphp.com/docs/browser-testing) — full list of interactions (`type()`, `select()`, `check()`, `drag()`, …) and assertions (`assertUrlIs()`, `assertAttribute()`, `assertNoJavaScriptErrors()`, …). + +## Infrastructure + +- **Containerfile.test**: `php:8.4-cli-bookworm` + Node 22 + Composer + Playwright Chromium at `/opt/playwright` +- **compose.test.yaml**: `app` service = the site; `test` service = runner; mounts `./tests` as volume so edits apply without rebuild +- **Pest.php**: `BASE_URL` defaults to `http://app` if `APP_URL` not set + +## Rebuild image + +Only needed after changing `Containerfile.test` or `composer.json`: + +```bash +podman compose -f compose.test.yaml build test +``` + +## Agent workflow + +When modifying or adding code in `custom/` or `content/`: + +1. Run the existing test suite first to establish a baseline. +2. Make your changes. +3. Run tests again. All previously passing tests must still pass. +4. If your change adds new behavior, add a test for it in `tests/Browser/`. +5. Do not mark work done until the test suite passes (excluding pre-existing known failures listed below). + +Failed tests save screenshots to `tests/Browser/Screenshots/` — read them to diagnose rendering issues. + +## Known failures + +None. diff --git a/tests/Unit/NewsletterSignupTest.php b/tests/Unit/NewsletterSignupTest.php new file mode 100644 index 0000000..e3cc0b2 --- /dev/null +++ b/tests/Unit/NewsletterSignupTest.php @@ -0,0 +1,105 @@ +toBe('Navn'); + expect(newsletterT('submit_button'))->toBe('Meld deg på'); + expect(newsletterT('success_message'))->toBe('Sjekk innboksen din!'); +}); + +it('returns the key itself for an unknown key when no context is present', function () { + unset($GLOBALS['ctx']); + expect(newsletterT('nonexistent_key'))->toBe('nonexistent_key'); +}); + +it('returns a translation from context when available', function () { + $GLOBALS['ctx'] = new class { + public function get(string $key, mixed $default = null): mixed + { + return $key === 'translations' + ? ['newsletter.name_label' => 'Name'] + : $default; + } + }; + expect(newsletterT('name_label'))->toBe('Name'); + unset($GLOBALS['ctx']); +}); + +it('falls back to the key when context has no matching translation', function () { + $GLOBALS['ctx'] = new class { + public function get(string $key, mixed $default = null): mixed + { + return $key === 'translations' ? [] : $default; + } + }; + expect(newsletterT('name_label'))->toBe('name_label'); + unset($GLOBALS['ctx']); +}); + +// --- newsletterCheckRateLimit --- + +it('returns true when no previous submission is recorded in the session', function () { + unset($_SESSION['newsletter_last_submit']); + expect(newsletterCheckRateLimit())->toBeTrue(); +}); + +it('returns false immediately after a submission', function () { + $_SESSION['newsletter_last_submit'] = time(); + expect(newsletterCheckRateLimit())->toBeFalse(); +}); + +it('returns false when 29 seconds have elapsed', function () { + $_SESSION['newsletter_last_submit'] = time() - 29; + expect(newsletterCheckRateLimit())->toBeFalse(); +}); + +it('returns true when exactly 30 seconds have elapsed', function () { + $_SESSION['newsletter_last_submit'] = time() - 30; + expect(newsletterCheckRateLimit())->toBeTrue(); +}); + +it('returns true when more than 30 seconds have elapsed', function () { + $_SESSION['newsletter_last_submit'] = time() - 60; + expect(newsletterCheckRateLimit())->toBeTrue(); +}); + +// --- newsletterGetTranslations --- + +it('returns all expected translation keys', function () { + unset($GLOBALS['ctx']); + expect(newsletterGetTranslations())->toHaveKeys([ + 'nameLabel', 'namePlaceholder', 'emailLabel', 'emailPlaceholder', + 'notice', 'submitButton', 'successMessage', 'successConfirm', 'errorMessage', + ]); +}); + +it('HTML-escapes special characters in translation values', function () { + $GLOBALS['ctx'] = new class { + public function get(string $key, mixed $default = null): mixed + { + return $key === 'translations' + ? ['newsletter.name_label' => 'Name & "Label"'] + : $default; + } + }; + $translations = newsletterGetTranslations(); + expect($translations['nameLabel'])->toBe('<b>Name & "Label"</b>'); + unset($GLOBALS['ctx']); +}); + +it('returns string values for all keys', function () { + unset($GLOBALS['ctx']); + foreach (newsletterGetTranslations() as $key => $value) { + expect($value)->toBeString("Key '{$key}' should be a string"); + } +}); diff --git a/tests/Unit/PetitionCliTest.php b/tests/Unit/PetitionCliTest.php new file mode 100644 index 0000000..f1aaa57 --- /dev/null +++ b/tests/Unit/PetitionCliTest.php @@ -0,0 +1,113 @@ +toBeFalse(); +}); + +it('returns true when the exact key is present', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 1700000000, + 'type' => 'failed', + 'petition_id' => 'my-petition', + 'email' => 'user@example.com', + 'reason' => 'bounced', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'my-petition', 'user@example.com'))->toBeTrue(); +}); + +it('matches the email case-insensitively', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'my-petition', 'User@EXAMPLE.COM'))->toBeTrue(); +}); + +it('returns false when the type does not match', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'unconfirmed', 'my-petition', 'user@example.com'))->toBeFalse(); +}); + +it('returns false when the petition_id does not match', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'other-petition', 'user@example.com'))->toBeFalse(); +}); + +it('returns false when the email does not match', function () { + $ignoreList = [ + 'failed|my-petition|user@example.com' => [ + 'timestamp' => 0, 'type' => 'failed', 'petition_id' => 'my-petition', + 'email' => 'user@example.com', 'reason' => '', + ], + ]; + expect(isIgnored($ignoreList, 'failed', 'my-petition', 'other@example.com'))->toBeFalse(); +}); + +// --- formatDate --- + +it('formats a unix timestamp as Y-m-d H:i', function () { + $result = formatDate(0); + expect($result)->toMatch('/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}$/'); +}); + +it('produces the expected date string for a known timestamp', function () { + // 2024-03-15 10:05:00 UTC — pin to UTC so test is locale-independent + $original = date_default_timezone_get(); + date_default_timezone_set('UTC'); + $ts = mktime(10, 5, 0, 3, 15, 2024); + expect(formatDate($ts))->toBe('2024-03-15 10:05'); + date_default_timezone_set($original); +}); + +// --- buildConfirmationEmail --- + +it('returns an array with subject and body keys', function () { + $sig = ['petition_id' => 'test', 'firstname' => 'Kari', 'token' => 'abc']; + $result = buildConfirmationEmail($sig, 'Test Campaign'); + expect($result)->toHaveKeys(['subject', 'body']); +}); + +it('includes the recipient first name in the body', function () { + $sig = ['petition_id' => 'test', 'firstname' => 'Ingrid', 'token' => 'tok1']; + expect(buildConfirmationEmail($sig, 'X')['body'])->toContain('Ingrid'); +}); + +it('includes the petition title in the body', function () { + $sig = ['petition_id' => 'test', 'firstname' => 'Ola', 'token' => 'tok2']; + expect(buildConfirmationEmail($sig, 'Medisinsk Cannabis')['body'])->toContain('Medisinsk Cannabis'); +}); + +it('includes the confirmation URL with the correct token', function () { + $sig = ['petition_id' => 'my-petition', 'firstname' => 'Per', 'token' => 'tok999']; + $body = buildConfirmationEmail($sig, 'X')['body']; + expect($body)->toContain('?confirm=tok999'); + expect($body)->toContain('/underskriftskampanje/my-petition/'); +}); + +it('uses the petition_id in the confirmation URL', function () { + $sig = ['petition_id' => 'helse-kampanje', 'firstname' => 'Liv', 'token' => 'x']; + $body = buildConfirmationEmail($sig, 'X')['body']; + expect($body)->toContain('/underskriftskampanje/helse-kampanje/'); +}); diff --git a/tests/Unit/PetitionFormTest.php b/tests/Unit/PetitionFormTest.php new file mode 100644 index 0000000..1c17a83 --- /dev/null +++ b/tests/Unit/PetitionFormTest.php @@ -0,0 +1,60 @@ +toBe('John Doe'); + expect(petitionSanitizeCSV('john@example.com'))->toBe('john@example.com'); + expect(petitionSanitizeCSV(''))->toBe(''); +}); + +it('prefixes formula-starting characters to prevent CSV injection', function () { + expect(petitionSanitizeCSV('=SUM(A1)'))->toBe("'=SUM(A1)"); + expect(petitionSanitizeCSV('+evil'))->toBe("'+evil"); + expect(petitionSanitizeCSV('-evil'))->toBe("'-evil"); + expect(petitionSanitizeCSV('@evil'))->toBe("'@evil"); +}); + +it('prefixes tab and newline characters', function () { + expect(petitionSanitizeCSV("\t data"))->toBe("'\t data"); + expect(petitionSanitizeCSV("\r data"))->toBe("'\r data"); + expect(petitionSanitizeCSV("\n data"))->toBe("'\n data"); +}); + +it('only checks the first character', function () { + expect(petitionSanitizeCSV('safe=still safe'))->toBe('safe=still safe'); +}); + +// --- petitionGetCsvPath --- + +it('builds the correct CSV path for a valid petition ID', function () { + $path = petitionGetCsvPath('my-petition'); + expect($path)->toEndWith('/data/petitions/my-petition.csv'); +}); + +it('strips non-alphanumeric characters from petition ID', function () { + $path = petitionGetCsvPath('my petition!'); + expect($path)->toEndWith('/data/petitions/mypetition.csv'); +}); + +it('throws for an empty petition ID', function () { + petitionGetCsvPath(''); +})->throws(Exception::class); + +it('strips path traversal characters rather than throwing', function () { + // Dots and slashes are stripped by the regex, leaving a safe ID + $path = petitionGetCsvPath('../evil'); + expect($path)->toEndWith('/data/petitions/evil.csv'); +}); diff --git a/tests/Unit/PetitionFormTestCsv.php b/tests/Unit/PetitionFormTestCsv.php new file mode 100644 index 0000000..ee03ab4 --- /dev/null +++ b/tests/Unit/PetitionFormTestCsv.php @@ -0,0 +1,366 @@ + time(), + 'email' => $email, + 'firstname' => 'Test', + 'surname' => 'User', + 'region' => 'oslo', + 'display' => 'semi', + 'status' => $status, + 'token' => $token, + 'token_created' => time(), + 'ip_hash' => 'hash', + ]; +} + +// --- petitionEmailExists --- + +it('returns false when the CSV does not exist', function () { + expect(petitionEmailExists('/tmp/nonexistent-petition-form-test.csv', 'a@b.com'))->toBeFalse(); +}); + +it('returns false when the email is not in the CSV', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'other@b.com', 'Ola', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + ]); + expect(petitionEmailExists($path, 'nothere@b.com'))->toBeFalse(); + unlink($path); +}); + +it('returns true when the email exists', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + ]); + expect(petitionEmailExists($path, 'kari@b.com'))->toBeTrue(); + unlink($path); +}); + +it('matches the email case-insensitively', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + ]); + expect(petitionEmailExists($path, 'KARI@B.COM'))->toBeTrue(); + unlink($path); +}); + +// --- petitionGetConfirmedSignatures --- + +it('returns an empty array when the CSV does not exist', function () { + expect(petitionGetConfirmedSignatures('/tmp/nonexistent-petition-form-test2.csv'))->toBe([]); +}); + +it('returns only confirmed signatures', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', time(), 'h1'], + [time(), 'b@b.com', 'Ola', 'Nilsen', 'oslo', 'semi', 'pending', 't2', time(), 'h2'], + [time(), 'c@b.com', 'Per', 'Olsen', 'oslo', 'semi', 'deleted', 't3', time(), 'h3'], + ]); + $sigs = petitionGetConfirmedSignatures($path); + expect(count($sigs))->toBe(1); + expect($sigs[0]['firstname'])->toBe('Kari'); + unlink($path); +}); + +it('sorts confirmed signatures newest-first', function () { + $older = time() - 100; + $newer = time(); + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [$older, 'a@b.com', 'Older', 'Hansen', 'oslo', 'semi', 'confirmed', 't1', $older, 'h1'], + [$newer, 'b@b.com', 'Newer', 'Nilsen', 'oslo', 'semi', 'confirmed', 't2', $newer, 'h2'], + ]); + $sigs = petitionGetConfirmedSignatures($path); + expect($sigs[0]['firstname'])->toBe('Newer'); + expect($sigs[1]['firstname'])->toBe('Older'); + unlink($path); +}); + +it('does not include email or token in the returned signature data', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'secret@b.com', 'Lars', 'Berg', 'oslo', 'semi', 'confirmed', 'secret-tok', time(), 'h1'], + ]); + $sig = petitionGetConfirmedSignatures($path)[0]; + expect($sig)->not->toHaveKey('email'); + expect($sig)->not->toHaveKey('token'); + unlink($path); +}); + +// --- petitionGetSignatureByToken --- + +it('returns null when the CSV does not exist', function () { + expect(petitionGetSignatureByToken('/tmp/nonexistent.csv', 'tok'))->toBeNull(); +}); + +it('returns null for an unknown token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'real-tok', time(), 'h1'], + ]); + expect(petitionGetSignatureByToken($path, 'wrong-tok'))->toBeNull(); + unlink($path); +}); + +it('returns email, firstname, and surname for a matching token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'my-tok', time(), 'h1'], + ]); + $sig = petitionGetSignatureByToken($path, 'my-tok'); + expect($sig)->not->toBeNull(); + expect($sig['email'])->toBe('kari@b.com'); + expect($sig['firstname'])->toBe('Kari'); + expect($sig['surname'])->toBe('Hansen'); + unlink($path); +}); + +// --- petitionGetPendingSignatureByEmail --- + +it('returns null when the CSV does not exist for pending lookup', function () { + expect(petitionGetPendingSignatureByEmail('/tmp/nonexistent.csv', 'a@b.com'))->toBeNull(); +}); + +it('returns null when the email is not present', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'other@b.com', 'Ola', 'Hansen', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + expect(petitionGetPendingSignatureByEmail($path, 'missing@b.com'))->toBeNull(); + unlink($path); +}); + +it('returns the signature when the email matches', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'tok1', time(), 'h1'], + ]); + $sig = petitionGetPendingSignatureByEmail($path, 'kari@b.com'); + expect($sig)->not->toBeNull(); + expect($sig['email'])->toBe('kari@b.com'); + expect($sig['status'])->toBe('pending'); + unlink($path); +}); + +it('matches the email case-insensitively for pending lookup', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'tok1', time(), 'h1'], + ]); + expect(petitionGetPendingSignatureByEmail($path, 'KARI@B.COM'))->not->toBeNull(); + unlink($path); +}); + +// --- petitionAppendSignature --- + +it('creates a new CSV with a header and appends the first signature', function () { + $path = tempnam(sys_get_temp_dir(), 'petition_append_test_'); + unlink($path); // petitionAppendSignature creates it + + $result = petitionAppendSignature($path, sigData('new@b.com')); + expect($result)->toBeTrue(); + expect(file_exists($path))->toBeTrue(); + + $rows = array_filter(explode("\n", file_get_contents($path))); + expect(count($rows))->toBe(2); // header + 1 row + unlink($path); +}); + +it('appends a signature to an existing CSV', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'first@b.com', 'First', 'User', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + petitionAppendSignature($path, sigData('second@b.com', 'pending', 't2')); + + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // skip header + $rows = []; + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) $rows[] = $row; + fclose($path); + + expect(count($rows))->toBe(2); + unlink($path); +}); + +it('rejects a duplicate email and returns false', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'existing@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + $result = petitionAppendSignature($path, sigData('existing@b.com', 'pending', 't2')); + expect($result)->toBeFalse(); + unlink($path); +}); + +it('rejects a duplicate email case-insensitively', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'kari@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 't1', time(), 'h1'], + ]); + $result = petitionAppendSignature($path, sigData('KARI@B.COM', 'pending', 't2')); + expect($result)->toBeFalse(); + unlink($path); +}); + +// --- petitionConfirmSignature --- + +it('returns error when the CSV does not exist', function () { + expect(petitionConfirmSignature('/tmp/nonexistent.csv', 'tok'))->toBe('error'); +}); + +it('returns error for an unknown token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'real-tok', time(), 'h1'], + ]); + expect(petitionConfirmSignature($path, 'wrong-tok'))->toBe('error'); + unlink($path); +}); + +it('returns success and sets status to confirmed for a valid pending token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'valid-tok', time(), 'h1'], + ]); + expect(petitionConfirmSignature($path, 'valid-tok'))->toBe('success'); + + // Verify the status was actually updated in the file + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // header + $row = fgetcsv($fp, null, ',', '"', ''); + fclose($fp); + expect($row[6])->toBe('confirmed'); + unlink($path); +}); + +it('returns already when the token is already confirmed', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'valid-tok', time(), 'h1'], + ]); + expect(petitionConfirmSignature($path, 'valid-tok'))->toBe('already'); + unlink($path); +}); + +it('returns expired when the token is older than 30 days', function () { + $oldTime = time() - (31 * 86400); + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [$oldTime, 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'old-tok', $oldTime, 'h1'], + ]); + expect(petitionConfirmSignature($path, 'old-tok'))->toBe('expired'); + unlink($path); +}); + +// --- petitionDeleteSignature --- + +it('returns error when the CSV does not exist for delete', function () { + expect(petitionDeleteSignature('/tmp/nonexistent.csv', 'tok'))->toBe('error'); +}); + +it('returns error when the token is not found for delete', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'real-tok', time(), 'h1'], + ]); + expect(petitionDeleteSignature($path, 'wrong-tok'))->toBe('error'); + unlink($path); +}); + +it('returns success and removes the row for a matching token', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'del-tok', time(), 'h1'], + [time(), 'b@b.com', 'Other', 'Nilsen', 'oslo', 'semi', 'confirmed', 'keep-tok', time(), 'h2'], + ]); + expect(petitionDeleteSignature($path, 'del-tok'))->toBe('success'); + + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // header + $rows = []; + while (($row = fgetcsv($fp, null, ',', '"', '')) !== false) $rows[] = $row; + fclose($fp); + + expect(count($rows))->toBe(1); + expect($rows[0][7])->toBe('keep-tok'); + unlink($path); +}); + +// --- petitionUpdateSignatureToken --- + +it('returns false when the CSV does not exist for token update', function () { + expect(petitionUpdateSignatureToken('/tmp/nonexistent.csv', 'a@b.com', 'new-tok'))->toBeFalse(); +}); + +it('returns false when the email has no pending entry', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'old-tok', time(), 'h1'], + ]); + expect(petitionUpdateSignatureToken($path, 'a@b.com', 'new-tok'))->toBeFalse(); + unlink($path); +}); + +it('returns true and updates the token for a pending entry', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'pending', 'old-tok', time(), 'h1'], + ]); + expect(petitionUpdateSignatureToken($path, 'a@b.com', 'new-tok'))->toBeTrue(); + + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); // header + $row = fgetcsv($fp, null, ',', '"', ''); + fclose($fp); + expect($row[7])->toBe('new-tok'); + unlink($path); +}); + +it('only updates pending entries, not confirmed ones', function () { + $path = petitionTestCsv([ + PETITION_CSV_HEADER, + [time(), 'a@b.com', 'Kari', 'Hansen', 'oslo', 'semi', 'confirmed', 'conf-tok', time(), 'h1'], + ]); + $result = petitionUpdateSignatureToken($path, 'a@b.com', 'new-tok'); + expect($result)->toBeFalse(); + + // Confirmed token should be unchanged + $fp = fopen($path, 'r'); + fgetcsv($fp, null, ',', '"', ''); + $row = fgetcsv($fp, null, ',', '"', ''); + fclose($fp); + expect($row[7])->toBe('conf-tok'); + unlink($path); +}); diff --git a/tests/Unit/PetitionFormTestPure.php b/tests/Unit/PetitionFormTestPure.php new file mode 100644 index 0000000..be0b4e6 --- /dev/null +++ b/tests/Unit/PetitionFormTestPure.php @@ -0,0 +1,162 @@ + $this->translations, + 'currentLang' => $this->lang, + default => $default, + }; + } + }; +} + +// --- petitionT --- + +it('returns the key when no translation is found', function () { + $ctx = mockContext([]); + expect(petitionT($ctx, 'petition', 'missing_key'))->toBe('missing_key'); +}); + +it('returns the translated string when found', function () { + $ctx = mockContext(['petition.greeting' => 'Hei!']); + expect(petitionT($ctx, 'petition', 'greeting'))->toBe('Hei!'); +}); + +it('applies placeholder replacements', function () { + $ctx = mockContext(['petition.email_greeting' => 'Hei {name}!']); + expect(petitionT($ctx, 'petition', 'email_greeting', ['name' => 'Kari']))->toBe('Hei Kari!'); +}); + +it('applies multiple replacements in a single string', function () { + $ctx = mockContext(['petition.thanks' => 'Takk {name} for {title}']); + $result = petitionT($ctx, 'petition', 'thanks', ['name' => 'Ola', 'title' => 'kampanjen']); + expect($result)->toBe('Takk Ola for kampanjen'); +}); + +it('uses the section as part of the translation key', function () { + $ctx = mockContext(['regions.oslo' => 'Oslo', 'petition.oslo' => 'Wrong']); + expect(petitionT($ctx, 'regions', 'oslo'))->toBe('Oslo'); +}); + +// --- petitionGetIdFromPath --- + +it('returns the basename of a path as the petition ID', function () { + expect(petitionGetIdFromPath('/content/underskriftskampanje/my-petition'))->toBe('my-petition'); +}); + +it('strips a leading numeric prefix from the folder name', function () { + expect(petitionGetIdFromPath('/content/01-my-petition'))->toBe('my-petition'); + expect(petitionGetIdFromPath('/content/001-my-petition'))->toBe('my-petition'); +}); + +it('leaves non-prefixed names intact', function () { + expect(petitionGetIdFromPath('/content/medisinsk-cannabis'))->toBe('medisinsk-cannabis'); +}); + +it('returns null when the basename is empty', function () { + expect(petitionGetIdFromPath('/'))->toBeNull(); +}); + +// --- petitionFormatDate --- + +it('formats a date in Norwegian by default', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'no'); + // 2024-03-15 10:05:00 UTC + $ts = mktime(10, 5, 0, 3, 15, 2024); + expect(petitionFormatDate($ts, $ctx))->toBe('15. mars 2024, 10:05'); +}); + +it('formats a date in English when lang is en', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'en'); + $ts = mktime(10, 5, 0, 3, 15, 2024); + expect(petitionFormatDate($ts, $ctx))->toBe('March 15, 2024, 10:05'); +}); + +it('uses Norwegian month names for an unknown language', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'fr'); + $ts = mktime(0, 0, 0, 1, 1, 2024); + $result = petitionFormatDate($ts, $ctx); + expect($result)->toContain('januar'); +}); + +it('uses the correct Norwegian month names', function () { + date_default_timezone_set('UTC'); + $ctx = mockContext([], 'no'); + $months = ['januar','februar','mars','april','mai','juni', + 'juli','august','september','oktober','november','desember']; + foreach ($months as $i => $name) { + $ts = mktime(0, 0, 0, $i + 1, 1, 2024); + expect(petitionFormatDate($ts, $ctx))->toContain($name); + } +}); + +// --- petitionFormatSignature --- + +it('shows only first name and region for semi display', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => 'Hansen', 'region' => 'oslo', 'display' => 'semi']; + $result = petitionFormatSignature($sig, $ctx); + expect($result)->toBe('Kari fra Oslo'); + expect($result)->not->toContain('Hansen'); +}); + +it('shows full name and region for full display', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => 'Hansen', 'region' => 'oslo', 'display' => 'full']; + expect(petitionFormatSignature($sig, $ctx))->toBe('Kari Hansen fra Oslo'); +}); + +it('shows anonymous label for anonymous display', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'petition.anonymous_name' => 'Anonym', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => 'Hansen', 'region' => 'oslo', 'display' => 'anonymous']; + $result = petitionFormatSignature($sig, $ctx); + expect($result)->toContain('Anonym'); + expect($result)->not->toContain('Kari'); + expect($result)->not->toContain('Hansen'); +}); + +it('HTML-escapes names in full display mode', function () { + $ctx = mockContext([ + 'petition.from_region' => 'fra', + 'regions.oslo' => 'Oslo', + ]); + $sig = ['firstname' => 'Kari', 'surname' => '