Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC
name: Tests

on:
push:
branches: [main, master, "feature/**"]
pull_request:

jobs:
phpunit:
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
php: ["8.3", "8.4"]
name: PHPUnit (PHP ${{ matrix.php }})
steps:
- uses: actions/checkout@v4
- uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: sodium, intl
coverage: none
- run: composer install --no-interaction --no-progress
- run: vendor/bin/phpunit
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC
/vendor/
/composer.lock
/.phpunit.cache/
3 changes: 3 additions & 0 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@
"doctrine/orm": "^2.0|^3.0",
"psr/log": "^3.0"
},
"require-dev": {
"phpunit/phpunit": "^12.0"
},
"autoload": {
"psr-4": {
"Dmstr\\ApiPlatformUtils\\": "src/"
Expand Down
17 changes: 17 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC
# CLI-only test runner for this library — no MySQL/FPM required.
# Usage (on host):
# docker compose run --rm php
# Runs `composer install` then PHPUnit against tests/.
services:
php:
build:
dockerfile_inline: |
FROM php:8.4-cli-alpine
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
RUN install-php-extensions sodium intl
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
working_dir: /repo
volumes:
- .:/repo
command: sh -c "composer install --no-interaction --no-progress && vendor/bin/phpunit"
18 changes: 18 additions & 0 deletions phpunit.dist.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC -->
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="default">
<directory>tests</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory>src</directory>
</include>
</source>
</phpunit>
96 changes: 96 additions & 0 deletions tests/Service/CredentialEncryptionTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
<?php
// file generated with AI assistance: Claude Code - 2026-06-13 23:14:54 UTC

declare(strict_types=1);

namespace Dmstr\ApiPlatformUtils\Tests\Service;

use Dmstr\ApiPlatformUtils\Service\CredentialEncryption;
use PHPUnit\Framework\TestCase;

/**
* Unit tests for {@see CredentialEncryption}.
*
* Security-critical: verifies the XChaCha20-Poly1305 round-trip, key
* validation, nonce randomisation and authenticated-decryption failure modes
* without any framework or filesystem dependency.
*/
final class CredentialEncryptionTest extends TestCase
{
/** A deterministic 32-byte raw key for reproducible tests. */
private const KEY = '0123456789abcdef0123456789abcdef';

public function testEncryptDecryptRoundTrip(): void
{
$service = new CredentialEncryption(self::KEY);
$credentials = ['username' => 'alice', 'password' => 's3cr3t!', 'scopes' => ['read', 'write']];

$cipher = $service->encrypt($credentials);
self::assertNotSame('', $cipher);
self::assertSame($credentials, $service->decrypt($cipher));
}

public function testGenerateKeyProducesUsable32ByteKey(): void
{
$encoded = CredentialEncryption::generateKey();
$raw = base64_decode($encoded, true);

self::assertNotFalse($raw, 'generateKey() must return valid base64');
self::assertSame(SODIUM_CRYPTO_SECRETBOX_KEYBYTES, \strlen($raw));

// A freshly generated key must work for a real round-trip.
$service = new CredentialEncryption($raw);
self::assertSame(['a' => 1], $service->decrypt($service->encrypt(['a' => 1])));
}

public function testConstructorRejectsWrongKeyLength(): void
{
$this->expectException(\InvalidArgumentException::class);
new CredentialEncryption('too-short');
}

public function testDecryptRejectsInvalidBase64(): void
{
$service = new CredentialEncryption(self::KEY);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Invalid base64 encoding');
$service->decrypt('###-not-base64-###');
}

public function testDecryptRejectsTamperedCiphertext(): void
{
$service = new CredentialEncryption(self::KEY);
$cipher = $service->encrypt(['token' => 'abc']);

// Flip the final byte of the authenticated ciphertext.
$raw = base64_decode($cipher, true);
$raw[\strlen($raw) - 1] = $raw[\strlen($raw) - 1] === "\x00" ? "\x01" : "\x00";
$tampered = base64_encode($raw);

$this->expectException(\RuntimeException::class);
$this->expectExceptionMessage('Decryption failed');
$service->decrypt($tampered);
}

public function testDecryptWithWrongKeyFails(): void
{
$cipher = (new CredentialEncryption(self::KEY))->encrypt(['token' => 'abc']);

$otherKey = 'fedcba9876543210fedcba9876543210';
$this->expectException(\RuntimeException::class);
(new CredentialEncryption($otherKey))->decrypt($cipher);
}

public function testEncryptIsNonDeterministicPerNonce(): void
{
$service = new CredentialEncryption(self::KEY);
$payload = ['username' => 'bob'];

self::assertNotSame(
$service->encrypt($payload),
$service->encrypt($payload),
'Each encryption must use a fresh random nonce.'
);
}
}
Loading