Add comprehensive test coverage and update notes
Some checks failed
CI/CD Pipeline / test (push) Successful in 3s
CI/CD Pipeline / deploy (push) Failing after 15s

This commit is contained in:
2026-02-08 19:04:12 +01:00
parent 160430e128
commit 88e4a70f88
43 changed files with 6114 additions and 520 deletions

View File

@@ -94,6 +94,19 @@ it('sends a reset link for valid email', function (): void {
$response->assertJsonStructure(['message']);
});
it('returns validation error when reset link cannot be sent', function (): void {
Password::shouldReceive('sendResetLink')
->once()
->andReturn(Password::INVALID_USER);
$response = $this->postJson('/api/forgot-password', [
'email' => 'missing@example.com',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['email']);
});
it('resets a password with a valid token', function (): void {
$user = User::factory()->create([
'email' => 'reset2@example.com',
@@ -116,6 +129,22 @@ it('resets a password with a valid token', function (): void {
expect(Hash::check('NewPassword123!', $user->password))->toBeTrue();
});
it('returns validation error when reset fails', function (): void {
Password::shouldReceive('reset')
->once()
->andReturn(Password::INVALID_TOKEN);
$response = $this->postJson('/api/reset-password', [
'email' => 'resetfail@example.com',
'password' => 'NewPassword123!',
'password_confirmation' => 'NewPassword123!',
'token' => 'bad-token',
]);
$response->assertStatus(422);
$response->assertJsonValidationErrors(['email']);
});
it('verifies email and redirects to login', function (): void {
$user = User::factory()->unverified()->create();
@@ -133,6 +162,19 @@ it('verifies email and redirects to login', function (): void {
expect($user->hasVerifiedEmail())->toBeTrue();
});
it('rejects invalid email verification hash', function (): void {
$user = User::factory()->unverified()->create();
$url = URL::signedRoute('verification.verify', [
'id' => $user->id,
'hash' => sha1('wrong'),
]);
$response = $this->get($url);
$response->assertStatus(403);
});
it('updates password for authenticated users', function (): void {
$user = User::factory()->create([
'password' => Hash::make('OldPass123!'),

View File

@@ -1,6 +1,9 @@
<?php
use App\Models\Forum;
use App\Models\Post;
use App\Models\Role;
use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
@@ -32,6 +35,365 @@ it('can filter forums by parent exists', function (): void {
$response->assertJsonFragment(['id' => $forum->id]);
});
it('filters forums by parent id and type', function (): void {
$category = Forum::create([
'name' => 'Category 2',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum B',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$response = $this->getJson("/api/forums?parent=/api/forums/{$category->id}");
$response->assertOk();
$response->assertJsonCount(1);
$response->assertJsonFragment(['id' => $forum->id]);
$response = $this->getJson('/api/forums?type=category');
$response->assertOk();
$response->assertJsonFragment(['id' => $category->id]);
});
it('shows forum with last post data', function (): void {
$role = Role::create(['name' => 'ROLE_MEMBER', 'color' => '#00ff00']);
$user = User::factory()->create();
$user->roles()->attach($role);
$user->load('roles');
$category = Forum::create([
'name' => 'Category 3',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum C',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Thread',
'body' => 'Body',
]);
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'Reply',
]);
$response = $this->getJson("/api/forums/{$forum->id}");
$response->assertOk();
$response->assertJsonFragment([
'id' => $forum->id,
'last_post_user_id' => $user->id,
]);
$payload = $response->getData(true);
expect($payload['last_post_user_group_color'])->toBe('#00ff00');
});
it('creates category and shifts positions', function (): void {
Sanctum::actingAs(User::factory()->create());
Forum::create([
'name' => 'Category A',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$response = $this->postJson('/api/forums', [
'name' => 'Category B',
'type' => 'category',
'description' => 'Desc',
]);
$response->assertStatus(201);
$this->assertDatabaseHas('forums', [
'name' => 'Category A',
'position' => 2,
]);
});
it('updates forum parent and description', function (): void {
Sanctum::actingAs(User::factory()->create());
$categoryA = Forum::create([
'name' => 'Category A',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$categoryB = Forum::create([
'name' => 'Category B',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 2,
]);
$forum = Forum::create([
'name' => 'Forum D',
'description' => null,
'type' => 'forum',
'parent_id' => $categoryA->id,
'position' => 1,
]);
$response = $this->patchJson("/api/forums/{$forum->id}", [
'parent' => "/api/forums/{$categoryB->id}",
'description' => 'Updated',
]);
$response->assertOk();
$this->assertDatabaseHas('forums', [
'id' => $forum->id,
'parent_id' => $categoryB->id,
'description' => 'Updated',
]);
});
it('updates forum name and type', function (): void {
Sanctum::actingAs(User::factory()->create());
$category = Forum::create([
'name' => 'Category H',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum H',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$response = $this->patchJson("/api/forums/{$forum->id}", [
'name' => 'Forum H Updated',
'type' => 'forum',
]);
$response->assertOk();
$this->assertDatabaseHas('forums', [
'id' => $forum->id,
'name' => 'Forum H Updated',
]);
});
it('rejects forum update without category parent', function (): void {
Sanctum::actingAs(User::factory()->create());
$category = Forum::create([
'name' => 'Category Z',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum E',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$response = $this->patchJson("/api/forums/{$forum->id}", [
'parent' => null,
]);
$response->assertStatus(422);
$response->assertJsonFragment(['message' => 'Forums must belong to a category.']);
});
it('rejects forum update with non-category parent', function (): void {
Sanctum::actingAs(User::factory()->create());
$category = Forum::create([
'name' => 'Category X',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$parent = Forum::create([
'name' => 'Not Category',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum G',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$response = $this->patchJson("/api/forums/{$forum->id}", [
'parent' => "/api/forums/{$parent->id}",
]);
$response->assertStatus(422);
$response->assertJsonFragment(['message' => 'Parent must be a category.']);
});
it('destroys forum and sets deleted_by', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$forum = Forum::create([
'name' => 'Forum F',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$response = $this->deleteJson("/api/forums/{$forum->id}");
$response->assertStatus(204);
$forum->refresh();
expect($forum->deleted_by)->toBe($user->id);
});
it('reorders with string parent id', function (): void {
Sanctum::actingAs(User::factory()->create());
$parent = Forum::create([
'name' => 'Cat Parent',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$first = Forum::create([
'name' => 'Forum 1',
'description' => null,
'type' => 'forum',
'parent_id' => $parent->id,
'position' => 1,
]);
$second = Forum::create([
'name' => 'Forum 2',
'description' => null,
'type' => 'forum',
'parent_id' => $parent->id,
'position' => 2,
]);
$response = $this->postJson('/api/forums/reorder', [
'parentId' => (string) $parent->id,
'orderedIds' => [$second->id, $first->id],
]);
$response->assertOk();
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
});
it('reorders with empty parent id string', function (): void {
Sanctum::actingAs(User::factory()->create());
$first = Forum::create([
'name' => 'Cat X',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$second = Forum::create([
'name' => 'Cat Y',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 2,
]);
$response = $this->postJson('/api/forums/reorder', [
'parentId' => '',
'orderedIds' => [$second->id, $first->id],
]);
$response->assertOk();
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
});
it('reorders with parent id null string', function (): void {
Sanctum::actingAs(User::factory()->create());
$first = Forum::create([
'name' => 'Cat N1',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$second = Forum::create([
'name' => 'Cat N2',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 2,
]);
$response = $this->postJson('/api/forums/reorder', [
'parentId' => 'null',
'orderedIds' => [$second->id, $first->id],
]);
$response->assertOk();
$this->assertDatabaseHas('forums', ['id' => $second->id, 'position' => 1]);
});
it('creates forum under category and increments position', function (): void {
Sanctum::actingAs(User::factory()->create());
$category = Forum::create([
'name' => 'Category P',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
Forum::create([
'name' => 'Forum P1',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$response = $this->postJson('/api/forums', [
'name' => 'Forum P2',
'type' => 'forum',
'parent' => "/api/forums/{$category->id}",
]);
$response->assertStatus(201);
$this->assertDatabaseHas('forums', [
'name' => 'Forum P2',
'position' => 2,
]);
});
it('rejects forum without category parent', function (): void {
Sanctum::actingAs(User::factory()->create());

View File

@@ -2,6 +2,8 @@
use App\Models\Forum;
use App\Models\Post;
use App\Models\Rank;
use App\Models\Role;
use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
@@ -46,3 +48,88 @@ it('returns portal summary payload', function (): void {
$response->assertJsonFragment(['name' => 'Forum']);
$response->assertJsonFragment(['title' => 'Thread']);
});
it('includes avatar and rank data in portal threads', function (): void {
$rank = Rank::create([
'name' => 'Gold',
'badge_type' => 'image',
'badge_image_path' => 'ranks/gold.png',
]);
$role = Role::create(['name' => 'ROLE_SPECIAL', 'color' => '#ff0000']);
$user = User::factory()->create([
'avatar_path' => 'avatars/u.png',
'rank_id' => $rank->id,
]);
$user->roles()->attach($role);
$category = Forum::create([
'name' => 'Category',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
$thread = Thread::create([
'forum_id' => $forum->id,
'user_id' => $user->id,
'title' => 'Thread',
'body' => 'Body',
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/portal/summary');
$response->assertOk();
$payload = $response->getData(true);
expect($payload['threads'][0]['user_avatar_url'])->not->toBeNull();
expect($payload['threads'][0]['user_rank_badge_url'])->not->toBeNull();
expect($payload['threads'][0]['user_group_color'])->toBe('#ff0000');
});
it('handles empty forum last posts and resolveGroupColor', function (): void {
$user = User::factory()->create();
$user->setRelation('roles', null);
$category = Forum::create([
'name' => 'Category2',
'description' => null,
'type' => 'category',
'parent_id' => null,
'position' => 1,
]);
$forum = Forum::create([
'name' => 'Forum2',
'description' => null,
'type' => 'forum',
'parent_id' => $category->id,
'position' => 1,
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/portal/summary');
$response->assertOk();
$payload = $response->getData(true);
expect($payload['forums'][0]['last_post_user_group_color'])->toBeNull();
});
it('handles summary when no forums exist', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->getJson('/api/portal/summary');
$response->assertOk();
$payload = $response->getData(true);
expect($payload['forums'])->toBe([]);
});

View File

@@ -8,6 +8,30 @@ use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
beforeEach(function (): void {
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
$parserProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Parser::class)
->shouldReceive('parse')
->andReturn('<r/>')
->getMock()
);
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
$rendererProp->setAccessible(true);
$rendererProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
->shouldReceive('render')
->andReturn('<p></p>')
->getMock()
);
});
afterEach(function (): void {
\Mockery::close();
});
function makeThread(): Thread
{
$category = Forum::create([

View File

@@ -90,3 +90,73 @@ it('lists thanks received for a user', function (): void {
'thanker_name' => 'ThanksGiver',
]);
});
it('requires auth to thank and unthank posts', function (): void {
$thread = makeThanksThread();
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => null,
'body' => 'Post',
]);
$this->app['auth']->forgetGuards();
$response = $this->postJson("/api/posts/{$post->id}/thanks");
$response->assertStatus(401);
$this->app['auth']->forgetGuards();
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
$response->assertStatus(401);
});
it('creates and deletes thanks for a post', function (): void {
$thread = makeThanksThread();
$user = User::factory()->create();
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => $user->id,
'body' => 'Post',
]);
Sanctum::actingAs($user);
$response = $this->postJson("/api/posts/{$post->id}/thanks");
$response->assertStatus(201);
$response = $this->deleteJson("/api/posts/{$post->id}/thanks");
$response->assertStatus(204);
});
it('serializes group colors for thanks', function (): void {
$thread = makeThanksThread();
$authorRole = \App\Models\Role::create(['name' => 'ROLE_AUTHOR', 'color' => '#ff0000']);
$thankerRole = \App\Models\Role::create(['name' => 'ROLE_THANKER', 'color' => '#00ff00']);
$author = User::factory()->create(['name' => 'Author']);
$author->roles()->attach($authorRole);
$author->load('roles');
$thanker = User::factory()->create(['name' => 'ThanksGiver']);
$thanker->roles()->attach($thankerRole);
$thanker->load('roles');
$post = Post::create([
'thread_id' => $thread->id,
'user_id' => $author->id,
'body' => 'Helpful post',
]);
PostThank::create([
'post_id' => $post->id,
'user_id' => $thanker->id,
]);
Sanctum::actingAs($thanker);
$response = $this->getJson("/api/user/{$thanker->id}/thanks/given");
$response->assertOk();
$payload = $response->getData(true);
expect($payload[0]['post_author_group_color'])->toBe('#ff0000');
Sanctum::actingAs($author);
$response = $this->getJson("/api/user/{$author->id}/thanks/received");
$response->assertOk();
$payload = $response->getData(true);
expect($payload[0]['thanker_group_color'])->toBe('#00ff00');
});

View File

@@ -1,6 +1,24 @@
<?php
it('renders bbcode preview', function (): void {
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
$parserProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Parser::class)
->shouldReceive('parse')
->andReturn('<r/>')
->getMock()
);
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
$rendererProp->setAccessible(true);
$rendererProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
->shouldReceive('render')
->andReturn('<p></p>')
->getMock()
);
$user = \App\Models\User::factory()->create();
\Laravel\Sanctum\Sanctum::actingAs($user);
@@ -13,6 +31,24 @@ it('renders bbcode preview', function (): void {
});
it('validates preview body', function (): void {
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
$parserProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Parser::class)
->shouldReceive('parse')
->andReturn('<r/>')
->getMock()
);
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
$rendererProp->setAccessible(true);
$rendererProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
->shouldReceive('render')
->andReturn('<p></p>')
->getMock()
);
$user = \App\Models\User::factory()->create();
\Laravel\Sanctum\Sanctum::actingAs($user);
@@ -21,3 +57,7 @@ it('validates preview body', function (): void {
$response->assertStatus(422);
$response->assertJsonValidationErrors(['body']);
});
afterEach(function (): void {
\Mockery::close();
});

View File

@@ -27,6 +27,31 @@ it('lists ranks for authenticated users', function (): void {
$response->assertJsonFragment(['name' => 'Bronze']);
});
it('forbids non-admin rank changes', function (): void {
$user = User::factory()->create();
$rank = Rank::create(['name' => 'Nope']);
Sanctum::actingAs($user);
$response = $this->postJson('/api/ranks', [
'name' => 'Silver',
]);
$response->assertStatus(403);
$response = $this->patchJson("/api/ranks/{$rank->id}", [
'name' => 'Nope',
]);
$response->assertStatus(403);
$response = $this->deleteJson("/api/ranks/{$rank->id}");
$response->assertStatus(403);
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
]);
$response->assertStatus(403);
});
it('creates ranks as admin', function (): void {
$admin = makeAdminForRanks();
Sanctum::actingAs($admin);
@@ -45,6 +70,22 @@ it('creates ranks as admin', function (): void {
]);
});
it('creates ranks with none badge type', function (): void {
$admin = makeAdminForRanks();
Sanctum::actingAs($admin);
$response = $this->postJson('/api/ranks', [
'name' => 'NoBadge',
'badge_type' => 'none',
]);
$response->assertStatus(201);
$response->assertJsonFragment([
'name' => 'NoBadge',
'badge_text' => null,
]);
});
it('updates ranks and clears badge images when switching to text', function (): void {
Storage::fake('public');
@@ -71,6 +112,47 @@ it('updates ranks and clears badge images when switching to text', function ():
Storage::disk('public')->assertMissing('rank-badges/old.png');
});
it('updates ranks with badge_type none', function (): void {
$admin = makeAdminForRanks();
$rank = Rank::create([
'name' => 'Plain',
'badge_type' => 'text',
'badge_text' => 'P',
]);
Sanctum::actingAs($admin);
$response = $this->patchJson("/api/ranks/{$rank->id}", [
'name' => 'Plain',
'badge_type' => 'none',
]);
$response->assertOk();
$response->assertJsonFragment(['badge_text' => null]);
});
it('updates ranks to image badge and keeps existing image', function (): void {
Storage::fake('public');
$admin = makeAdminForRanks();
$rank = Rank::create([
'name' => 'ImageRank',
'badge_type' => 'image',
'badge_text' => null,
'badge_image_path' => 'rank-badges/existing.png',
]);
Storage::disk('public')->put('rank-badges/existing.png', 'existing');
Sanctum::actingAs($admin);
$response = $this->patchJson("/api/ranks/{$rank->id}", [
'name' => 'ImageRank',
'badge_type' => 'image',
]);
$response->assertOk();
Storage::disk('public')->assertExists('rank-badges/existing.png');
});
it('uploads a rank badge image', function (): void {
Storage::fake('public');
@@ -86,6 +168,48 @@ it('uploads a rank badge image', function (): void {
$response->assertJsonFragment(['badge_type' => 'image']);
});
it('includes badge image url in rank list when present', function (): void {
Storage::fake('public');
Storage::disk('public')->put('rank-badges/show.png', 'img');
$user = User::factory()->create();
Rank::create([
'name' => 'WithImage',
'badge_type' => 'image',
'badge_image_path' => 'rank-badges/show.png',
]);
Sanctum::actingAs($user);
$response = $this->getJson('/api/ranks');
$response->assertOk();
$response->assertJsonFragment([
'name' => 'WithImage',
]);
expect($response->getData(true)[0]['badge_image_url'])->not->toBeNull();
});
it('uploads badge image replaces existing one', function (): void {
Storage::fake('public');
$admin = makeAdminForRanks();
$rank = Rank::create([
'name' => 'Replace',
'badge_type' => 'image',
'badge_image_path' => 'rank-badges/old.png',
]);
Storage::disk('public')->put('rank-badges/old.png', 'old');
Sanctum::actingAs($admin);
$response = $this->postJson("/api/ranks/{$rank->id}/badge-image", [
'file' => UploadedFile::fake()->image('badge.png', 50, 50),
]);
$response->assertOk();
Storage::disk('public')->assertMissing('rank-badges/old.png');
});
it('deletes ranks as admin', function (): void {
Storage::fake('public');

View File

@@ -42,6 +42,17 @@ it('creates normalized roles as admin', function (): void {
]);
});
it('lists roles for admins', function (): void {
$admin = makeAdminForRoles();
Role::create(['name' => 'ROLE_ALPHA', 'color' => '#111111']);
Sanctum::actingAs($admin);
$response = $this->getJson('/api/roles');
$response->assertOk();
$response->assertJsonFragment(['name' => 'ROLE_ALPHA']);
});
it('prevents renaming core roles', function (): void {
$admin = makeAdminForRoles();
$core = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
@@ -56,6 +67,49 @@ it('prevents renaming core roles', function (): void {
$response->assertJsonFragment(['message' => 'Core roles cannot be renamed.']);
});
it('prevents creating duplicate roles after normalization', function (): void {
$admin = makeAdminForRoles();
Role::create(['name' => 'ROLE_TEST', 'color' => '#111111']);
Sanctum::actingAs($admin);
$response = $this->postJson('/api/roles', [
'name' => 'test',
'color' => '#222222',
]);
$response->assertStatus(422);
$response->assertJsonFragment(['message' => 'Role already exists.']);
});
it('updates role color when provided and keeps name', function (): void {
$admin = makeAdminForRoles();
$role = Role::create(['name' => 'ROLE_EDIT', 'color' => '#111111']);
Sanctum::actingAs($admin);
$response = $this->patchJson("/api/roles/{$role->id}", [
'name' => 'ROLE_EDIT',
'color' => '#222222',
]);
$response->assertOk();
$response->assertJsonFragment(['color' => '#222222']);
});
it('prevents updating to duplicate normalized name', function (): void {
$admin = makeAdminForRoles();
$first = Role::create(['name' => 'ROLE_FIRST', 'color' => '#111111']);
$second = Role::create(['name' => 'ROLE_SECOND', 'color' => '#111111']);
Sanctum::actingAs($admin);
$response = $this->patchJson("/api/roles/{$second->id}", [
'name' => 'first',
'color' => '#111111',
]);
$response->assertStatus(422);
$response->assertJsonFragment(['message' => 'Role already exists.']);
});
it('prevents deleting core roles', function (): void {
$admin = makeAdminForRoles();
$core = Role::firstOrCreate(['name' => 'ROLE_USER'], ['color' => '#111111']);
@@ -90,3 +144,37 @@ it('deletes non-core roles without assignments', function (): void {
$response->assertStatus(204);
$this->assertDatabaseMissing('roles', ['id' => $role->id]);
});
it('forbids non-admin create update delete', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/roles', [
'name' => 'helper',
'color' => '#111111',
]);
$response->assertStatus(403);
$role = Role::create(['name' => 'ROLE_TEMP', 'color' => '#111111']);
$response = $this->patchJson("/api/roles/{$role->id}", [
'name' => 'ROLE_TEMP',
'color' => '#222222',
]);
$response->assertStatus(403);
$response = $this->deleteJson("/api/roles/{$role->id}");
$response->assertStatus(403);
});
it('normalizes invalid role names to ROLE_', function (): void {
$admin = makeAdminForRoles();
Sanctum::actingAs($admin);
$response = $this->postJson('/api/roles', [
'name' => '!!!',
'color' => '#111111',
]);
$response->assertStatus(201);
$response->assertJsonFragment(['name' => 'ROLE_']);
});

View File

@@ -87,3 +87,16 @@ it('bulk stores settings as admin', function (): void {
'value' => 'Fast',
]);
});
it('bulk store forbids non-admin users', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$response = $this->postJson('/api/settings/bulk', [
'settings' => [
['key' => 'site.name', 'value' => 'SpeedBB'],
],
]);
$response->assertStatus(403);
});

View File

@@ -0,0 +1,462 @@
<?php
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Laravel\Sanctum\Sanctum;
function makeAdminForSystemUpdate(): User
{
$admin = User::factory()->create();
$role = Role::firstOrCreate(['name' => 'ROLE_ADMIN'], ['color' => '#111111']);
$admin->roles()->attach($role);
return $admin;
}
function withFakeBin(array $scripts, callable $callback): void
{
$dir = storage_path('app/test-bin-' . Str::random(6));
if (!is_dir($dir)) {
mkdir($dir, 0777, true);
}
foreach ($scripts as $name => $body) {
$path = $dir . DIRECTORY_SEPARATOR . $name;
file_put_contents($path, $body);
chmod($path, 0755);
}
$originalPath = getenv('PATH') ?: '';
putenv("PATH={$dir}");
$_ENV['PATH'] = $dir;
$_SERVER['PATH'] = $dir;
try {
$callback();
} finally {
putenv("PATH={$originalPath}");
$_ENV['PATH'] = $originalPath;
$_SERVER['PATH'] = $originalPath;
if (is_dir($dir)) {
$items = scandir($dir);
if (is_array($items)) {
foreach ($items as $item) {
if ($item === '.' || $item === '..') {
continue;
}
$path = $dir . DIRECTORY_SEPARATOR . $item;
if (is_file($path)) {
unlink($path);
}
}
}
rmdir($dir);
}
}
}
it('uses token auth header and tarball template', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
putenv('GITEA_TGZ_URL_TEMPLATE=https://git.example.test/tarball/{{TAG}}-{{VERSION}}.tgz');
putenv('GITEA_TOKEN=secrettoken');
$tarballUrl = 'https://git.example.test/tarball/v1.2.3-1.2.3.tgz';
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => '',
], 200),
$tarballUrl => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan');
$originalArtisan = file_get_contents($artisanPath);
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
chmod($artisanPath, 0755);
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void {
try {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertOk();
$response->assertJsonFragment(['tag' => 'v1.2.3']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
});
Http::assertSent(function ($request) use ($tarballUrl) {
if ($request->url() === $tarballUrl) {
return true;
}
return $request->hasHeader('Authorization', 'token secrettoken');
});
});
it('returns update failed on unexpected exception', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake(function () {
throw new RuntimeException('boom');
});
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Update failed.']);
});
it('handles release check failures', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([], 500),
]);
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Release check failed: 500']);
});
it('handles missing tag in release response', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => '',
], 200),
]);
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Release tag not found.']);
});
it('handles missing tarball url', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
putenv('GITEA_TGZ_URL_TEMPLATE=');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => '',
], 200),
]);
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'No tarball URL available.']);
});
it('handles tarball download failure', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('fail', 500),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Download failed: 500']);
});
it('handles extract failure', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
withFakeBin([
'tar' => "#!/bin/sh\nexit 1\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Failed to extract archive.']);
});
});
it('handles missing extracted folder', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn([]);
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'No extracted folder found.']);
});
});
it('handles rsync failure when available', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'rsync' => "#!/bin/sh\nexit 1\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'rsync failed.']);
});
});
it('handles composer install failure after copyDirectory', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 1\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Composer install failed.']);
});
});
it('handles npm install failure', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nif [ \"$1\" = \"install\" ]; then exit 1; fi\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'npm install failed.']);
});
});
it('handles npm build failure', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nif [ \"$1\" = \"run\" ] && [ \"$2\" = \"build\" ]; then exit 1; fi\nexit 0\n",
], function (): void {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'npm run build failed.']);
});
});
it('handles migration failure', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan');
$originalArtisan = file_get_contents($artisanPath);
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(1);\n");
chmod($artisanPath, 0755);
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void {
try {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertStatus(500);
$response->assertJsonFragment(['message' => 'Migrations failed.']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
});
});
it('handles fallback copyDirectory update success', function (): void {
putenv('GITEA_OWNER=acme');
putenv('GITEA_REPO=speedbb');
putenv('GITEA_API_BASE=https://git.example.test/api/v1');
Http::fake([
'https://git.example.test/api/v1/repos/acme/speedbb/releases/latest' => Http::response([
'tag_name' => 'v1.2.3',
'tarball_url' => 'https://git.example.test/archive.tgz',
], 200),
'https://git.example.test/archive.tgz' => Http::response('archive-bytes', 200),
]);
File::shouldReceive('ensureDirectoryExists')->andReturnTrue();
File::shouldReceive('put')->andReturnTrue();
File::shouldReceive('directories')->andReturn(['/tmp/extract/speedbb']);
File::shouldReceive('copyDirectory')->andReturnTrue();
$artisanPath = base_path('artisan');
$originalArtisan = file_get_contents($artisanPath);
file_put_contents($artisanPath, "#!/usr/bin/env php\n<?php exit(0);\n");
chmod($artisanPath, 0755);
withFakeBin([
'tar' => "#!/bin/sh\nexit 0\n",
'composer' => "#!/bin/sh\nexit 0\n",
'npm' => "#!/bin/sh\nexit 0\n",
], function () use ($artisanPath, $originalArtisan): void {
try {
Sanctum::actingAs(makeAdminForSystemUpdate());
$response = $this->postJson('/api/system/update');
$response->assertOk();
$response->assertJsonFragment(['message' => 'Update finished.']);
$response->assertJsonStructure(['used_rsync']);
} finally {
file_put_contents($artisanPath, $originalArtisan);
}
});
});

