feat: 初始化知识库系统项目
- 实现基于 Laravel 11 和 Filament 3.X 的文档管理系统 - 添加用户认证和分组管理功能 - 实现文档上传、分类和权限控制 - 集成 Word 文档自动转换为 Markdown - 集成 Meilisearch 全文搜索引擎 - 实现文档在线预览功能 - 添加安全日志和审计功能 - 完整的简体中文界面 - 包含完整的项目文档和部署指南 技术栈: - Laravel 11.x - Filament 3.X - Meilisearch 1.5+ - Pandoc 文档转换 - Redis 队列系统 - Pest PHP 测试框架
This commit is contained in:
138
tests/Feature/DocumentAccessScopePropertyTest.php
Normal file
138
tests/Feature/DocumentAccessScopePropertyTest.php
Normal file
@@ -0,0 +1,138 @@
|
||||
<?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');
|
||||
124
tests/Feature/DocumentFileNameTest.php
Normal file
124
tests/Feature/DocumentFileNameTest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentFileNameTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
public function test_上传文档时保留原始文件名(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$documentService = new DocumentService();
|
||||
|
||||
// 创建一个测试文件,使用特定的文件名
|
||||
$originalFileName = '测试文档_2024.docx';
|
||||
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
// 上传文档
|
||||
$document = $documentService->uploadDocument(
|
||||
$file,
|
||||
'测试文档',
|
||||
'global',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
|
||||
// 验证文件名被正确保存
|
||||
$this->assertEquals($originalFileName, $document->file_name);
|
||||
|
||||
// 验证文件路径包含原始文件名
|
||||
$this->assertStringContainsString($originalFileName, $document->file_path);
|
||||
}
|
||||
|
||||
public function test_下载文档时使用原始文件名(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$documentService = new DocumentService();
|
||||
|
||||
// 创建一个测试文件
|
||||
$originalFileName = '重要文档.docx';
|
||||
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
// 上传文档
|
||||
$document = $documentService->uploadDocument(
|
||||
$file,
|
||||
'重要文档',
|
||||
'global',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
|
||||
// 下载文档
|
||||
$response = $documentService->downloadDocument($document, $user);
|
||||
|
||||
// 验证响应头中的文件名(可能被 URL 编码)
|
||||
$contentDisposition = $response->headers->get('Content-Disposition');
|
||||
|
||||
// 检查是否包含文件名(可能是原始格式或 URL 编码格式)
|
||||
$encodedFileName = rawurlencode($originalFileName);
|
||||
$this->assertTrue(
|
||||
str_contains($contentDisposition, $originalFileName) ||
|
||||
str_contains($contentDisposition, $encodedFileName),
|
||||
"Content-Disposition 应该包含原始文件名或其编码版本"
|
||||
);
|
||||
}
|
||||
|
||||
public function test_中文文件名正确处理(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$documentService = new DocumentService();
|
||||
|
||||
// 创建一个带中文名称的测试文件
|
||||
$originalFileName = '知识库管理系统需求文档.docx';
|
||||
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
// 上传文档
|
||||
$document = $documentService->uploadDocument(
|
||||
$file,
|
||||
'需求文档',
|
||||
'global',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
|
||||
// 验证中文文件名被正确保存
|
||||
$this->assertEquals($originalFileName, $document->file_name);
|
||||
}
|
||||
|
||||
public function test_特殊字符文件名正确处理(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$documentService = new DocumentService();
|
||||
|
||||
// 创建一个带特殊字符的测试文件
|
||||
$originalFileName = '文档(2024-01-01)_v1.0.docx';
|
||||
$file = UploadedFile::fake()->create($originalFileName, 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
// 上传文档
|
||||
$document = $documentService->uploadDocument(
|
||||
$file,
|
||||
'版本文档',
|
||||
'global',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
|
||||
// 验证特殊字符文件名被正确保存
|
||||
$this->assertEquals($originalFileName, $document->file_name);
|
||||
}
|
||||
}
|
||||
174
tests/Feature/DocumentPolicyTest.php
Normal file
174
tests/Feature/DocumentPolicyTest.php
Normal file
@@ -0,0 +1,174 @@
|
||||
<?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 () {
|
||||
$user = User::factory()->create();
|
||||
|
||||
expect($user->can('viewAny', Document::class))->toBeTrue();
|
||||
});
|
||||
|
||||
test('view 允许所有用户查看全局文档', function () {
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'type' => 'global',
|
||||
'group_id' => null,
|
||||
]);
|
||||
|
||||
expect($user->can('view', $document))->toBeTrue();
|
||||
});
|
||||
|
||||
test('view 允许分组成员查看该分组的专用文档', 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();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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 () {
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'type' => 'global',
|
||||
'group_id' => null,
|
||||
]);
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
70
tests/Feature/DocumentPreviewServiceTest.php
Normal file
70
tests/Feature/DocumentPreviewServiceTest.php
Normal file
@@ -0,0 +1,70 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentPreviewService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Tests\TestCase;
|
||||
|
||||
class DocumentPreviewServiceTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
protected DocumentPreviewService $previewService;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->previewService = new DocumentPreviewService();
|
||||
Storage::fake('local');
|
||||
}
|
||||
|
||||
public function test_可以检查文档是否支持预览(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
// 创建一个 .docx 文档
|
||||
$document = Document::factory()->create([
|
||||
'file_name' => 'test.docx',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->previewService->canPreview($document));
|
||||
|
||||
// 创建一个 .doc 文档
|
||||
$document2 = Document::factory()->create([
|
||||
'file_name' => 'test.doc',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertTrue($this->previewService->canPreview($document2));
|
||||
|
||||
// 创建一个不支持的格式
|
||||
$document3 = Document::factory()->create([
|
||||
'file_name' => 'test.pdf',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->assertFalse($this->previewService->canPreview($document3));
|
||||
}
|
||||
|
||||
public function test_文档不存在时抛出异常(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'file_path' => 'documents/2024/01/01/nonexistent.docx',
|
||||
'file_name' => 'nonexistent.docx',
|
||||
'uploaded_by' => $user->id,
|
||||
]);
|
||||
|
||||
$this->expectException(\Exception::class);
|
||||
$this->expectExceptionMessage('文档文件不存在');
|
||||
|
||||
$this->previewService->convertToHtml($document);
|
||||
}
|
||||
}
|
||||
123
tests/Feature/DocumentPreviewTest.php
Normal file
123
tests/Feature/DocumentPreviewTest.php
Normal file
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
// 设置存储磁盘
|
||||
Storage::fake('local');
|
||||
});
|
||||
|
||||
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));
|
||||
});
|
||||
117
tests/Feature/DocumentServiceTest.php
Normal file
117
tests/Feature/DocumentServiceTest.php
Normal file
@@ -0,0 +1,117 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Services\DocumentService;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Http\UploadedFile;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
Storage::fake('local');
|
||||
$this->service = new DocumentService();
|
||||
});
|
||||
|
||||
test('可以上传有效的 Word 文档', function () {
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('test.docx', 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
$document = $this->service->uploadDocument(
|
||||
$file,
|
||||
'测试文档',
|
||||
'global',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
|
||||
expect($document)->toBeInstanceOf(Document::class);
|
||||
expect($document->title)->toBe('测试文档');
|
||||
expect($document->type)->toBe('global');
|
||||
expect($document->uploaded_by)->toBe($user->id);
|
||||
});
|
||||
|
||||
test('上传非 Word 文档应该抛出异常', function () {
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('test.pdf', 100, 'application/pdf');
|
||||
|
||||
$this->service->uploadDocument(
|
||||
$file,
|
||||
'测试文档',
|
||||
'global',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
})->throws(\InvalidArgumentException::class, '文件格式不支持,请上传 Word 文档(.doc 或 .docx)');
|
||||
|
||||
test('专用文档没有分组应该抛出异常', function () {
|
||||
$user = User::factory()->create();
|
||||
$file = UploadedFile::fake()->create('test.docx', 100, 'application/vnd.openxmlformats-officedocument.wordprocessingml.document');
|
||||
|
||||
$this->service->uploadDocument(
|
||||
$file,
|
||||
'测试文档',
|
||||
'dedicated',
|
||||
null,
|
||||
$user->id
|
||||
);
|
||||
})->throws(\InvalidArgumentException::class, '专用知识库文档必须指定所属分组');
|
||||
|
||||
test('用户可以访问全局文档', function () {
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->global()->create();
|
||||
|
||||
$hasAccess = $this->service->validateDocumentAccess($document, $user);
|
||||
|
||||
expect($hasAccess)->toBeTrue();
|
||||
});
|
||||
|
||||
test('用户可以访问自己分组的专用文档', function () {
|
||||
$group = Group::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$user->groups()->attach($group->id);
|
||||
|
||||
$document = Document::factory()->dedicated($group->id)->create();
|
||||
|
||||
$hasAccess = $this->service->validateDocumentAccess($document, $user);
|
||||
|
||||
expect($hasAccess)->toBeTrue();
|
||||
});
|
||||
|
||||
test('用户不能访问其他分组的专用文档', function () {
|
||||
$group1 = Group::factory()->create();
|
||||
$group2 = Group::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
$user->groups()->attach($group1->id);
|
||||
|
||||
$document = Document::factory()->dedicated($group2->id)->create();
|
||||
|
||||
$hasAccess = $this->service->validateDocumentAccess($document, $user);
|
||||
|
||||
expect($hasAccess)->toBeFalse();
|
||||
});
|
||||
|
||||
test('可以记录文档下载日志', function () {
|
||||
$user = User::factory()->create();
|
||||
$document = Document::factory()->global()->create();
|
||||
|
||||
$log = $this->service->logDownload($document, $user, '127.0.0.1');
|
||||
|
||||
expect($log->document_id)->toBe($document->id);
|
||||
expect($log->user_id)->toBe($user->id);
|
||||
expect($log->ip_address)->toBe('127.0.0.1');
|
||||
expect($log->downloaded_at)->not->toBeNull();
|
||||
});
|
||||
|
||||
test('下载文档时验证权限', function () {
|
||||
$group = Group::factory()->create();
|
||||
$user = User::factory()->create();
|
||||
// 用户不属于该分组
|
||||
|
||||
$document = Document::factory()->dedicated($group->id)->create();
|
||||
|
||||
$this->service->downloadDocument($document, $user);
|
||||
})->throws(\Exception::class, '您没有权限访问此文档');
|
||||
|
||||
19
tests/Feature/ExampleTest.php
Normal file
19
tests/Feature/ExampleTest.php
Normal file
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
// use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_the_application_returns_a_successful_response(): void
|
||||
{
|
||||
$response = $this->get('/');
|
||||
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
}
|
||||
98
tests/Feature/GroupDeletionTest.php
Normal file
98
tests/Feature/GroupDeletionTest.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?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();
|
||||
});
|
||||
193
tests/Feature/SecurityLoggerTest.php
Normal file
193
tests/Feature/SecurityLoggerTest.php
Normal file
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Document;
|
||||
use App\Models\Group;
|
||||
use App\Models\User;
|
||||
use App\Services\SecurityLogger;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Tests\TestCase;
|
||||
|
||||
class SecurityLoggerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试记录未授权访问尝试
|
||||
* 需求:7.3
|
||||
*/
|
||||
public function test_logs_unauthorized_access_attempt(): void
|
||||
{
|
||||
// 创建测试数据
|
||||
$user = User::factory()->create();
|
||||
$group = Group::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'type' => 'dedicated',
|
||||
'group_id' => $group->id,
|
||||
]);
|
||||
|
||||
// 模拟日志记录
|
||||
Log::shouldReceive('channel')
|
||||
->with('security')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('未授权访问尝试', \Mockery::on(function ($context) use ($user, $document) {
|
||||
return $context['event'] === 'unauthorized_access'
|
||||
&& $context['action'] === 'view'
|
||||
&& $context['user_id'] === $user->id
|
||||
&& $context['document_id'] === $document->id;
|
||||
}));
|
||||
|
||||
// 执行测试
|
||||
$securityLogger = new SecurityLogger();
|
||||
$securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 DocumentPolicy 在权限验证失败时记录日志
|
||||
* 需求:7.3
|
||||
*/
|
||||
public function test_document_policy_logs_unauthorized_view_attempt(): void
|
||||
{
|
||||
// 创建测试数据
|
||||
$user = User::factory()->create();
|
||||
$otherGroup = Group::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'type' => 'dedicated',
|
||||
'group_id' => $otherGroup->id,
|
||||
]);
|
||||
|
||||
// 模拟日志记录
|
||||
Log::shouldReceive('channel')
|
||||
->with('security')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('未授权访问尝试', \Mockery::on(function ($context) {
|
||||
return $context['event'] === 'unauthorized_access'
|
||||
&& $context['action'] === 'view';
|
||||
}));
|
||||
|
||||
// 尝试查看文档(应该失败并记录日志)
|
||||
$canView = $user->can('view', $document);
|
||||
|
||||
$this->assertFalse($canView);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 DocumentPolicy 在更新权限验证失败时记录日志
|
||||
* 需求:7.3
|
||||
*/
|
||||
public function test_document_policy_logs_unauthorized_update_attempt(): void
|
||||
{
|
||||
// 创建测试数据
|
||||
$uploader = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'uploaded_by' => $uploader->id,
|
||||
]);
|
||||
|
||||
// 模拟日志记录
|
||||
Log::shouldReceive('channel')
|
||||
->with('security')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('未授权访问尝试', \Mockery::on(function ($context) {
|
||||
return $context['event'] === 'unauthorized_access'
|
||||
&& $context['action'] === 'update';
|
||||
}));
|
||||
|
||||
// 尝试更新文档(应该失败并记录日志)
|
||||
$canUpdate = $otherUser->can('update', $document);
|
||||
|
||||
$this->assertFalse($canUpdate);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 DocumentPolicy 在删除权限验证失败时记录日志
|
||||
* 需求:7.3
|
||||
*/
|
||||
public function test_document_policy_logs_unauthorized_delete_attempt(): void
|
||||
{
|
||||
// 创建测试数据
|
||||
$uploader = User::factory()->create();
|
||||
$otherUser = User::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'uploaded_by' => $uploader->id,
|
||||
]);
|
||||
|
||||
// 模拟日志记录
|
||||
Log::shouldReceive('channel')
|
||||
->with('security')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('未授权访问尝试', \Mockery::on(function ($context) {
|
||||
return $context['event'] === 'unauthorized_access'
|
||||
&& $context['action'] === 'delete';
|
||||
}));
|
||||
|
||||
// 尝试删除文档(应该失败并记录日志)
|
||||
$canDelete = $otherUser->can('delete', $document);
|
||||
|
||||
$this->assertFalse($canDelete);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试安全日志包含完整的上下文信息
|
||||
* 需求:7.3
|
||||
*/
|
||||
public function test_security_log_contains_complete_context(): void
|
||||
{
|
||||
// 创建测试数据
|
||||
$user = User::factory()->create([
|
||||
'name' => '测试用户',
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
$group = Group::factory()->create();
|
||||
$document = Document::factory()->create([
|
||||
'title' => '测试文档',
|
||||
'type' => 'dedicated',
|
||||
'group_id' => $group->id,
|
||||
]);
|
||||
|
||||
// 模拟日志记录并验证上下文
|
||||
Log::shouldReceive('channel')
|
||||
->with('security')
|
||||
->once()
|
||||
->andReturnSelf();
|
||||
|
||||
Log::shouldReceive('warning')
|
||||
->once()
|
||||
->with('未授权访问尝试', \Mockery::on(function ($context) use ($user, $document, $group) {
|
||||
return $context['event'] === 'unauthorized_access'
|
||||
&& $context['action'] === 'view'
|
||||
&& $context['user_id'] === $user->id
|
||||
&& $context['user_name'] === '测试用户'
|
||||
&& $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['ip_address'])
|
||||
&& isset($context['timestamp'])
|
||||
&& isset($context['user_agent']);
|
||||
}));
|
||||
|
||||
// 执行测试
|
||||
$securityLogger = new SecurityLogger();
|
||||
$securityLogger->logUnauthorizedAccess($user, $document, 'view');
|
||||
}
|
||||
}
|
||||
58
tests/Feature/SetupTest.php
Normal file
58
tests/Feature/SetupTest.php
Normal file
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
|
||||
class SetupTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
/**
|
||||
* 测试应用语言配置为简体中文
|
||||
*/
|
||||
public function test_app_locale_is_chinese(): void
|
||||
{
|
||||
$this->assertEquals('zh_CN', config('app.locale'));
|
||||
$this->assertEquals('zh_CN', config('app.fallback_locale'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试数据库连接正常
|
||||
*/
|
||||
public function test_database_connection(): void
|
||||
{
|
||||
// 测试数据库连接是否正常
|
||||
$this->assertDatabaseCount('users', 0); // 测试数据库初始为空
|
||||
|
||||
// 创建一个测试用户
|
||||
\App\Models\User::factory()->create([
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
|
||||
$this->assertDatabaseCount('users', 1);
|
||||
$this->assertDatabaseHas('users', [
|
||||
'email' => 'test@example.com',
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试 Filament 管理面板路由可访问
|
||||
*/
|
||||
public function test_filament_admin_routes_exist(): void
|
||||
{
|
||||
$response = $this->get('/admin/login');
|
||||
$response->assertStatus(200);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试中文翻译文件存在
|
||||
*/
|
||||
public function test_chinese_translation_files_exist(): void
|
||||
{
|
||||
$this->assertFileExists(lang_path('zh_CN/auth.php'));
|
||||
$this->assertFileExists(lang_path('zh_CN/validation.php'));
|
||||
$this->assertFileExists(lang_path('vendor/filament/zh_CN/components/button.php'));
|
||||
}
|
||||
}
|
||||
45
tests/Pest.php
Normal file
45
tests/Pest.php
Normal file
@@ -0,0 +1,45 @@
|
||||
<?php
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Test Case
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| The closure you provide to your test functions is always bound to a specific PHPUnit test
|
||||
| case class. By default, that class is "PHPUnit\Framework\TestCase". Of course, you may
|
||||
| need to change it using the "pest()" function to bind a different classes or traits.
|
||||
|
|
||||
*/
|
||||
|
||||
pest()->extend(Tests\TestCase::class)->in('Feature', 'Unit');
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Expectations
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| When you're writing tests, you often need to check that values meet certain conditions. The
|
||||
| "expect()" function gives you access to a set of "expectations" methods that you can use
|
||||
| to assert different things. Of course, you may extend the Expectation API at any time.
|
||||
|
|
||||
*/
|
||||
|
||||
expect()->extend('toBeOne', function () {
|
||||
return $this->toBe(1);
|
||||
});
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
| Functions
|
||||
|--------------------------------------------------------------------------
|
||||
|
|
||||
| While Pest is very powerful out-of-the-box, you may have some testing code specific to your
|
||||
| project that you don't want to repeat in every file. Here you can also expose helpers as
|
||||
| global functions to help you to reduce the number of lines of code in your test files.
|
||||
|
|
||||
*/
|
||||
|
||||
function something()
|
||||
{
|
||||
// ..
|
||||
}
|
||||
10
tests/TestCase.php
Normal file
10
tests/TestCase.php
Normal file
@@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace Tests;
|
||||
|
||||
use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
|
||||
|
||||
abstract class TestCase extends BaseTestCase
|
||||
{
|
||||
//
|
||||
}
|
||||
16
tests/Unit/ExampleTest.php
Normal file
16
tests/Unit/ExampleTest.php
Normal file
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
|
||||
class ExampleTest extends TestCase
|
||||
{
|
||||
/**
|
||||
* A basic test example.
|
||||
*/
|
||||
public function test_that_true_is_true(): void
|
||||
{
|
||||
$this->assertTrue(true);
|
||||
}
|
||||
}
|
||||
17
tests/fixtures/sample.txt
vendored
Normal file
17
tests/fixtures/sample.txt
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
# 示例文档
|
||||
|
||||
这是一个测试文档,用于演示文档预览功能。
|
||||
|
||||
## 功能特点
|
||||
|
||||
1. 支持 Word 文档预览
|
||||
2. 自动转换为 HTML 格式
|
||||
3. 保持基本格式
|
||||
|
||||
## 使用说明
|
||||
|
||||
在文档查看页面,系统会自动显示文档内容预览。
|
||||
|
||||
**注意事项:**
|
||||
- 预览可能与原始格式略有差异
|
||||
- 建议下载查看完整格式
|
||||
Reference in New Issue
Block a user