refactor: kb & station & terminal

This commit is contained in:
2026-03-23 20:17:17 +08:00
parent 63ea2686e1
commit b74ba1a3f8
81 changed files with 1016 additions and 2492 deletions

View File

@@ -1,138 +0,0 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
/**
* Feature: knowledge-base-system, Property 7: 用户文档列表权限过滤
*
* 对于任何用户,当查询其可访问的文档列表时,返回的结果应该只包含:
* (1) 所有全局知识库文档,以及 (2) 该用户所属分组的专用知识库文档
*
* Validates: Requirements 3.1, 3.2, 3.3
*/
test('property 7: 用户可访问的文档列表应该包含所有全局文档和用户分组的专用文档', function () {
// 运行 100 次迭代
for ($i = 0; $i < 100; $i++) {
// 创建随机数量的分组1-5个
$groupCount = rand(1, 5);
$groups = Group::factory()->count($groupCount)->create();
// 创建一个测试用户
$user = User::factory()->create();
// 随机将用户分配到一些分组0-3个
$userGroupCount = rand(0, min(3, $groupCount));
$userGroups = $groups->random(min($userGroupCount, $groupCount));
$user->groups()->attach($userGroups->pluck('id'));
// 创建随机数量的全局文档1-10个
$globalDocCount = rand(1, 10);
$globalDocs = Document::factory()->count($globalDocCount)->global()->create();
// 为每个分组创建随机数量的专用文档0-5个
$dedicatedDocs = collect();
foreach ($groups as $group) {
$docCount = rand(0, 5);
$docs = Document::factory()->count($docCount)->dedicated($group->id)->create();
$dedicatedDocs = $dedicatedDocs->merge($docs);
}
// 执行查询:获取用户可访问的文档
$accessibleDocs = Document::accessibleBy($user)->get();
// 验证:所有全局文档都应该在结果中
foreach ($globalDocs as $globalDoc) {
expect($accessibleDocs->contains('id', $globalDoc->id))
->toBeTrue("全局文档 {$globalDoc->id} 应该对用户可见");
}
// 验证:用户所属分组的专用文档都应该在结果中
$userGroupIds = $userGroups->pluck('id')->toArray();
$userDedicatedDocs = $dedicatedDocs->whereIn('group_id', $userGroupIds);
foreach ($userDedicatedDocs as $doc) {
expect($accessibleDocs->contains('id', $doc->id))
->toBeTrue("用户分组的专用文档 {$doc->id} 应该对用户可见");
}
// 验证:其他分组的专用文档不应该在结果中
$otherGroupDocs = $dedicatedDocs->whereNotIn('group_id', $userGroupIds);
foreach ($otherGroupDocs as $doc) {
expect($accessibleDocs->contains('id', $doc->id))
->toBeFalse("其他分组的专用文档 {$doc->id} 不应该对用户可见");
}
// 验证:结果数量应该等于全局文档数 + 用户分组的专用文档数
$expectedCount = $globalDocCount + $userDedicatedDocs->count();
expect($accessibleDocs->count())
->toBe($expectedCount, "可访问文档数量应该是 {$expectedCount}");
// 清理数据以便下一次迭代
Document::query()->delete();
User::query()->delete();
Group::query()->delete();
}
})->group('property-based-test');
/**
* Feature: knowledge-base-system, Property 8: 其他分组文档隔离
*
* 对于任何用户和任何不属于该用户分组的专用知识库文档,
* 该文档不应该出现在用户的可访问文档列表中
*
* Validates: Requirements 3.4
*/
test('property 8: 其他分组的专用文档应该被隔离,不出现在用户的可访问列表中', function () {
// 运行 100 次迭代
for ($i = 0; $i < 100; $i++) {
// 创建至少 2 个分组
$groupCount = rand(2, 5);
$groups = Group::factory()->count($groupCount)->create();
// 创建一个测试用户
$user = User::factory()->create();
// 将用户分配到部分分组(至少留一个分组不分配)
$userGroupCount = rand(1, $groupCount - 1);
$userGroups = $groups->random($userGroupCount);
$user->groups()->attach($userGroups->pluck('id'));
// 获取用户不属于的分组
$otherGroups = $groups->diff($userGroups);
// 为其他分组创建专用文档
$otherGroupDocs = collect();
foreach ($otherGroups as $group) {
$docCount = rand(1, 5);
$docs = Document::factory()->count($docCount)->dedicated($group->id)->create();
$otherGroupDocs = $otherGroupDocs->merge($docs);
}
// 执行查询:获取用户可访问的文档
$accessibleDocs = Document::accessibleBy($user)->get();
// 验证:其他分组的所有专用文档都不应该在结果中
foreach ($otherGroupDocs as $doc) {
expect($accessibleDocs->contains('id', $doc->id))
->toBeFalse("其他分组的专用文档 {$doc->id}(分组 {$doc->group_id})不应该对用户可见");
}
// 额外验证:确保结果中没有任何其他分组的文档
$otherGroupIds = $otherGroups->pluck('id')->toArray();
$leakedDocs = $accessibleDocs->filter(function ($doc) use ($otherGroupIds) {
return $doc->type === 'dedicated' && in_array($doc->group_id, $otherGroupIds);
});
expect($leakedDocs->count())
->toBe(0, "不应该有任何其他分组的专用文档泄露到用户的可访问列表中");
// 清理数据以便下一次迭代
Document::query()->delete();
User::query()->delete();
Group::query()->delete();
}
})->group('property-based-test');

