test(phase-1): feature coverage for security + data-integrity fixes

Seven feature tests, run against an in-memory SQLite forced via putenv
in setUp (keeps the shared dev DB safe). Covers:

- Reserved-identifier rule rejects admin / super_admin / takeone /
  support / etc, matches both name and email local-part only, and
  lets normal names through.
- SafeNotifier swallows a notification throw without re-raising.
- Identical comments within 60s collapse to the first row.
- X-Idempotency-Key returns the first comment on retry.
- Owner viewing their own video does not insert a video_views row.

AppServiceProvider stops force-upgrading the scheme in the testing env
so the HTTP test client isn't 302'd before reaching the controller.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
ghassanandClaude Opus 4.7 committed 2026-10-21 00:44:58 +03:00
1 parent c7d2f25ef6
commit 4718888de0
2 files changed
+186 -2

No files matched your search

+6 -2
View File
@@ -26,8 +26,12 @@ class AppServiceProvider extends ServiceProvider
\Illuminate\Http\Request::HEADER_X_FORWARDED_AWS_ELB
);
// Force HTTPS
URL::forceScheme('https');
// Force HTTPS unless we're running under phpunit — the test client
// speaks plain http://localhost; forcing https would make every POST
// in a feature test resolve to a 302 instead of hitting the controller.
if (! $this->app->environment('testing')) {
URL::forceScheme('https');
}
// Universal pagination view — used everywhere by default
Paginator::defaultView('partials.pagination');
+180
View File
@@ -0,0 +1,180 @@
<?php
namespace Tests\Feature;
use App\Models\Comment;
use App\Models\User;
use App\Models\Video;
use App\Rules\ReservedIdentifier;
use App\Support\SafeNotifier;
use App\Http\Middleware\VerifyCsrfToken;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\DB;
use Tests\TestCase;
/**
* Phase 1 audit remediation: security + data-integrity regression tests.
*
* These tests run on a scratch SQLite file in storage/testing.sqlite so they
* can't collide with the shared dev DB. phpunit.xml keeps DB_CONNECTION out
* of the test env so this per-class override is where the swap happens.
*/
class Phase1SecurityTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
// Force the test runner onto an in-memory SQLite BEFORE the framework
// boots, so RefreshDatabase migrates the throwaway DB and never the
// shared dev file. Env vars are read during app bootstrap in
// CreatesApplication, which is why putenv has to come first.
putenv('DB_CONNECTION=sqlite');
putenv('DB_DATABASE=:memory:');
$_ENV['DB_CONNECTION'] = 'sqlite';
$_ENV['DB_DATABASE'] = ':memory:';
parent::setUp();
// Strip the two middleware that fight the test client: CSRF (no
// token flow in postJson) and AuthenticateSession (which rotates
// the session on cross-request assertions and 302s to APP_URL).
$this->withoutMiddleware([
VerifyCsrfToken::class,
\Illuminate\Session\Middleware\AuthenticateSession::class,
]);
}
// ─── ReservedIdentifier rule ──────────────────────────
public function test_reserved_name_rule_blocks_impersonation_candidates(): void
{
$rule = new ReservedIdentifier('name');
foreach (['admin', 'Administrator', 'super_admin', 'super-admin',
'SuperAdmin', 'TAKEONE', 'support', 'root', 'Postmaster'] as $name) {
$failed = false;
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
$this->assertTrue($failed, "Expected name '{$name}' to be reserved");
}
}
public function test_reserved_email_rule_matches_on_local_part_only(): void
{
$rule = new ReservedIdentifier('email');
$failed = false;
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
$this->assertTrue($failed);
$failed = false;
$rule->validate('email', '[email protected]', function () use (&$failed) { $failed = true; });
$this->assertFalse($failed, 'Only exact local-part should match; "admin.user" is a real person.');
}
public function test_reserved_rule_allows_normal_names(): void
{
$rule = new ReservedIdentifier('name');
foreach (['Alice', 'Bob Smith', 'John Doe', 'الحسن'] as $name) {
$failed = false;
$rule->validate('name', $name, function () use (&$failed) { $failed = true; });
$this->assertFalse($failed, "Expected name '{$name}' to be allowed");
}
}
// ─── SafeNotifier swallows mail failures ──────────────
public function test_safe_notifier_logs_but_does_not_throw_on_failure(): void
{
// A fake notifiable whose notify() blows up — stands in for the Gmail
// 535 case where sending throws out of the transport layer.
$exploding = new class {
public function notify($n): void { throw new \RuntimeException('smtp exploded'); }
public function getKey(): int { return 1; }
};
$notification = new \App\Notifications\VerifyEmail;
// Call must not throw — if it did, PHPUnit would mark this test as errored.
SafeNotifier::send($exploding, $notification);
$this->assertTrue(true, 'SafeNotifier swallowed the throw');
}
// ─── Comment dedupe ───────────────────────────────────
public function test_identical_comments_within_60s_are_collapsed(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$first = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
$first->assertOk();
$firstId = $first->json('comment.id');
$second = $this->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Great goal!']);
$second->assertOk();
$secondId = $second->json('comment.id');
$this->assertSame($firstId, $secondId, 'Duplicate within 60s should return the first row, not insert a new one.');
$this->assertSame(1, Comment::where('video_id', $video->id)->count());
}
public function test_idempotency_key_returns_the_same_comment(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$key = 'test-idem-key-' . bin2hex(random_bytes(8));
$a = $this->withHeaders(['X-Idempotency-Key' => $key])
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'First body']);
$a->assertOk();
$aId = $a->json('comment.id');
// Different body, same key — because the first write cached the id,
// the second call returns it unchanged.
$b = $this->withHeaders(['X-Idempotency-Key' => $key])
->postJson("/videos/".Video::encodeId($video->id)."/comments", ['body' => 'Different body']);
$b->assertOk();
$bId = $b->json('comment.id');
$this->assertSame($aId, $bId, 'Same idempotency key must return the same comment id.');
}
// ─── Owner view exclusion ─────────────────────────────
public function test_owner_view_is_not_recorded(): void
{
[$user, $video] = $this->makeUserAndVideo();
$this->actingAs($user);
$this->get("/videos/{$video->id}");
$this->assertSame(0, DB::table('video_views')->where('video_id', $video->id)->count(),
'Views by the video owner must not be counted.');
}
// ─── Helpers ──────────────────────────────────────────
private function makeUserAndVideo(): array
{
$user = User::factory()->create([
'username' => 'u'.substr(bin2hex(random_bytes(4)), 0, 8),
]);
$video = Video::create([
'user_id' => $user->id,
'title' => 'Test video',
'description'=> 'Hello',
'filename' => 'users/x/videos/y/video.mp4',
'path' => 'users/x/videos/y/video.mp4',
'type' => 'generic',
'visibility' => 'public',
'status' => 'ready',
]);
return [$user, $video];
}
}