diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index dfd77fd..2bb8c02 100755 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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'); diff --git a/tests/Feature/Phase1SecurityTest.php b/tests/Feature/Phase1SecurityTest.php new file mode 100644 index 0000000..3391fa4 --- /dev/null +++ b/tests/Feature/Phase1SecurityTest.php @@ -0,0 +1,180 @@ +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', 'admin@example.com', function () use (&$failed) { $failed = true; }); + $this->assertTrue($failed); + + $failed = false; + $rule->validate('email', 'admin.user@example.com', 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]; + } +}