View File

@@ -1,81 +1,40 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
describe('DocumentPolicy', function () {
test('viewAny 允许所有已认证用户查看文档列表', function () {
test('viewAny 允许有权限的用户查看文档列表', function () {
$user = User::factory()->create();
expect($user->can('viewAny', Document::class))->toBeTrue();
});
test('view 允许有用户查看全局文档', function () {
test('view 允许有权限的用户查看文档', function () {
$user = User::factory()->create();
$document = Document::factory()->create([
'type' => 'global',
'group_id' => null,
]);
$document = Document::factory()->create();
expect($user->can('view', $document))->toBeTrue();
});
test('view 允许分组成员查看该分组的专用文档', function () {
$group = Group::factory()->create();
test('create 允许有权限的用户创建文档', function () {
$user = User::factory()->create();
$user->groups()->attach($group);
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
]);
expect($user->can('view', $document))->toBeTrue();
});
test('view 拒绝非分组成员查看专用文档', function () {
$group = Group::factory()->create();
$user = User::factory()->create();
// 用户不属于该分组
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
]);
expect($user->can('view', $document))->toBeFalse();
});
test('view 拒绝访问没有分组的专用文档', function () {
$user = User::factory()->create();
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => null,
]);
expect($user->can('view', $document))->toBeFalse();
});
test('create 允许所有已认证用户创建文档', function () {
$user = User::factory()->create();
expect($user->can('create', Document::class))->toBeTrue();
});
test('update 只允许文档上传者更新文档', function () {
$uploader = User::factory()->create();
$otherUser = User::factory()->create();
$document = Document::factory()->create([
'uploaded_by' => $uploader->id,
]);
expect($uploader->can('update', $document))->toBeTrue();
expect($otherUser->can('update', $document))->toBeFalse();
});
@@ -83,92 +42,19 @@ describe('DocumentPolicy', function () {
test('delete 只允许文档上传者删除文档', function () {
$uploader = User::factory()->create();
$otherUser = User::factory()->create();
$document = Document::factory()->create([
'uploaded_by' => $uploader->id,
]);
expect($uploader->can('delete', $document))->toBeTrue();
expect($otherUser->can('delete', $document))->toBeFalse();
});
test('download 允许有用户下载全局文档', function () {
test('download 允许有权限的用户下载文档', function () {
$user = User::factory()->create();
$document = Document::factory()->create([
'type' => 'global',
'group_id' => null,
]);
$document = Document::factory()->create();
expect($user->can('download', $document))->toBeTrue();
});
test('download 允许分组成员下载该分组的专用文档', function () {
$group = Group::factory()->create();
$user = User::factory()->create();
$user->groups()->attach($group);
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
]);
expect($user->can('download', $document))->toBeTrue();
});
test('download 拒绝非分组成员下载专用文档', function () {
$group = Group::factory()->create();
$user = User::factory()->create();
// 用户不属于该分组
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
]);
expect($user->can('download', $document))->toBeFalse();
});
test('用户从分组移除后失去访问权限', function () {
$group = Group::factory()->create();
$user = User::factory()->create();
$user->groups()->attach($group);
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
]);
// 用户在分组中时可以访问
expect($user->can('view', $document))->toBeTrue();
// 从分组中移除用户
$user->groups()->detach($group);
// 刷新用户关系
$user->refresh();
// 用户不再能访问该文档
expect($user->can('view', $document))->toBeFalse();
});
test('用户属于多个分组时可以访问所有分组的专用文档', function () {
$group1 = Group::factory()->create();
$group2 = Group::factory()->create();
$user = User::factory()->create();
$user->groups()->attach([$group1->id, $group2->id]);
$document1 = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group1->id,
]);
$document2 = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group2->id,
]);
expect($user->can('view', $document1))->toBeTrue();
expect($user->can('view', $document2))->toBeTrue();
});
});

