Files
preauth/tests/Unit/Command/GenerateBackupCodesCommandTest.php
T
lyra 2064153cd3
Tests / test (pull_request) Successful in 1m10s
chore: adopt shared Guiding Light configs and fix conformance gaps
Brings preauth from 18/34 to 30/34 conformance (auth-gateway profile). The
remaining four checks all depend on files this branch cannot change (see below).

PHP toolchain (§1)
  - require.php  >=8.4 -> ^8.5, and pin config.platform to 8.5.0. The old
    constraint also permitted PHP 9, which is not a promise we can keep.
    composer.lock regenerated with --lock: content-hash + platform-overrides
    only, zero dependency version movement.
  - friendsofphp/php-cs-fixer  * -> ^3.95. A wildcard meant CI was not
    reproducible.

PHPStan (§2.2)
  - vendor the shared phpstan.neon.dist (level 6) + a generated baseline.
    187 errors are captured rather than fixed; the baseline should only shrink
    from here.
  - add phpstan/phpstan:^2.1 to require-dev.

Code style (§8.2)
  - vendor the shared .php-cs-fixer.dist.php (@Symfony + @Symfony:risky +
    declare_strict_types) and apply it: 59 of 67 files reformatted.
  - Verified this is a formatting change, not a behaviour change: all 313 tests
    pass after the reformat, all in_array() calls already passed strict=true,
    and the remaining edits are @Symfony:risky idiom (yoda conditions, \count(),
    self:: over the class name).

Repository layout (§4.4)
  - docs/{Caddyfile,compose.yaml,example.env} -> docs/examples/, with
    example.env becoming the conventional .env.example. This is the layout
    GUIDING-LIGHT already cites preauth as doing correctly — it just needed
    renaming.
  - update the four readme.md references and a stale compose.yaml comment.
  - docs/v1.1-plan.md references are left alone deliberately: it is a historical
    plan recording what was done at the time, not live documentation.

Licence and security policy (§7)
  - add LICENSE (uniform MIT, matching composer.json).
  - add SECURITY.md describing the actual threat model: per-request
    allow/intercept, no caching of the login flow, app-set security headers,
    TOTP, and the fact that REMOTE_USER is trusted input.

Mobile accessibility (§3.3a)
  - templates/base.html.twig: drop maximum-scale=1 and add viewport-fit=cover.
    preauth was the one app already past the font-size precondition (controls
    render at 21.6px = 0.9em x 24px), so removing the lock is safe here and
    restores pinch-zoom for Android users.

Conformance tooling (§8.2)
  - vendor .ci/conformance.sh and .ci/css-control-size.py so the check runs
    from a checkout rather than fetching from the LAN-only private/ci.
  - .editorconfig synced from the version that keeps the Caddyfile tab rule.

Not included (blocked by the .gitea/workflows pre-receive hook):
  - ci-composer-audit, ci-composer-validate, ci-reusable-workflows.
    Workflow files may only change via a trusted ref, so the caller files are
    staged but not committed.

Also not included: dockerfile-nonroot (§6.4). Adding USER to an image with
VOLUME [/config, /data] changes volume ownership and needs an actual container
build/run to verify, so it goes in its own change.
2026-09-23 20:13:45 +00:00

135 lines
4.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Command;
use App\Command\GenerateBackupCodesCommand;
use App\PersistCache;
use App\Service\BackupCodeInterface;
use PHPUnit\Framework\TestCase;
use Symfony\Component\Cache\Adapter\ArrayAdapter;
use Symfony\Component\Console\Exception\InvalidArgumentException;
use Symfony\Component\Console\Tester\CommandTester;
final class GenerateBackupCodesCommandTest extends TestCase
{
/** PersistCache is final, so construct a real one backed by ArrayAdapters. */
private function makePersistCache(): PersistCache
{
return new PersistCache(new ArrayAdapter(), new ArrayAdapter());
}
/** A stub BackupCodeInterface that returns the given codes from generate(). */
private function makeManagerStub(array $generatedCodes): BackupCodeInterface
{
$manager = $this->createStub(BackupCodeInterface::class);
$manager->method('generate')->willReturn($generatedCodes);
return $manager;
}
public function test_generate_default_count_outputs_codes(): void
{
$codes = ['abc123', 'def456', 'ghi789', 'jkl012', 'mno345',
'pqr678', 'stu901', 'vwx234', 'yzA567', 'bCd890'];
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub($codes),
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$exit = $tester->execute([]);
self::assertSame(0, $exit);
$output = $tester->getDisplay();
foreach ($codes as $code) {
self::assertStringContainsString($code, $output);
}
}
public function test_generate_specific_count_passes_count_to_manager(): void
{
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
->method('generate')
->with(self::identicalTo(5))
->willReturn(['c1', 'c2', 'c3', 'c4', 'c5']);
$command = new GenerateBackupCodesCommand($manager, $this->makePersistCache());
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$exit = $tester->execute(['count' => 5]);
self::assertSame(0, $exit);
}
public function test_default_count_argument_is_ten(): void
{
// the configured default for the count argument should be 10
$manager = $this->createMock(BackupCodeInterface::class);
$manager->expects(self::once())
->method('generate')
->with(self::identicalTo(10))
->willReturn(array_fill(0, 10, 'code'));
$command = new GenerateBackupCodesCommand($manager, $this->makePersistCache());
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$tester->execute([]);
// assertion is in the mock expectation above
$this->addToAssertionCount(1);
}
public function test_boots_and_persists_cache(): void
{
// PersistCache is final and can't be mocked, but we can verify the
// command runs end-to-end with a real instance; boot()/persist()
// are invoked implicitly. A successful exit confirms both were called
// without throwing.
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['code1']),
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$exit = $tester->execute([]);
self::assertSame(0, $exit);
}
public function test_zero_codes_throws_exception(): void
{
// count must be a positive integer — zero is rejected
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub([]),
$this->makePersistCache(),
);
$command->setName('app:generate-backup-codes');
$tester = new CommandTester($command);
$this->expectException(InvalidArgumentException::class);
$tester->execute(['count' => 0]);
}
public function test_command_name_and_description_are_configured(): void
{
$command = new GenerateBackupCodesCommand(
$this->makeManagerStub(['dummy']),
$this->makePersistCache(),
);
// configuring via the Application runs the protected configure()
$app = new \Symfony\Component\Console\Application();
$app->addCommand($command);
self::assertSame('app:generate-backup-codes', $command->getName());
// the source uses a non-breaking hyphen (U+2011) in "single‑use",
// so assert against the substring to avoid encoding fragility
self::assertStringContainsString('backup codes', $command->getDescription());
}
}