View File

@@ -2,6 +2,8 @@
use App\Models\Role;
use App\Models\User;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Http;
use Laravel\Sanctum\Sanctum;
it('forbids system update for non-admins', function (): void {

View File

@@ -6,6 +6,30 @@ use App\Models\Thread;
use App\Models\User;
use Laravel\Sanctum\Sanctum;
beforeEach(function (): void {
$parserProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'parser');
$parserProp->setAccessible(true);
$parserProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Parser::class)
->shouldReceive('parse')
->andReturn('<r/>')
->getMock()
);
$rendererProp = new ReflectionProperty(\App\Actions\BbcodeFormatter::class, 'renderer');
$rendererProp->setAccessible(true);
$rendererProp->setValue(
\Mockery::mock(\s9e\TextFormatter\Renderer::class)
->shouldReceive('render')
->andReturn('<p></p>')
->getMock()
);
});
afterEach(function (): void {
\Mockery::close();
});
function makeForum(): Forum
{
$category = Forum::create([
@@ -80,7 +104,7 @@ it('requires authentication to update a thread', function (): void {
'forum_id' => $forum->id,
'user_id' => $owner->id,
'title' => 'Original',
'body' => 'Body',
'body' => '',
]);
$response = $this->patchJson("/api/threads/{$thread->id}", [
@@ -100,7 +124,7 @@ it('enforces thread update permissions', function (): void {
'forum_id' => $forum->id,
'user_id' => $owner->id,
'title' => 'Original',
'body' => 'Body',
'body' => '',
]);
Sanctum::actingAs($other);
@@ -151,7 +175,7 @@ it('enforces solved status permissions', function (): void {
'forum_id' => $forum->id,
'user_id' => $owner->id,
'title' => 'Original',
'body' => 'Body',
'body' => '',
'solved' => false,
]);
@@ -182,14 +206,14 @@ it('filters threads by forum', function (): void {
'forum_id' => $forumA->id,
'user_id' => null,
'title' => 'Thread A',
'body' => 'Body A',
'body' => '',
]);
Thread::create([
'forum_id' => $forumB->id,
'user_id' => null,
'title' => 'Thread B',
'body' => 'Body B',
'body' => '',
]);
$response = $this->getJson("/api/threads?forum=/api/forums/{$forumA->id}");
@@ -208,7 +232,7 @@ it('increments views count when showing a thread', function (): void {
'forum_id' => $forum->id,
'user_id' => null,
'title' => 'Viewed Thread',
'body' => 'Body',
'body' => '',
'views_count' => 0,
]);