View File

@@ -3,7 +3,6 @@
namespace Tests\Feature;
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use App\Services\DocumentPreviewService;
use Illuminate\Foundation\Testing\RefreshDatabase;

View File

@@ -1,7 +1,6 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Storage;
@@ -9,114 +8,50 @@ use Illuminate\Support\Facades\Storage;
uses(RefreshDatabase::class);
beforeEach(function () {
// 设置存储磁盘
Storage::fake('local');
});
test('用户可以预览有权限的全局文档', function () {
// 创建用户和文档
test('用户可以预览已转换的文档', function () {
$user = User::factory()->create();
$document = Document::factory()->create([
'type' => 'global',
'conversion_status' => 'completed',
'markdown_path' => 'markdown/test.md',
]);
// 创建 Markdown 文件
Storage::disk('local')->put($document->markdown_path, '# 测试标题\n\n这是测试内容。');
// 访问预览页面
$response = $this->actingAs($user)->get(route('documents.preview', $document));
// 验证响应
$response->assertStatus(200);
$response->assertSee($document->title);
$response->assertSee('测试标题');
});
test('用户可以预览有权限的专用文档', function () {
// 创建分组和用户
$group = Group::factory()->create();
$user = User::factory()->create();
$user->groups()->attach($group);
// 创建专用文档
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
'conversion_status' => 'completed',
'markdown_path' => 'markdown/test.md',
]);
// 创建 Markdown 文件
Storage::disk('local')->put($document->markdown_path, '# 专用文档\n\n这是专用内容。');
// 访问预览页面
$response = $this->actingAs($user)->get(route('documents.preview', $document));
// 验证响应
$response->assertStatus(200);
$response->assertSee($document->title);
$response->assertSee('专用文档');
});
test('用户无法预览无权限的专用文档', function () {
// 创建两个分组
$group1 = Group::factory()->create();
$group2 = Group::factory()->create();
// 用户属于分组1
$user = User::factory()->create();
$user->groups()->attach($group1);
// 文档属于分组2
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group2->id,
'conversion_status' => 'completed',
]);
// 尝试访问预览页面
$response = $this->actingAs($user)->get(route('documents.preview', $document));
// 验证被拒绝
$response->assertStatus(403);
});
test('预览页面正确处理 Markdown 内容为空的情况', function () {
// 创建用户和文档(没有 Markdown 内容)
$user = User::factory()->create();
$document = Document::factory()->create([
'type' => 'global',
'conversion_status' => 'completed',
'markdown_path' => null,
]);
// 访问预览页面
$response = $this->actingAs($user)->get(route('documents.preview', $document));
// 验证响应
$response->assertStatus(200);
$response->assertSee('Markdown 内容为空');
$response->assertSee('下载原始文档');
});
test('预览页面显示下载按钮', function () {
// 创建用户和文档
$user = User::factory()->create();
$document = Document::factory()->create([
'type' => 'global',
'conversion_status' => 'completed',
'markdown_path' => 'markdown/test.md',
]);
// 创建 Markdown 文件
Storage::disk('local')->put($document->markdown_path, '# 测试');
// 访问预览页面
$response = $this->actingAs($user)->get(route('documents.preview', $document));
// 验证下载按钮存在
$response->assertStatus(200);
$response->assertSee('下载原文档');
$response->assertSee(route('documents.download', $document));

View File

@@ -1,98 +0,0 @@
<?php
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
test('删除分组时将关联的专用文档设置为孤立状态', function () {
// 创建测试数据
$user = User::factory()->create();
$group = Group::factory()->create(['name' => '测试分组']);
// 创建属于该分组的专用文档
$document1 = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
'uploaded_by' => $user->id,
'title' => '专用文档1',
]);
$document2 = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
'uploaded_by' => $user->id,
'title' => '专用文档2',
]);
// 创建全局文档(不应受影响)
$globalDocument = Document::factory()->create([
'type' => 'global',
'group_id' => null,
'uploaded_by' => $user->id,
'title' => '全局文档',
]);
// 验证文档初始状态
expect($document1->fresh()->group_id)->toBe($group->id);
expect($document2->fresh()->group_id)->toBe($group->id);
expect($globalDocument->fresh()->group_id)->toBeNull();
// 删除分组
$group->delete();
// 验证专用文档的 group_id 已被设置为 null
expect($document1->fresh()->group_id)->toBeNull();
expect($document2->fresh()->group_id)->toBeNull();
// 验证全局文档不受影响
expect($globalDocument->fresh()->group_id)->toBeNull();
// 验证文档本身没有被删除
expect(Document::find($document1->id))->not->toBeNull();
expect(Document::find($document2->id))->not->toBeNull();
expect(Document::find($globalDocument->id))->not->toBeNull();
});
test('删除分组不影响其他分组的文档', function () {
// 创建测试数据
$user = User::factory()->create();
$group1 = Group::factory()->create(['name' => '分组1']);
$group2 = Group::factory()->create(['name' => '分组2']);
// 创建属于分组1的文档
$document1 = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group1->id,
'uploaded_by' => $user->id,
]);
// 创建属于分组2的文档
$document2 = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group2->id,
'uploaded_by' => $user->id,
]);
// 删除分组1
$group1->delete();
// 验证分组1的文档变为孤立状态
expect($document1->fresh()->group_id)->toBeNull();
// 验证分组2的文档不受影响
expect($document2->fresh()->group_id)->toBe($group2->id);
});
test('删除没有文档的分组不会出错', function () {
// 创建一个没有文档的分组
$group = Group::factory()->create(['name' => '空分组']);
// 删除分组应该成功且不抛出异常
expect(fn() => $group->delete())->not->toThrow(Exception::class);
// 验证分组已被删除
expect(Group::find($group->id))->toBeNull();
});

