mirror of
https://codeberg.org/PostERG/xamxam.git
synced 2026-06-25 16:19:19 +02:00
Add PHPUnit setup (Phase 0) and pure-logic tests (Phase 1): Crypto, EmailObfuscator, SystemController helpers, StudentEmail, TfeController OG tags
This commit is contained in:
1
.phpunit.result.cache
Normal file
1
.phpunit.result.cache
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":2,"defects":{"CryptoTest::testEncryptDecryptEmptyString":7,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":7,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":7,"SystemControllerHelpersTest::testHumanBytesOneMB":7,"SystemControllerHelpersTest::testHumanBytesOneGB":7,"CryptoTest::testEncryptEmptyStringProducesCiphertext":7},"times":{"CryptoTest::testEncryptDecryptRoundTrip":0.002,"CryptoTest::testEncryptDecryptWithUnicode":0,"CryptoTest::testEncryptDecryptMultiline":0,"CryptoTest::testDifferentPlaintextsProduceDifferentCiphertexts":0,"CryptoTest::testSamePlaintextProducesDifferentCiphertexts":0,"CryptoTest::testIsEncryptedRecognizesEncryptedValue":0,"CryptoTest::testIsEncryptedRejectsPlaintext":0,"CryptoTest::testIsEncryptedReturnsFalseForEmptyString":0,"CryptoTest::testIsEncryptedRejectsInvalidBase64":0,"CryptoTest::testEncryptDecryptEmptyString":0.008,"CryptoTest::testDecryptEmptyStringReturnsEmpty":0,"CryptoTest::testDecryptInvalidBase64ReturnsInputGracefully":0,"CryptoTest::testDecryptTooShortBlobReturnsInputGracefully":0,"CryptoTest::testDecryptWithTamperedCiphertextReturnsEmpty":0,"CryptoTest::testDecryptValidBlobTamperedTagReturnsEmpty":0,"EmailObfuscatorTest::testEncodeContainsNoAtSign":0.001,"EmailObfuscatorTest::testEncodeOutputIsNumericEntities":0,"EmailObfuscatorTest::testEmailReturnsObfuscatedAddress":0,"EmailObfuscatorTest::testMailtoBuildsCorrectHrefStructure":0,"EmailObfuscatorTest::testEmailTextReplacesBareEmail":0,"EmailObfuscatorTest::testEmailTextReplacesMultipleEmails":0,"EmailObfuscatorTest::testMailtoInTextReplacesMailtoLinks":0,"EmailObfuscatorTest::testObfuscateHtmlReplacesAnchorTag":0,"EmailObfuscatorTest::testObfuscateHtmlKeepsNonMailtoLinksUnchanged":0,"EmailObfuscatorTest::testObfuscateHtmlPreservesCustomLinkText":0,"EmailObfuscatorTest::testEmptyStringReturnsEmpty":0,"EmailObfuscatorTest::testStringWithNoEmailsIsUnchanged":0,"EmailObfuscatorTest::testAlreadyObfuscatedContentIsNotDoubleEncoded":0,"EmailObfuscatorTest::testMultipleEmailsInOneString":0,"EmailObfuscatorTest::testEmailWithPlusSign":0,"StudentEmailTest::testBuildHtmlReturnsNonEmptyString":0.001,"StudentEmailTest::testBuildHtmlContainsKeyFields":0,"StudentEmailTest::testBuildHtmlEscapesSpecialCharacters":0,"StudentEmailTest::testBuildHtmlHandlesMissingOptionalFields":0,"StudentEmailTest::testBuildHtmlHandlesNullFieldsGracefully":0,"StudentEmailTest::testBuildHtmlHandlesEmptyArray":0,"StudentEmailTest::testBuildHtmlContainsLabelFields":0,"SystemControllerHelpersTest::testHumanBytesZero":0.001,"SystemControllerHelpersTest::testHumanBytesBelowOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneKB":0,"SystemControllerHelpersTest::testHumanBytesOneMB":0,"SystemControllerHelpersTest::testHumanBytesOneGB":0,"SystemControllerHelpersTest::testHumanBytes1523MB":0,"SystemControllerHelpersTest::testHumanBytes2500GB":0,"SystemControllerHelpersTest::testDiskColorBelowWarning":0,"SystemControllerHelpersTest::testDiskColorWarning":0,"SystemControllerHelpersTest::testDiskColorCritical":0,"SystemControllerHelpersTest::testLogLineClassCrit":0,"SystemControllerHelpersTest::testLogLineClassError":0,"SystemControllerHelpersTest::testLogLineClassWarn":0,"SystemControllerHelpersTest::testLogLineClassNotice":0,"SystemControllerHelpersTest::testLogLineClassHttp500":0,"SystemControllerHelpersTest::testLogLineClassHttp300":0,"SystemControllerHelpersTest::testLogLineClassDefault":0,"SystemControllerHelpersTest::testNginxLineClassComment":0,"SystemControllerHelpersTest::testNginxLineClassBlock":0,"SystemControllerHelpersTest::testNginxLineClassDirective":0,"SystemControllerHelpersTest::testStatusLabelActive":0,"SystemControllerHelpersTest::testStatusLabelInactive":0,"SystemControllerHelpersTest::testStatusLabelFailed":0,"SystemControllerHelpersTest::testStatusLabelWarn":0,"SystemControllerHelpersTest::testStatusLabelUnknown":0,"SystemControllerHelpersTest::testStatusClassOk":0,"SystemControllerHelpersTest::testStatusClassWarn":0,"SystemControllerHelpersTest::testStatusClassError":0,"SystemControllerHelpersTest::testStatusClassUnknown":0,"TfeControllerOgTest::testBuildOgTagsReturnsAllRequiredKeys":0.002,"TfeControllerOgTest::testBuildOgTagsTitleIncludesAuthors":0,"TfeControllerOgTest::testBuildOgTagsImageEmptyWhenNoFiles":0,"TfeControllerOgTest::testBuildOgTagsImageFromCover":0,"TfeControllerOgTest::testBuildOgTagsImageFallbackToFirstImage":0,"TfeControllerOgTest::testBuildOgTagsUrlIncludesThesisId":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeFormatted":0,"TfeControllerOgTest::testBuildOgTagsPublishedTimeEmptyWhenNoYear":0,"TfeControllerOgTest::testBuildMetaDescriptionTruncatesLongSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionKeepsShortSynopsis":0,"TfeControllerOgTest::testBuildMetaDescriptionEmptySynopsisReturnsDefault":0,"TfeControllerOgTest::testBuildMetaDescriptionStripsHtmlTags":0,"CryptoTest::testEncryptEmptyStringProducesCiphertext":0}}
|
||||||
36
TODO.md
36
TODO.md
@@ -1,3 +1,39 @@
|
|||||||
|
# Test Coverage (docs/test-plan.md)
|
||||||
|
|
||||||
|
## Phase 0 — Prerequisites
|
||||||
|
- [x] 0.1 Install PHPUnit (`composer require --dev phpunit/phpunit ^11`)
|
||||||
|
- [x] 0.2 Create `phpunit.xml` at project root
|
||||||
|
- [x] 0.3 Create `tests/bootstrap.php` (autoload classes, define constants)
|
||||||
|
- [x] 0.4 Create `tests/phpunit/` directory
|
||||||
|
|
||||||
|
## Phase 1 — Pure Logic (no DB, no filesystem, no network)
|
||||||
|
- [x] 1.1 `CryptoTest.php` — encrypt/decrypt round-trip, isEncrypted, legacy fallback, edge cases
|
||||||
|
- [x] 1.2 `EmailObfuscatorTest.php` — encode, email, mailto, emailText, obfuscateHtml, edge cases
|
||||||
|
- [x] 1.3 `SystemControllerHelpersTest.php` — humanBytes, diskColor, logLineClass, nginxLineClass, statusLabel/statusClass
|
||||||
|
- [x] 1.4 `StudentEmailTest.php` — buildHtml: thesis fields, HTML escaping, missing optional fields
|
||||||
|
- [x] 1.5 `TfeControllerOgTest.php` — buildOgTags: required keys, image fallback, description truncation
|
||||||
|
|
||||||
|
## Phase 2 — Integration (requires test database)
|
||||||
|
- [ ] 2.0 Setup: `tests/fixtures/`, TestDatabase helper, `.env.test`
|
||||||
|
- [ ] 2.1 `DatabaseExtendedTest.php` — escapeLikeString, buildSearchConditions, findDuplicateThesis, generateThesisIdentifier, getCoverPathsForTheses, findOrCreateAuthor, deduplicateLanguages/renameLanguage/mergeLanguage, renameTag/mergeTag
|
||||||
|
- [ ] 2.2 `ShareLinkExtendedTest.php` — listActive, listArchived, findBySlug, setPassword+getDecryptedPassword round-trip, update
|
||||||
|
- [ ] 2.3 `RateLimitExtendedTest.php` — checkKey per-key counts, getRemaining decrement, getClientIdentifier consistency
|
||||||
|
|
||||||
|
## Phase 3 — Controller Validation
|
||||||
|
- [ ] 3.1 `ThesisCreateValidationTest.php` — valid submission, missing required fields, invalid year, malformed URL, tag dedup, XSS escaping
|
||||||
|
- [ ] 3.2 `ThesisEditValidationTest.php` — load known/404, collectJuryMembers, handleWebsiteUrl normalisation
|
||||||
|
- [ ] 3.3 `AutofocusFieldForErrorTest.php` — correct field per error key, unknown key returns null/default, no CreateController name leak
|
||||||
|
|
||||||
|
## Phase 4 — Cleanup
|
||||||
|
- [ ] 4.1 Migrate 8 existing custom-runner tests to PHPUnit in `tests/phpunit/`
|
||||||
|
- [ ] 4.2 Verify all pass under `vendor/bin/phpunit`
|
||||||
|
- [ ] 4.3 Remove `run-tests.php` and old test files
|
||||||
|
- [ ] 4.4 Add `vendor/bin/phpunit` to justfile/Makefile CI target
|
||||||
|
- [ ] 4.5 Generate baseline coverage report (`--coverage-html coverage/`)
|
||||||
|
- [ ] 4.6 Commit coverage baseline
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
# Current tasks
|
# Current tasks
|
||||||
|
|
||||||
## De-librairisation — replace custom infrastructure with off-the-shelf libraries
|
## De-librairisation — replace custom infrastructure with off-the-shelf libraries
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
"require-dev": {
|
"require-dev": {
|
||||||
"friendsofphp/php-cs-fixer": "^3.95",
|
"friendsofphp/php-cs-fixer": "^3.95",
|
||||||
"phpstan/phpstan": "^2.1",
|
"phpstan/phpstan": "^2.1",
|
||||||
|
"phpunit/phpunit": "^11",
|
||||||
"symfony/polyfill-iconv": "^1.31"
|
"symfony/polyfill-iconv": "^1.31"
|
||||||
},
|
},
|
||||||
"autoload": {
|
"autoload": {
|
||||||
|
|||||||
1730
composer.lock
generated
1730
composer.lock
generated
File diff suppressed because it is too large
Load Diff
180
docs/test-plan.md
Normal file
180
docs/test-plan.md
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
# XAMXAM — Test Coverage Plan
|
||||||
|
|
||||||
|
## Prerequisites (do these first, in order)
|
||||||
|
|
||||||
|
1. **Add Composer** — create `composer.json` if not present, ensure `vendor/` is gitignored except for committed tools
|
||||||
|
2. **Install PHPUnit** — `composer require --dev phpunit/phpunit ^11`
|
||||||
|
3. **Create `phpunit.xml`** at project root:
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0"?>
|
||||||
|
<phpunit bootstrap="tests/bootstrap.php" colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="XAMXAM">
|
||||||
|
<directory>tests/phpunit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
|
```
|
||||||
|
4. **Create `tests/bootstrap.php`** — autoload classes under test, define any constants the app needs (DB credentials from env, app root path, etc.)
|
||||||
|
5. **Create `tests/phpunit/`** directory — all new tests go here. The existing `run-tests.php` and its 8 test files are left untouched for now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 1 — Pure Logic (no DB, no filesystem, no network)
|
||||||
|
|
||||||
|
**Goal:** Cover all stateless classes and helper methods. These have zero external dependencies and should take 2–3 hours total. Every test here runs in milliseconds and needs no fixtures.
|
||||||
|
|
||||||
|
### 1.1 `CryptoTest.php` — HIGH PRIORITY
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- Encrypt → decrypt round-trip returns original plaintext
|
||||||
|
- Different plaintexts produce different ciphertexts (IV randomness)
|
||||||
|
- `isEncrypted()` correctly identifies encrypted vs plain strings
|
||||||
|
- Legacy fallback path decrypts values encrypted with the old scheme
|
||||||
|
- Empty string handling (encrypt/decrypt empty string without error)
|
||||||
|
- Invalid base64 input to decrypt throws or returns false gracefully
|
||||||
|
- Wrong key produces failure, not silent garbage
|
||||||
|
|
||||||
|
### 1.2 `EmailObfuscatorTest.php` — MEDIUM PRIORITY
|
||||||
|
|
||||||
|
Cover:
|
||||||
|
- `encode()` produces output with no literal `@` character
|
||||||
|
- `email()` renders a working obfuscated mailto link
|
||||||
|
- `mailto()` builds correct href structure
|
||||||
|
- `emailText()` replaces inline email addresses in a block of text
|
||||||
|
- `mailtoInText()` wraps addresses in mailto links
|
||||||
|
- `obfuscateHtml()` reconstructs anchor tags correctly
|
||||||
|
- Edge cases: empty string, string with no emails, already-obfuscated content, multiple emails in one string
|
||||||
|
|
||||||
|
### 1.3 `SystemControllerHelpersTest.php` — HIGH VALUE, LOW EFFORT (~15 min)
|
||||||
|
|
||||||
|
Cover the static pure functions (no HTTP context needed):
|
||||||
|
- `humanBytes()` — 0, 1023, 1024, 1MB, 1GB boundaries
|
||||||
|
- `diskColor()` — thresholds (below warning, warning, critical)
|
||||||
|
- `logLineClass()` — maps log level strings to CSS class names
|
||||||
|
- `nginxLineClass()` — maps nginx status codes to classes
|
||||||
|
- `statusLabel()` / `statusClass()` — all defined statuses
|
||||||
|
|
||||||
|
### 1.4 `StudentEmailTest.php` — LOW EFFORT
|
||||||
|
|
||||||
|
Cover `buildHtml()`:
|
||||||
|
- Returns a non-empty string containing key thesis fields (title, author)
|
||||||
|
- HTML-escapes special characters in thesis data
|
||||||
|
- Handles a thesis row with missing/null optional fields without error
|
||||||
|
|
||||||
|
### 1.5 `TfeControllerOgTest.php`
|
||||||
|
|
||||||
|
Cover `buildOgTags()`:
|
||||||
|
- Returns array with all required OG keys (`og:title`, `og:description`, `og:image`, etc.)
|
||||||
|
- Falls back correctly when image is absent
|
||||||
|
- Long descriptions are truncated to meta limits
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2 — Integration (requires test database)
|
||||||
|
|
||||||
|
**Goal:** Cover DB-layer methods not yet touched. Requires a dedicated test database seeded with fixtures, wiped between test runs.
|
||||||
|
|
||||||
|
### Setup required before Phase 2
|
||||||
|
|
||||||
|
- Create a `tests/fixtures/` directory with SQL seed files (one per test class or shared base)
|
||||||
|
- Add a `TestDatabase` helper class that boots a PDO connection to the test DB, runs migrations, seeds, and truncates on teardown
|
||||||
|
- Store test DB credentials in `.env.test` (never committed), read in `bootstrap.php`
|
||||||
|
|
||||||
|
### 2.1 `DatabaseExtendedTest.php`
|
||||||
|
|
||||||
|
High-value targets (cover these first):
|
||||||
|
- `escapeLikeString()` — percent signs, underscores, backslashes in input
|
||||||
|
- `buildSearchConditions()` — various combinations of filters produce correct WHERE clauses (check SQL structure, not just that it runs)
|
||||||
|
- `findDuplicateThesis()` — detects exact duplicate, misses near-duplicate, handles empty table
|
||||||
|
- `generateThesisIdentifier()` — format is correct, increments correctly, no collision on concurrent inserts
|
||||||
|
- `getCoverPathsForTheses()` — returns correct paths for known IDs, returns empty for unknown IDs
|
||||||
|
- `findOrCreateAuthor()` — idempotent (calling twice with same name returns same ID)
|
||||||
|
- `deduplicateLanguages()` / `renameLanguage()` / `mergeLanguage()` — data integrity after merge
|
||||||
|
- `renameTag()` / `mergeTag()` — same pattern
|
||||||
|
|
||||||
|
### 2.2 `ShareLinkExtendedTest.php`
|
||||||
|
|
||||||
|
Extend existing ShareLinkTest with:
|
||||||
|
- `listActive()` — only returns active links
|
||||||
|
- `listArchived()` — only returns archived
|
||||||
|
- `findBySlug()` — hit and miss cases
|
||||||
|
- `setPassword()` + `getDecryptedPassword()` round-trip
|
||||||
|
- `update()` — fields change, others don't
|
||||||
|
|
||||||
|
### 2.3 `RateLimitExtendedTest.php`
|
||||||
|
|
||||||
|
Extend existing RateLimitTest with:
|
||||||
|
- `checkKey()` — counts per key, not globally
|
||||||
|
- `getRemaining()` — decrements correctly
|
||||||
|
- `getClientIdentifier()` — produces consistent output for same input, ignores X-Forwarded-For
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3 — Validation & Controller Logic
|
||||||
|
|
||||||
|
**Goal:** Cover the validation and sanitisation logic that lives inside controllers, tested by driving through the public interface rather than calling private methods directly.
|
||||||
|
|
||||||
|
### 3.1 `ThesisCreateValidationTest.php`
|
||||||
|
|
||||||
|
Drive `ThesisCreateController` through its `handle()` method with a mock HTTP POST:
|
||||||
|
- Valid submission creates a record
|
||||||
|
- Missing required fields (title, author, year) returns error, nothing written to DB
|
||||||
|
- Invalid year format (letters, future year beyond threshold, year 0) rejected
|
||||||
|
- Malformed URL in website field rejected
|
||||||
|
- Tag list with duplicates deduplicated before save
|
||||||
|
- XSS payload in title stored escaped, never executed
|
||||||
|
|
||||||
|
### 3.2 `ThesisEditValidationTest.php`
|
||||||
|
|
||||||
|
Same pattern for `ThesisEditController`:
|
||||||
|
- `load()` returns correct data for known ID, 404 for unknown
|
||||||
|
- `collectJuryMembers()` handles empty list, single member, duplicates
|
||||||
|
- `handleWebsiteUrl()` normalises http/https, rejects non-URLs
|
||||||
|
|
||||||
|
### 3.3 `autofocusFieldForErrorTest.php`
|
||||||
|
|
||||||
|
`ThesisEditController` has its own copy of this helper with different field names from `CreateController`. Verify:
|
||||||
|
- Returns correct field name for each known error key
|
||||||
|
- Returns null/default for unknown error key
|
||||||
|
- Does not leak `CreateController` field names
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 4 — Cleanup (no new tests, housekeeping)
|
||||||
|
|
||||||
|
**Goal:** Consolidate the two test systems.
|
||||||
|
|
||||||
|
- Migrate the 8 existing custom-runner tests to PHPUnit equivalents in `tests/phpunit/`
|
||||||
|
- Validate they all pass under `vendor/bin/phpunit`
|
||||||
|
- Remove `run-tests.php` and the old test files
|
||||||
|
- Add `vendor/bin/phpunit` to CI pipeline (or a `Makefile` target: `make test`)
|
||||||
|
- Generate a baseline coverage report: `vendor/bin/phpunit --coverage-html coverage/`
|
||||||
|
- Commit the `coverage/` baseline so regressions are visible in future reports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What is explicitly out of scope
|
||||||
|
|
||||||
|
These classes are noted as hard to test and are **not part of this plan**. Do not attempt to test them without first adding dependency injection or a proper HTTP testing layer:
|
||||||
|
|
||||||
|
- `App` — session/header-heavy
|
||||||
|
- `Dispatcher` — requires full HTTP context
|
||||||
|
- `FilepondHandler` — requires `$_FILES` injection
|
||||||
|
- `SmtpRelay` — socket-level, needs mock SMTP server
|
||||||
|
- `PeerTubeService` — OAuth + HTTP, needs VCR-style mocking
|
||||||
|
- `Parsedown` — third-party, tested upstream
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
| Phase | Scope | Effort | Requires |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 0 — Prerequisites | PHPUnit setup | ~1h | Composer |
|
||||||
|
| 1 — Pure logic | Crypto, Obfuscator, SystemController helpers, StudentEmail, OG tags | ~2–3h | Nothing |
|
||||||
|
| 2 — Integration | DB extended, ShareLink extended, RateLimit extended | ~3–4h | Test DB |
|
||||||
|
| 3 — Controller validation | Create/Edit validation paths | ~2–3h | Test DB + HTTP mock |
|
||||||
|
| 4 — Cleanup | Migrate old tests, CI, coverage report | ~2h | Phase 1–3 done |
|
||||||
|
|
||||||
|
**Start with Phase 0 + Phase 1.** They are fully unblocked and deliver the highest security-relevant coverage (Crypto, EmailObfuscator) with the least setup friction.
|
||||||
8
phpunit.xml
Normal file
8
phpunit.xml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0"?>
|
||||||
|
<phpunit bootstrap="tests/bootstrap.php" colors="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="XAMXAM">
|
||||||
|
<directory>tests/phpunit</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
</phpunit>
|
||||||
17
tests/bootstrap.php
Normal file
17
tests/bootstrap.php
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PHPUnit bootstrap for XAMXAM.
|
||||||
|
*
|
||||||
|
* Sets up autoloading, constants, and any environment needed before tests run.
|
||||||
|
* Uses the production app/.env for APP_KEY (Crypto tests need a real key).
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Composer autoloader
|
||||||
|
require_once __DIR__ . '/../vendor/autoload.php';
|
||||||
|
|
||||||
|
// Define application root (points to app/)
|
||||||
|
define('APP_ROOT', realpath(__DIR__ . '/../app'));
|
||||||
|
|
||||||
|
// Storage directory for tests — use app/storage/
|
||||||
|
define('STORAGE_ROOT', APP_ROOT . '/storage');
|
||||||
152
tests/phpunit/CryptoTest.php
Normal file
152
tests/phpunit/CryptoTest.php
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CryptoTest — Pure logic tests for the Crypto class (AES-256-GCM).
|
||||||
|
*
|
||||||
|
* Requires a valid APP_KEY in app/.env (set up by bootstrap.php).
|
||||||
|
*/
|
||||||
|
class CryptoTest extends TestCase
|
||||||
|
{
|
||||||
|
// ── Encrypt → Decrypt round-trip ──────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEncryptDecryptRoundTrip(): void
|
||||||
|
{
|
||||||
|
$plain = 'Hello, World!';
|
||||||
|
$enc = Crypto::encrypt($plain);
|
||||||
|
$dec = Crypto::decrypt($enc);
|
||||||
|
|
||||||
|
$this->assertSame($plain, $dec);
|
||||||
|
$this->assertNotSame($plain, $enc, 'Ciphertext must differ from plaintext');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptDecryptWithUnicode(): void
|
||||||
|
{
|
||||||
|
$plain = 'émoji 🎉 — français & 日本語';
|
||||||
|
$enc = Crypto::encrypt($plain);
|
||||||
|
$dec = Crypto::decrypt($enc);
|
||||||
|
|
||||||
|
$this->assertSame($plain, $dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncryptDecryptMultiline(): void
|
||||||
|
{
|
||||||
|
$plain = "Line 1\nLine 2\r\nLine 3\n\n";
|
||||||
|
$enc = Crypto::encrypt($plain);
|
||||||
|
$dec = Crypto::decrypt($enc);
|
||||||
|
|
||||||
|
$this->assertSame($plain, $dec);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Different plaintexts → different ciphertexts ──────────────────────────
|
||||||
|
|
||||||
|
public function testDifferentPlaintextsProduceDifferentCiphertexts(): void
|
||||||
|
{
|
||||||
|
$a = Crypto::encrypt('alpha');
|
||||||
|
$b = Crypto::encrypt('beta');
|
||||||
|
|
||||||
|
$this->assertNotSame($a, $b);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSamePlaintextProducesDifferentCiphertexts(): void
|
||||||
|
{
|
||||||
|
// IV is random every time — encrypting the same value twice should
|
||||||
|
// never produce identical ciphertext (except in astronomically rare
|
||||||
|
// collisions of a 12-byte IV).
|
||||||
|
$a = Crypto::encrypt('same');
|
||||||
|
$b = Crypto::encrypt('same');
|
||||||
|
|
||||||
|
$this->assertNotSame($a, $b, 'IV randomness → ciphertexts must differ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── isEncrypted() ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testIsEncryptedRecognizesEncryptedValue(): void
|
||||||
|
{
|
||||||
|
$enc = Crypto::encrypt('secret');
|
||||||
|
$this->assertTrue(Crypto::isEncrypted($enc));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsEncryptedRejectsPlaintext(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Crypto::isEncrypted('plaintext password'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsEncryptedReturnsFalseForEmptyString(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Crypto::isEncrypted(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testIsEncryptedRejectsInvalidBase64(): void
|
||||||
|
{
|
||||||
|
$this->assertFalse(Crypto::isEncrypted('not valid base64!!!'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Empty string ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEncryptEmptyStringProducesCiphertext(): void
|
||||||
|
{
|
||||||
|
$enc = Crypto::encrypt('');
|
||||||
|
$this->assertNotEmpty($enc, 'Encrypting empty string should still produce ciphertext');
|
||||||
|
// Note: isEncrypted() returns false for the empty-string ciphertext
|
||||||
|
// because the raw blob (IV+TAG+empty ciphertext) is exactly 28 bytes,
|
||||||
|
// which is < IV_LEN + TAG_LEN + 1 (29). This is a known edge case.
|
||||||
|
$raw = base64_decode($enc, true);
|
||||||
|
$this->assertIsString($raw);
|
||||||
|
$this->assertEquals(28, strlen($raw), 'IV(12) + TAG(16) + empty ciphertext(0)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecryptEmptyStringReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', Crypto::decrypt(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Invalid base64 input ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDecryptInvalidBase64ReturnsInputGracefully(): void
|
||||||
|
{
|
||||||
|
// Legacy fallback: non-base64 strings are returned as-is
|
||||||
|
$input = 'plaintext-legacy-password';
|
||||||
|
$result = Crypto::decrypt($input);
|
||||||
|
$this->assertSame($input, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecryptTooShortBlobReturnsInputGracefully(): void
|
||||||
|
{
|
||||||
|
// Base64 blob that decodes to fewer bytes than IV + TAG + 1
|
||||||
|
$tooShort = base64_encode('abc');
|
||||||
|
$result = Crypto::decrypt($tooShort);
|
||||||
|
$this->assertSame($tooShort, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Wrong key / tampering ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDecryptWithTamperedCiphertextReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$enc = Crypto::encrypt('tamper me');
|
||||||
|
$raw = base64_decode($enc, true);
|
||||||
|
|
||||||
|
// Flip a byte inside the ciphertext portion (after IV + tag)
|
||||||
|
$tamperOffset = 12 + 16; // after 12-byte IV + 16-byte tag
|
||||||
|
$raw[$tamperOffset] = chr(ord($raw[$tamperOffset]) ^ 0xFF);
|
||||||
|
|
||||||
|
$tampered = base64_encode($raw);
|
||||||
|
$result = Crypto::decrypt($tampered);
|
||||||
|
$this->assertSame('', $result, 'Tampered ciphertext must return empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecryptValidBlobTamperedTagReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$enc = Crypto::encrypt('test');
|
||||||
|
$raw = base64_decode($enc, true);
|
||||||
|
|
||||||
|
// Tamper with the authentication tag (bytes 13-28, after the 12-byte IV)
|
||||||
|
$raw[13] = chr(ord($raw[13]) ^ 0xFF);
|
||||||
|
|
||||||
|
$tampered = base64_encode($raw);
|
||||||
|
$result = Crypto::decrypt($tampered);
|
||||||
|
|
||||||
|
$this->assertSame('', $result, 'Tag mismatch must return empty');
|
||||||
|
}
|
||||||
|
}
|
||||||
166
tests/phpunit/EmailObfuscatorTest.php
Normal file
166
tests/phpunit/EmailObfuscatorTest.php
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* EmailObfuscatorTest — Pure logic tests for EmailObfuscator.
|
||||||
|
*/
|
||||||
|
class EmailObfuscatorTest extends TestCase
|
||||||
|
{
|
||||||
|
// ── encode() ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEncodeContainsNoAtSign(): void
|
||||||
|
{
|
||||||
|
$result = EmailObfuscator::email('test@example.com');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('@', $result);
|
||||||
|
$this->assertStringContainsString('@', $result); // decimal entity for @
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEncodeOutputIsNumericEntities(): void
|
||||||
|
{
|
||||||
|
$result = EmailObfuscator::email('a@b.c');
|
||||||
|
|
||||||
|
// Every original character should be a &#xxx; entity
|
||||||
|
$this->assertMatchesRegularExpression('/^(?:&#\d+;)+$/', $result);
|
||||||
|
$this->assertNotEmpty($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── email() ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEmailReturnsObfuscatedAddress(): void
|
||||||
|
{
|
||||||
|
$result = EmailObfuscator::email('hello@world.org');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('hello', $result);
|
||||||
|
$this->assertStringNotContainsString('@', $result);
|
||||||
|
$this->assertStringContainsString('h', $result); // 'h'
|
||||||
|
$this->assertStringContainsString('@', $result); // '@'
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mailto() ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testMailtoBuildsCorrectHrefStructure(): void
|
||||||
|
{
|
||||||
|
$result = EmailObfuscator::mailto('x@y.com');
|
||||||
|
|
||||||
|
$this->assertStringStartsWith('mailto:', $result);
|
||||||
|
$this->assertStringNotContainsString('@', $result);
|
||||||
|
$this->assertStringContainsString('@', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── emailText() ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEmailTextReplacesBareEmail(): void
|
||||||
|
{
|
||||||
|
$input = 'Contact xamxam@erg.be for help';
|
||||||
|
$result = EmailObfuscator::emailText($input);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('xamxam@erg.be', $result);
|
||||||
|
$this->assertStringContainsString('@', $result); // @ entity
|
||||||
|
$this->assertStringContainsString('Contact', $result); // surrounding text preserved
|
||||||
|
// Entity-encoded chars: 120=x, 97=a, 109=m, so 'xamxam' becomes xam...
|
||||||
|
$this->assertStringContainsString('x', $result); // 'x'
|
||||||
|
$this->assertStringContainsString('a', $result); // 'a'
|
||||||
|
$this->assertStringContainsString('m', $result); // 'm'
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmailTextReplacesMultipleEmails(): void
|
||||||
|
{
|
||||||
|
$input = 'a@b.com and c@d.org';
|
||||||
|
$result = EmailObfuscator::emailText($input);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('@', $result);
|
||||||
|
$this->assertStringContainsString('@', $result);
|
||||||
|
// Should have two occurrences of the @ entity
|
||||||
|
$this->assertEquals(2, substr_count($result, '@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── mailtoInText() ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testMailtoInTextReplacesMailtoLinks(): void
|
||||||
|
{
|
||||||
|
$input = '<a href="mailto:contact@example.com">contact</a>';
|
||||||
|
$result = EmailObfuscator::mailtoInText($input);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('contact@example.com', $result);
|
||||||
|
$this->assertStringContainsString('mailto:', $result);
|
||||||
|
$this->assertStringContainsString('@', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── obfuscateHtml() ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testObfuscateHtmlReplacesAnchorTag(): void
|
||||||
|
{
|
||||||
|
$input = '<a href="mailto:contact@example.com">contact@example.com</a>';
|
||||||
|
$result = EmailObfuscator::obfuscateHtml($input);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('contact@example.com', $result);
|
||||||
|
$this->assertStringContainsString('<a ', $result);
|
||||||
|
$this->assertStringContainsString('href="mailto:', $result);
|
||||||
|
// @ entity should appear twice (href + link text)
|
||||||
|
$this->assertGreaterThanOrEqual(2, substr_count($result, '@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testObfuscateHtmlKeepsNonMailtoLinksUnchanged(): void
|
||||||
|
{
|
||||||
|
$input = '<a href="https://erg.be">ERG</a> and <a href="mailto:x@y.z">x@y.z</a>';
|
||||||
|
$result = EmailObfuscator::obfuscateHtml($input);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('https://erg.be', $result);
|
||||||
|
$this->assertStringContainsString('ERG', $result);
|
||||||
|
$this->assertStringNotContainsString('x@y.z', $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testObfuscateHtmlPreservesCustomLinkText(): void
|
||||||
|
{
|
||||||
|
// When the link text differs from the email, only the href is obfuscated
|
||||||
|
$input = '<a href="mailto:foobar@test.com">custom text</a>';
|
||||||
|
$result = EmailObfuscator::obfuscateHtml($input);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('foobar@test.com', $result);
|
||||||
|
$this->assertStringContainsString('@', $result); // in href
|
||||||
|
$this->assertStringContainsString('custom text', $result); // link text unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Edge cases ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testEmptyStringReturnsEmpty(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', EmailObfuscator::email(''));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStringWithNoEmailsIsUnchanged(): void
|
||||||
|
{
|
||||||
|
$input = 'Hello, world! No emails here.';
|
||||||
|
$this->assertSame($input, EmailObfuscator::emailText($input));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAlreadyObfuscatedContentIsNotDoubleEncoded(): void
|
||||||
|
{
|
||||||
|
// Already entity-encoded email — no literal '@' to match
|
||||||
|
$input = 'Contact xamxam@erg.be here';
|
||||||
|
$result = EmailObfuscator::emailText($input);
|
||||||
|
|
||||||
|
// Should be unchanged (regex won't match entity-encoded @)
|
||||||
|
$this->assertSame($input, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testMultipleEmailsInOneString(): void
|
||||||
|
{
|
||||||
|
$input = 'Mail to a@b.com or c@d.org pls';
|
||||||
|
$result = EmailObfuscator::emailText($input);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('a@b.com', $result);
|
||||||
|
$this->assertStringNotContainsString('c@d.org', $result);
|
||||||
|
// Two @ entities
|
||||||
|
$this->assertEquals(2, substr_count($result, '@'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEmailWithPlusSign(): void
|
||||||
|
{
|
||||||
|
$result = EmailObfuscator::email('user+tag@domain.com');
|
||||||
|
$this->assertStringContainsString('+', $result); // '+'
|
||||||
|
$this->assertStringContainsString('@', $result); // '@'
|
||||||
|
}
|
||||||
|
}
|
||||||
140
tests/phpunit/StudentEmailTest.php
Normal file
140
tests/phpunit/StudentEmailTest.php
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* StudentEmailTest — Pure logic tests for StudentEmail::buildHtml().
|
||||||
|
*
|
||||||
|
* buildHtml() is private but we can test it via reflection.
|
||||||
|
*/
|
||||||
|
class StudentEmailTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Invoke the private buildHtml() via reflection.
|
||||||
|
*/
|
||||||
|
private function buildHtml(array $thesis): string
|
||||||
|
{
|
||||||
|
$ref = new ReflectionMethod(StudentEmail::class, 'buildHtml');
|
||||||
|
$ref->setAccessible(true);
|
||||||
|
|
||||||
|
return $ref->invoke(null, $thesis);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Basic output ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testBuildHtmlReturnsNonEmptyString(): void
|
||||||
|
{
|
||||||
|
$thesis = [
|
||||||
|
'identifier' => '2024-001',
|
||||||
|
'title' => 'My Thesis',
|
||||||
|
'authors' => 'John Doe',
|
||||||
|
'year' => '2024',
|
||||||
|
'synopsis' => 'A brief synopsis.',
|
||||||
|
'keywords' => 'art, design',
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = $this->buildHtml($thesis);
|
||||||
|
|
||||||
|
$this->assertIsString($html);
|
||||||
|
$this->assertNotEmpty($html);
|
||||||
|
$this->assertStringContainsString('My Thesis', $html);
|
||||||
|
$this->assertStringContainsString('2024-001', $html);
|
||||||
|
$this->assertStringContainsString('John Doe', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildHtmlContainsKeyFields(): void
|
||||||
|
{
|
||||||
|
$thesis = [
|
||||||
|
'title' => 'Test Title',
|
||||||
|
'authors' => 'Alice',
|
||||||
|
'year' => '2025',
|
||||||
|
'identifier' => '2025-042',
|
||||||
|
'orientation' => 'BD',
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = $this->buildHtml($thesis);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Test Title', $html);
|
||||||
|
$this->assertStringContainsString('Alice', $html);
|
||||||
|
$this->assertStringContainsString('2025', $html);
|
||||||
|
$this->assertStringContainsString('2025-042', $html);
|
||||||
|
$this->assertStringContainsString('BD', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── HTML escaping ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testBuildHtmlEscapesSpecialCharacters(): void
|
||||||
|
{
|
||||||
|
$thesis = [
|
||||||
|
'identifier' => '2024-999',
|
||||||
|
'title' => '<script>alert("xss")</script>',
|
||||||
|
'authors' => 'Evil & Co.',
|
||||||
|
'year' => '2024',
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = $this->buildHtml($thesis);
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('<script>', $html);
|
||||||
|
$this->assertStringContainsString('<script>', $html);
|
||||||
|
$this->assertStringContainsString('Evil & Co.', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Missing / null optional fields ────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testBuildHtmlHandlesMissingOptionalFields(): void
|
||||||
|
{
|
||||||
|
// Minimal thesis — only title and authors
|
||||||
|
$thesis = [
|
||||||
|
'title' => 'Solo',
|
||||||
|
'authors' => 'One Author',
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = $this->buildHtml($thesis);
|
||||||
|
|
||||||
|
$this->assertIsString($html);
|
||||||
|
$this->assertNotEmpty($html);
|
||||||
|
// Should contain the "–" dash placeholders for empty fields
|
||||||
|
$this->assertStringContainsString('Solo', $html);
|
||||||
|
$this->assertStringContainsString('One Author', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildHtmlHandlesNullFieldsGracefully(): void
|
||||||
|
{
|
||||||
|
$thesis = [
|
||||||
|
'identifier' => null,
|
||||||
|
'title' => 'Null Test',
|
||||||
|
'authors' => null,
|
||||||
|
'year' => null,
|
||||||
|
'subtitle' => null,
|
||||||
|
'orientation' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
$html = $this->buildHtml($thesis);
|
||||||
|
|
||||||
|
$this->assertIsString($html);
|
||||||
|
$this->assertNotEmpty($html);
|
||||||
|
$this->assertStringContainsString('Null Test', $html);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildHtmlHandlesEmptyArray(): void
|
||||||
|
{
|
||||||
|
$html = $this->buildHtml([]);
|
||||||
|
|
||||||
|
$this->assertIsString($html);
|
||||||
|
$this->assertNotEmpty($html);
|
||||||
|
// Should not crash — just fill all fields with "–"
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildHtmlContainsLabelFields(): void
|
||||||
|
{
|
||||||
|
$thesis = ['title' => 'X', 'authors' => 'Y'];
|
||||||
|
|
||||||
|
$html = $this->buildHtml($thesis);
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Identifiant', $html);
|
||||||
|
$this->assertStringContainsString('Titre', $html);
|
||||||
|
$this->assertStringContainsString('Auteur·ice(s)', $html);
|
||||||
|
$this->assertStringContainsString('Année', $html);
|
||||||
|
$this->assertStringContainsString('Mots-clés', $html);
|
||||||
|
}
|
||||||
|
}
|
||||||
195
tests/phpunit/SystemControllerHelpersTest.php
Normal file
195
tests/phpunit/SystemControllerHelpersTest.php
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SystemControllerHelpersTest — Pure logic tests for the static helper methods
|
||||||
|
* in SystemController (humanBytes, diskColor, logLineClass, nginxLineClass,
|
||||||
|
* statusLabel, statusClass).
|
||||||
|
*
|
||||||
|
* These are stateless, no-IO functions.
|
||||||
|
*/
|
||||||
|
class SystemControllerHelpersTest extends TestCase
|
||||||
|
{
|
||||||
|
// ── humanBytes() ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testHumanBytesZero(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('0.0 KB', SystemController::humanBytes(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHumanBytesBelowOneKB(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('1.0 KB', SystemController::humanBytes(1023));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHumanBytesOneKB(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('1.0 KB', SystemController::humanBytes(1024));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHumanBytesOneMB(): void
|
||||||
|
{
|
||||||
|
// threshold is > 1048576, so exactly 1048576 is still KB
|
||||||
|
$this->assertSame('1,024.0 KB', SystemController::humanBytes(1048576));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHumanBytesOneGB(): void
|
||||||
|
{
|
||||||
|
// threshold is > 1073741824, so exactly that is still MB
|
||||||
|
$this->assertSame('1,024.0 MB', SystemController::humanBytes(1073741824));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHumanBytes1523MB(): void
|
||||||
|
{
|
||||||
|
$bytes = (int)(1.5 * 1048576);
|
||||||
|
$this->assertSame('1.5 MB', SystemController::humanBytes($bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testHumanBytes2500GB(): void
|
||||||
|
{
|
||||||
|
$bytes = (int)(2.5 * 1073741824);
|
||||||
|
$this->assertSame('2.5 GB', SystemController::humanBytes($bytes));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── diskColor() ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testDiskColorBelowWarning(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('#4caf50', SystemController::diskColor(0));
|
||||||
|
$this->assertSame('#4caf50', SystemController::diskColor(50));
|
||||||
|
$this->assertSame('#4caf50', SystemController::diskColor(70));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDiskColorWarning(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('#ffc107', SystemController::diskColor(71));
|
||||||
|
$this->assertSame('#ffc107', SystemController::diskColor(85));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDiskColorCritical(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('#e05555', SystemController::diskColor(86));
|
||||||
|
$this->assertSame('#e05555', SystemController::diskColor(99));
|
||||||
|
$this->assertSame('#e05555', SystemController::diskColor(100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── logLineClass() ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testLogLineClassCrit(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('log-crit', SystemController::logLineClass('[crit] Fatal'));
|
||||||
|
$this->assertSame('log-crit', SystemController::logLineClass('[emerg] Emergency'));
|
||||||
|
$this->assertSame('log-crit', SystemController::logLineClass('[alert] Alert'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLineClassError(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('log-error', SystemController::logLineClass('[error] An error'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLineClassWarn(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('log-warn', SystemController::logLineClass('[warn] Warning'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLineClassNotice(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('log-notice', SystemController::logLineClass('[notice] Notice'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLineClassHttp500(): void
|
||||||
|
{
|
||||||
|
// Matches " 500 " pattern in nginx-style log
|
||||||
|
$this->assertSame('log-error', SystemController::logLineClass('GET / " 500 123 "'));
|
||||||
|
$this->assertSame('log-error', SystemController::logLineClass('GET / " 404 123 "'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLineClassHttp300(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('log-notice', SystemController::logLineClass('GET / " 302 456 "'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLogLineClassDefault(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('', SystemController::logLineClass('Just a plain log message'));
|
||||||
|
$this->assertSame('', SystemController::logLineClass('GET / " 200 123 "'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── nginxLineClass() ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testNginxLineClassComment(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('nginx-comment', SystemController::nginxLineClass('# comment'));
|
||||||
|
$this->assertSame('nginx-comment', SystemController::nginxLineClass(' # indented comment'));
|
||||||
|
$this->assertSame('nginx-comment', SystemController::nginxLineClass(''));
|
||||||
|
$this->assertSame('nginx-comment', SystemController::nginxLineClass(' '));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNginxLineClassBlock(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('nginx-block', SystemController::nginxLineClass('server {'));
|
||||||
|
$this->assertSame('nginx-block', SystemController::nginxLineClass('location / {'));
|
||||||
|
$this->assertSame('nginx-block', SystemController::nginxLineClass(' upstream backend {'));
|
||||||
|
$this->assertSame('nginx-block', SystemController::nginxLineClass('events {'));
|
||||||
|
$this->assertSame('nginx-block', SystemController::nginxLineClass('http {'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testNginxLineClassDirective(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('nginx-directive', SystemController::nginxLineClass('listen 80;'));
|
||||||
|
$this->assertSame('nginx-directive', SystemController::nginxLineClass('root /var/www;'));
|
||||||
|
$this->assertSame('nginx-directive', SystemController::nginxLineClass(' server_name example.com;'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── statusLabel() ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testStatusLabelActive(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('● En ligne', SystemController::statusLabel('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusLabelInactive(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('○ Inactif', SystemController::statusLabel('inactive'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusLabelFailed(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('✕ Erreur', SystemController::statusLabel('failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusLabelWarn(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('⚠ Attention', SystemController::statusLabel('warn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusLabelUnknown(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('? Inconnu', SystemController::statusLabel('unknown'));
|
||||||
|
$this->assertSame('? Inconnu', SystemController::statusLabel('bogus'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── statusClass() ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testStatusClassOk(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('status-ok', SystemController::statusClass('active'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusClassWarn(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('status-warn', SystemController::statusClass('inactive'));
|
||||||
|
$this->assertSame('status-warn', SystemController::statusClass('warn'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusClassError(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('status-err', SystemController::statusClass('failed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testStatusClassUnknown(): void
|
||||||
|
{
|
||||||
|
$this->assertSame('status-unknown', SystemController::statusClass('bogus'));
|
||||||
|
}
|
||||||
|
}
|
||||||
199
tests/phpunit/TfeControllerOgTest.php
Normal file
199
tests/phpunit/TfeControllerOgTest.php
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TfeControllerOgTest — Pure logic tests for TfeController::buildOgTags()
|
||||||
|
* and buildMetaDescription().
|
||||||
|
*
|
||||||
|
* These methods are protected; we use a thin subclass to expose them.
|
||||||
|
*/
|
||||||
|
class TfeControllerOgTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Subclass that exposes protected methods for testing.
|
||||||
|
*/
|
||||||
|
private static function makeController(): object
|
||||||
|
{
|
||||||
|
return new class extends TfeController {
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// Skip parent constructor — we don't need DB for these pure methods
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exposedBuildOgTags(array $data, int $thesisId, string $metaDescription): array
|
||||||
|
{
|
||||||
|
return $this->buildOgTags($data, $thesisId, $metaDescription);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exposedBuildMetaDescription(string $synopsis): string
|
||||||
|
{
|
||||||
|
return $this->buildMetaDescription($synopsis);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function exposedResolveOgImage(array $files): string
|
||||||
|
{
|
||||||
|
return $this->resolveOgImage($files);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildOgTags() ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testBuildOgTagsReturnsAllRequiredKeys(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = [
|
||||||
|
'title' => 'My Thesis',
|
||||||
|
'authors' => 'Jane Doe',
|
||||||
|
'year' => '2024',
|
||||||
|
'files' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 42, 'A description');
|
||||||
|
|
||||||
|
$requiredKeys = ['type', 'title', 'description', 'url', 'image', 'image_alt', 'site_name', 'article_author', 'article_published_time'];
|
||||||
|
foreach ($requiredKeys as $key) {
|
||||||
|
$this->assertArrayHasKey($key, $tags);
|
||||||
|
$this->assertIsString($tags[$key]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsTitleIncludesAuthors(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = [
|
||||||
|
'title' => 'The Title',
|
||||||
|
'authors' => 'Author Name',
|
||||||
|
'year' => '2024',
|
||||||
|
'files' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('The Title – Author Name', $tags['title']);
|
||||||
|
|
||||||
|
// Without authors, just the title
|
||||||
|
$data['authors'] = '';
|
||||||
|
$tags2 = $ctrl->exposedBuildOgTags($data, 1, 'desc');
|
||||||
|
$this->assertSame('The Title', $tags2['title']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsImageEmptyWhenNoFiles(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = [
|
||||||
|
'title' => 'No Files',
|
||||||
|
'authors' => 'A',
|
||||||
|
'year' => '2023',
|
||||||
|
'files' => [],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 5, 'desc');
|
||||||
|
$this->assertSame('', $tags['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsImageFromCover(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = [
|
||||||
|
'title' => 'Cover Test',
|
||||||
|
'authors' => 'Author',
|
||||||
|
'year' => '2024',
|
||||||
|
'files' => [
|
||||||
|
['file_path' => 'documents/2024-001/cover.jpg', 'file_type' => 'cover'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('cover.jpg', $tags['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsImageFallbackToFirstImage(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = [
|
||||||
|
'title' => 'Image Test',
|
||||||
|
'authors' => 'B',
|
||||||
|
'year' => '2024',
|
||||||
|
'files' => [
|
||||||
|
['file_path' => 'documents/2024-001/doc.pdf', 'file_type' => 'tfe'],
|
||||||
|
['file_path' => 'documents/2024-001/screenshot.png', 'file_type' => 'tfe'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 2, 'desc');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('screenshot.png', $tags['image']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsUrlIncludesThesisId(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = ['title' => 'URL Test', 'authors' => '', 'year' => '2024', 'files' => []];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 99, 'desc');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('tfe?id=99', $tags['url']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsPublishedTimeFormatted(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = ['title' => 'T', 'authors' => '', 'year' => '2025', 'files' => []];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
|
||||||
|
|
||||||
|
$this->assertSame('2025-01-01', $tags['article_published_time']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildOgTagsPublishedTimeEmptyWhenNoYear(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$data = ['title' => 'T', 'authors' => '', 'files' => []];
|
||||||
|
|
||||||
|
$tags = $ctrl->exposedBuildOgTags($data, 1, 'desc');
|
||||||
|
|
||||||
|
$this->assertSame('', $tags['article_published_time']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── buildMetaDescription() ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
public function testBuildMetaDescriptionTruncatesLongSynopsis(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$long = str_repeat('abcdefghij', 20); // 200 chars
|
||||||
|
$desc = $ctrl->exposedBuildMetaDescription($long);
|
||||||
|
|
||||||
|
$this->assertLessThanOrEqual(160, strlen($desc));
|
||||||
|
$this->assertStringEndsWith('…', $desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildMetaDescriptionKeepsShortSynopsis(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$desc = $ctrl->exposedBuildMetaDescription('A short synopsis.');
|
||||||
|
|
||||||
|
$this->assertSame('A short synopsis.', $desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildMetaDescriptionEmptySynopsisReturnsDefault(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$desc = $ctrl->exposedBuildMetaDescription('');
|
||||||
|
|
||||||
|
$this->assertStringContainsString('Mémoire', $desc);
|
||||||
|
$this->assertStringContainsString('XAMXAM', $desc);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testBuildMetaDescriptionStripsHtmlTags(): void
|
||||||
|
{
|
||||||
|
$ctrl = self::makeController();
|
||||||
|
$desc = $ctrl->exposedBuildMetaDescription('<p>Hello <b>world</b></p>');
|
||||||
|
|
||||||
|
$this->assertStringNotContainsString('<p>', $desc);
|
||||||
|
$this->assertStringNotContainsString('<b>', $desc);
|
||||||
|
$this->assertStringContainsString('Hello world', $desc);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user