View File

@@ -3,7 +3,6 @@
namespace Tests\Feature;
use App\Models\Document;
use App\Models\Group;
use App\Models\User;
use App\Services\SecurityLogger;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -22,11 +21,7 @@ class SecurityLoggerTest extends TestCase
{
// 创建测试数据
$user = User::factory()->create();
$group = Group::factory()->create();
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $group->id,
]);
$document = Document::factory()->create();
// 模拟日志记录
Log::shouldReceive('channel')
@@ -56,11 +51,7 @@ class SecurityLoggerTest extends TestCase
{
// 创建测试数据
$user = User::factory()->create();
$otherGroup = Group::factory()->create();
$document = Document::factory()->create([
'type' => 'dedicated',
'group_id' => $otherGroup->id,
]);
$document = Document::factory()->create();
// 模拟日志记录
Log::shouldReceive('channel')
@@ -156,11 +147,8 @@ class SecurityLoggerTest extends TestCase
'name' => '测试用户',
'email' => 'test@example.com',
]);
$group = Group::factory()->create();
$document = Document::factory()->create([
'title' => '测试文档',
'type' => 'dedicated',
'group_id' => $group->id,
]);
// 模拟日志记录并验证上下文
@@ -171,7 +159,7 @@ class SecurityLoggerTest extends TestCase
Log::shouldReceive('warning')
->once()
->with('未授权访问尝试', \Mockery::on(function ($context) use ($user, $document, $group) {
->with('未授权访问尝试', \Mockery::on(function ($context) use ($user, $document) {
return $context['event'] === 'unauthorized_access'
&& $context['action'] === 'view'
&& $context['user_id'] === $user->id
@@ -179,8 +167,7 @@ class SecurityLoggerTest extends TestCase
&& $context['user_email'] === 'test@example.com'
&& $context['document_id'] === $document->id
&& $context['document_title'] === '测试文档'
&& $context['document_type'] === 'dedicated'
&& $context['document_group_id'] === $group->id
&& isset($context['document_knowledge_base_id'])
&& isset($context['ip_address'])
&& isset($context['timestamp'])
&& isset($context['user_agent']);

View File

@@ -3,6 +3,7 @@
namespace Tests\Feature;
use App\Filament\Resources\TerminalResource;
use App\Models\Station;
use App\Models\Terminal;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -26,11 +27,13 @@ class TerminalPromptFormTest extends TestCase
{
$this->actingAs($this->user);
$station = Station::factory()->create();
$terminalData = [
'name' => '测试终端',
'code' => 'TEST-001',
'ip_address' => '192.168.1.100',
'station_id' => 1,
'station_id' => $station->id,
'prompt' => [
'prompt_template' => '你是一个智能助手,当前用户是 {user}。',
],

View File

@@ -6,6 +6,7 @@ use App\Filament\Resources\TerminalResource;
use App\Filament\Resources\TerminalResource\Pages\CreateTerminal;
use App\Filament\Resources\TerminalResource\Pages\EditTerminal;
use App\Filament\Resources\TerminalResource\Pages\ListTerminals;
use App\Models\Station;
use App\Models\Terminal;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
@@ -57,11 +58,13 @@ class TerminalResourceTest extends TestCase
/** @test */
public function it_can_create_terminal(): void
{
$station = Station::factory()->create();
$newData = [
'name' => '测试终端',
'code' => 'TEST-0001',
'ip_address' => '192.168.1.100',
'station_id' => 1,
'station_id' => $station->id,
'diagram_url' => 'https://example.com/diagram.html',
];
@@ -161,11 +164,13 @@ class TerminalResourceTest extends TestCase
{
$terminal = Terminal::factory()->create();
$station = Station::factory()->create();
$newData = [
'name' => '更新后的终端',
'code' => $terminal->code,
'ip_address' => '192.168.1.200',
'station_id' => 2,
'station_id' => $station->id,
];
Livewire::test(EditTerminal::class, ['record' => $terminal->getRouteKey()])
@@ -239,10 +244,12 @@ class TerminalResourceTest extends TestCase
}
/** @test */
public function it_can_group_by_station(): void
public function it_can_group_by_group(): void
{
Terminal::factory()->count(3)->create(['station_id' => 1]);
Terminal::factory()->count(2)->create(['station_id' => 2]);
$station1 = Station::factory()->create();
$station2 = Station::factory()->create();
Terminal::factory()->count(3)->create(['station_id' => $station1->id]);
Terminal::factory()->count(2)->create(['station_id' => $station2->id]);
// 测试分组功能是否可用
$component = Livewire::test(ListTerminals::class);