diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d2a82c..3ff240e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **File Attachments API (Phase 2)** (#175) + - Upload encrypted file attachments to secrets (POST `/v1/secrets/{secret}/attachments`) + - List attachments for a secret (GET `/v1/secrets/{secret}/attachments`) + - Download decrypted attachments (GET `/v1/attachments/{attachment}/download`) + - Delete attachments (DELETE `/v1/attachments/{attachment}`) + - Files encrypted at rest using tenant DEK encryption + - Configurable file size limits and MIME type restrictions + - Owner-based authorization via `SecretAttachmentPolicy` + - OpenAPI documentation for all attachment endpoints + - Comprehensive test coverage: 13 Controller tests, 3 Service tests, 2 Model tests, 8 Policy tests + - **Code Coverage Integration** (#170) - Integrated Codecov for automated coverage tracking - PHPUnit now generates Clover XML coverage reports diff --git a/app/Http/Controllers/Api/V1/SecretAttachmentController.php b/app/Http/Controllers/Api/V1/SecretAttachmentController.php new file mode 100644 index 0000000..7d98fe7 --- /dev/null +++ b/app/Http/Controllers/Api/V1/SecretAttachmentController.php @@ -0,0 +1,107 @@ +validated()['file']; + + $user = $request->user(); + assert($user instanceof User, 'User must be authenticated'); + + $attachment = $this->storageService->store($file, $secret, $user); + + return SecretAttachmentResource::make($attachment) + ->response() + ->setStatusCode(201); + } + + /** + * List attachments for secret. + */ + public function index(Secret $secret): JsonResponse + { + Gate::authorize('viewAny', [SecretAttachment::class, $secret]); + + $attachments = $secret->attachments()->latest()->get(); + + return response()->json([ + 'data' => SecretAttachmentResource::collection($attachments), + ]); + } + + /** + * Download attachment. + */ + public function download(SecretAttachment $attachment): Response + { + Gate::authorize('view', $attachment); + + $content = $this->storageService->retrieve($attachment); + $filename = $attachment->filename_plain; + + // Ensure filename is present; attachments should always have a filename. + if ($filename === null) { + abort(500, 'Attachment is missing a filename.'); + } + + // Escape filename for Content-Disposition header (RFC 2231/5987) + $safeFilename = str_replace(['"', '\\'], ['', ''], $filename); + + return response($content, 200, [ + 'Content-Type' => $attachment->mime_type, + 'Content-Disposition' => 'attachment; filename="'.$safeFilename.'"', + 'Content-Length' => (string) $attachment->file_size, + ]); + } + + /** + * Delete attachment. + */ + public function destroy(SecretAttachment $attachment): Response + { + Gate::authorize('delete', $attachment); + + $this->storageService->delete($attachment); + + return response()->noContent(); + } +} diff --git a/app/Http/Requests/StoreSecretAttachmentRequest.php b/app/Http/Requests/StoreSecretAttachmentRequest.php new file mode 100644 index 0000000..3c1df6e --- /dev/null +++ b/app/Http/Requests/StoreSecretAttachmentRequest.php @@ -0,0 +1,67 @@ +|string> + */ + public function rules(): array + { + /** @var int $maxSize */ + $maxSize = config('attachments.max_file_size'); + /** @var array $allowedMimes */ + $allowedMimes = config('attachments.allowed_mime_types'); + + return [ + 'file' => [ + 'required', + 'file', + 'max:'.($maxSize / 1024), // Convert bytes to KB for Laravel validation + 'mimetypes:'.implode(',', $allowedMimes), + ], + ]; + } + + /** + * Get custom error messages for validation rules. + * + * @return array + */ + public function messages(): array + { + /** @var int $maxSizeBytes */ + $maxSizeBytes = config('attachments.max_file_size'); + $maxSize = $maxSizeBytes / (1024 * 1024); // Convert bytes to MB + + return [ + 'file.required' => 'Please select a file to upload.', + 'file.file' => 'The uploaded file is not valid.', + 'file.max' => 'The file size must not exceed '.$maxSize.' MB.', + 'file.mimetypes' => 'The file type is not allowed. Only certain file types are permitted.', + ]; + } +} diff --git a/app/Http/Resources/SecretAttachmentResource.php b/app/Http/Resources/SecretAttachmentResource.php new file mode 100644 index 0000000..aeb0b8b --- /dev/null +++ b/app/Http/Resources/SecretAttachmentResource.php @@ -0,0 +1,42 @@ + + */ + public function toArray(Request $request): array + { + /** @var \App\Models\SecretAttachment $attachment */ + $attachment = $this->resource; + + return [ + 'id' => $attachment->id, + 'filename' => $attachment->filename_plain, + 'file_size' => $attachment->file_size, + 'mime_type' => $attachment->mime_type, + 'download_url' => $attachment->download_url, + 'uploaded_by' => $attachment->uploaded_by, + 'created_at' => $attachment->created_at->toIso8601String(), + ]; + } +} diff --git a/app/Models/Secret.php b/app/Models/Secret.php index 5e04045..c235610 100644 --- a/app/Models/Secret.php +++ b/app/Models/Secret.php @@ -252,6 +252,39 @@ public function owner(): BelongsTo return $this->belongsTo(User::class, 'owner_id'); } + /** + * Relation to SecretAttachment (file attachments). + * + * @return \Illuminate\Database\Eloquent\Relations\HasMany + */ + public function attachments(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(SecretAttachment::class); + } + + /** + * Get count of attachments for this secret. + * + * Uses the aggregated count from withCount('attachments') if available, + * otherwise performs a count query. To avoid N+1 queries, use: + * Secret::withCount('attachments')->get() + * + * @return int Number of attachments + */ + public function getAttachmentCountAttribute(): int + { + // Use aggregated count if available (from withCount()) + if (isset($this->attributes['attachments_count'])) { + /** @var int|numeric-string $count */ + $count = $this->attributes['attachments_count']; + + return (int) $count; + } + + // Fallback to query (may cause N+1 if used in a loop) + return $this->attachments()->count(); + } + /** * Scope to filter by owner. * diff --git a/app/Models/SecretAttachment.php b/app/Models/SecretAttachment.php new file mode 100644 index 0000000..857972d --- /dev/null +++ b/app/Models/SecretAttachment.php @@ -0,0 +1,151 @@ + +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +namespace App\Models; + +use App\Casts\EncryptedWithDek; +use Illuminate\Database\Eloquent\Casts\Attribute; +use Illuminate\Database\Eloquent\Concerns\HasUuids; +use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\BelongsTo; + +/** + * Secret attachment model with encrypted filename. + * + * Encrypted fields (*_enc) use EncryptedWithDek cast and are stored as TEXT. + * Transient properties (*_plain) provide plaintext access with automatic encryption. + * + * @property string $id UUID primary key + * @property string $secret_id Foreign key to secrets table + * @property string $filename_enc Encrypted original filename (JSON) + * @property int $file_size Original file size in bytes + * @property string $mime_type MIME type (e.g., application/pdf) + * @property string $storage_path Path to encrypted blob + * @property ?string $checksum_sha256 SHA-256 checksum of original file + * @property string $uploaded_by UUID foreign key to users table + * @property \Illuminate\Support\Carbon $created_at + * @property \Illuminate\Support\Carbon $updated_at + * @property-write string $filename_plain Transient plaintext filename + * @property-read string $download_url Download URL accessor + * @property-read Secret $secret Relationship to secret + * @property-read User $uploader Relationship to uploader + * + * @method static \Illuminate\Database\Eloquent\Builder|SecretAttachment newModelQuery() + * @method static \Illuminate\Database\Eloquent\Builder|SecretAttachment newQuery() + * @method static \Illuminate\Database\Eloquent\Builder|SecretAttachment query() + */ +class SecretAttachment extends Model +{ + /** @use HasFactory<\Illuminate\Database\Eloquent\Factories\Factory> */ + use HasFactory, HasUuids; + + /** + * The table associated with the model. + * + * @var string + */ + protected $table = 'secret_attachments'; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'secret_id', + 'tenant_id', // Required for EncryptedWithDek cast + 'filename_enc', + 'file_size', + 'mime_type', + 'storage_path', + 'checksum_sha256', + 'uploaded_by', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * Protects encrypted fields and storage path from JSON exposure. + * + * @var list + */ + protected $hidden = [ + 'filename_enc', + 'storage_path', + ]; + + /** + * The attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'filename_enc' => EncryptedWithDek::class, + 'file_size' => 'integer', + 'created_at' => 'datetime', + 'updated_at' => 'datetime', + ]; + } + + /** + * Transient plaintext filename (write-only). + */ + private ?string $filenamePlain = null; + + /** + * Set plaintext filename (transient). + */ + public function setFilenamePlainAttribute(string $value): void + { + $this->filenamePlain = $value; + $this->filename_enc = $value; // Trigger encrypted cast + } + + /** + * Get plaintext filename (read accessor). + * + * Falls back to decrypting filename_enc if transient is null. + */ + public function getFilenamePlainAttribute(): ?string + { + return $this->filenamePlain ?? $this->filename_enc; + } + + /** + * Get the secret that owns this attachment. + * + * @return BelongsTo + */ + public function secret(): BelongsTo + { + return $this->belongsTo(Secret::class); + } + + /** + * Get the user who uploaded this attachment. + * + * @return BelongsTo + */ + public function uploader(): BelongsTo + { + return $this->belongsTo(User::class, 'uploaded_by'); + } + + /** + * Get the download URL for this attachment. + * + * @return Attribute + */ + protected function downloadUrl(): Attribute + { + return Attribute::make( + get: fn () => url("/v1/attachments/{$this->id}/download") + ); + } +} diff --git a/app/Policies/SecretAttachmentPolicy.php b/app/Policies/SecretAttachmentPolicy.php new file mode 100644 index 0000000..9dbb6b5 --- /dev/null +++ b/app/Policies/SecretAttachmentPolicy.php @@ -0,0 +1,56 @@ +id === $secret->owner_id; + } + + /** + * Determine if user can view a specific attachment. + */ + public function view(User $user, SecretAttachment $attachment): bool + { + return $user->id === $attachment->secret->owner_id; + } + + /** + * Determine if user can upload attachments to a secret. + */ + public function create(User $user, Secret $secret): bool + { + return $user->id === $secret->owner_id; + } + + /** + * Determine if user can delete an attachment. + */ + public function delete(User $user, SecretAttachment $attachment): bool + { + return $user->id === $attachment->secret->owner_id; + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 163bacc..c917f78 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -8,10 +8,12 @@ use App\Models\Permission; use App\Models\Person; use App\Models\Secret; +use App\Models\SecretAttachment; use App\Observers\PersonObserver; use App\Observers\SecretObserver; use App\Policies\PermissionManagementPolicy; use App\Policies\RoleManagementPolicy; +use App\Policies\SecretAttachmentPolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; use Spatie\Permission\Models\Role; @@ -40,6 +42,9 @@ public function boot(): void // Register policy for Spatie Permission model Gate::policy(Permission::class, PermissionManagementPolicy::class); + // Register policy for SecretAttachment model + Gate::policy(SecretAttachment::class, SecretAttachmentPolicy::class); + // Register gates for user permission management $this->registerUserPermissionGates(); } diff --git a/app/Services/AttachmentStorageService.php b/app/Services/AttachmentStorageService.php new file mode 100644 index 0000000..e6e196d --- /dev/null +++ b/app/Services/AttachmentStorageService.php @@ -0,0 +1,163 @@ +getRealPath()); + if ($content === false) { + throw new \RuntimeException('Failed to read uploaded file'); + } + + // Calculate checksum (before encryption) + $checksum = hash('sha256', $content); + + // Encrypt with tenant DEK + $tenant = $secret->tenantKey; + if ($tenant === null) { + throw new \RuntimeException('Secret must have an associated tenant key'); + } + + $encrypted = $tenant->encrypt($content); + + // Generate storage path + $attachmentId = Str::uuid()->toString(); + $storagePath = sprintf( + 'attachments/%d/%s/%s.enc', + $tenant->id, + $secret->id, + $attachmentId + ); + + // Store encrypted blob as JSON + $jsonBlob = json_encode([ + 'ciphertext' => base64_encode($encrypted['ciphertext']), + 'nonce' => base64_encode($encrypted['nonce']), + ]); + if ($jsonBlob === false) { + throw new \RuntimeException('Failed to encode encrypted data'); + } + + /** @var string $disk */ + $disk = config('attachments.storage_disk'); + Storage::disk($disk)->put($storagePath, $jsonBlob); + + // Create attachment record + $attachment = new SecretAttachment; + $attachment->id = $attachmentId; + $attachment->secret_id = $secret->id; + /** @var int<0, max> $tenantId */ + $tenantId = $secret->tenant_id; + $attachment->tenant_id = $tenantId; // MUST be set BEFORE encrypted fields + $attachment->filename_plain = $file->getClientOriginalName(); // Triggers encryption + $attachment->file_size = $file->getSize(); + $mimeType = $file->getMimeType(); + if ($mimeType === null) { + throw new \RuntimeException('Failed to determine file MIME type'); + } + $attachment->mime_type = $mimeType; + $attachment->storage_path = $storagePath; + $attachment->checksum_sha256 = $checksum; + $attachment->uploaded_by = $user->id; + $attachment->save(); + + return $attachment; + } + + /** + * Retrieve and decrypt file content. + * + * @param SecretAttachment $attachment The attachment to retrieve + * @return string The decrypted file content + */ + public function retrieve(SecretAttachment $attachment): string + { + // Read encrypted blob from storage + /** @var string $disk */ + $disk = config('attachments.storage_disk'); + $encryptedBlob = Storage::disk($disk)->get($attachment->storage_path); + if ($encryptedBlob === null) { + throw new \RuntimeException('Attachment file not found in storage'); + } + + $decoded = json_decode($encryptedBlob, true); + if (! is_array($decoded) || ! isset($decoded['ciphertext'], $decoded['nonce'])) { + throw new \RuntimeException('Invalid encrypted blob format'); + } + + if (! is_string($decoded['ciphertext']) || ! is_string($decoded['nonce'])) { + throw new \RuntimeException('Invalid encrypted blob data types'); + } + + // Decrypt with tenant DEK + $tenant = $attachment->secret->tenantKey; + if ($tenant === null) { + throw new \RuntimeException('Attachment secret must have an associated tenant key'); + } + + $decrypted = $tenant->decrypt( + base64_decode($decoded['ciphertext']), + base64_decode($decoded['nonce']) + ); + + // Verify checksum (integrity check) + $actualChecksum = hash('sha256', $decrypted); + if ($actualChecksum !== $attachment->checksum_sha256) { + throw new \RuntimeException('File integrity check failed: checksum mismatch'); + } + + return $decrypted; + } + + /** + * Delete attachment file from storage. + * + * @param SecretAttachment $attachment The attachment to delete + * @return bool True if deletion was successful + */ + public function delete(SecretAttachment $attachment): bool + { + // Delete file from storage + /** @var string $disk */ + $disk = config('attachments.storage_disk'); + $storage = Storage::disk($disk); + + if ($storage->exists($attachment->storage_path)) { + $storage->delete($attachment->storage_path); + } + // Note: If file is missing, we continue - the database record should still be cleaned up + + // Delete attachment record + $attachment->delete(); + + return true; + } +} diff --git a/config/attachments.php b/config/attachments.php new file mode 100644 index 0000000..d24b20d --- /dev/null +++ b/config/attachments.php @@ -0,0 +1,75 @@ + env('ATTACHMENT_MAX_SIZE', 10 * 1024 * 1024), + + /* + |-------------------------------------------------------------------------- + | Allowed MIME Types + |-------------------------------------------------------------------------- + | + | List of permitted MIME types for attachments. + | + | WARNING: This array MUST NOT be empty in production. + | Empty array allows ALL file types including executables (SECURITY RISK). + | Only explicitly listed MIME types are permitted when array contains values. + | + */ + 'allowed_mime_types' => [ + // Images + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + + // Documents + 'application/pdf', + 'application/msword', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'application/vnd.ms-excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + 'application/vnd.ms-powerpoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + + // Text + 'text/plain', + 'text/csv', + 'text/html', + 'text/markdown', + + // Archives + 'application/zip', + 'application/x-7z-compressed', + 'application/x-rar-compressed', + + // Other + 'application/json', + 'application/xml', + ], + + /* + |-------------------------------------------------------------------------- + | Storage Disk + |-------------------------------------------------------------------------- + | + | Disk to use for storing encrypted attachment files. + | Must be configured in config/filesystems.php + | + */ + 'storage_disk' => env('ATTACHMENT_STORAGE_DISK', 'local'), +]; diff --git a/database/migrations/2025_11_16_110234_create_secret_attachments_table.php b/database/migrations/2025_11_16_110234_create_secret_attachments_table.php new file mode 100644 index 0000000..643b143 --- /dev/null +++ b/database/migrations/2025_11_16_110234_create_secret_attachments_table.php @@ -0,0 +1,51 @@ + +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use Illuminate\Database\Migrations\Migration; +use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Facades\Schema; + +return new class extends Migration +{ + /** + * Run the migrations. + */ + public function up(): void + { + Schema::create('secret_attachments', function (Blueprint $table) { + $table->uuid('id')->primary(); + $table->foreignUuid('secret_id')->constrained('secrets')->cascadeOnDelete(); + + // Foreign key to tenant_keys table + // REQUIRED: EncryptedWithDek cast needs tenant_id to load the correct DEK for encryption/decryption + // The tenant_id MUST be set before accessing any encrypted fields (filename_enc) + $table->foreignId('tenant_id')->constrained('tenant_keys'); + + // File metadata (encrypted) + $table->text('filename_enc'); // Original filename (encrypted) + $table->unsignedBigInteger('file_size'); // Original size in bytes + $table->string('mime_type', 255); // MIME type + $table->text('storage_path'); // Path to encrypted blob + $table->string('checksum_sha256', 64)->nullable(); // File integrity + + // Audit + $table->foreignUuid('uploaded_by')->constrained('users'); + $table->timestamps(); + + // Indexes + $table->index('secret_id'); + $table->index(['secret_id', 'created_at']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('secret_attachments'); + } +}; diff --git a/routes/api.php b/routes/api.php index 2e73686..a5c1004 100644 --- a/routes/api.php +++ b/routes/api.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Api\V1\PermissionManagementController; use App\Http\Controllers\Api\V1\RoleManagementController; +use App\Http\Controllers\Api\V1\SecretAttachmentController; use App\Http\Controllers\Api\V1\UserPermissionController; use App\Http\Controllers\AuthController; use App\Http\Controllers\PersonController; @@ -100,5 +101,11 @@ Route::get('/persons/by-email', [PersonController::class, 'byEmail']) ->middleware('permission:person.read'); }); + + // Secret Attachment endpoints (File Attachments API - Phase 2) + Route::post('/secrets/{secret}/attachments', [SecretAttachmentController::class, 'store']); + Route::get('/secrets/{secret}/attachments', [SecretAttachmentController::class, 'index']); + Route::get('/attachments/{attachment}/download', [SecretAttachmentController::class, 'download']); + Route::delete('/attachments/{attachment}', [SecretAttachmentController::class, 'destroy']); }); }); diff --git a/tests/Feature/Controllers/SecretAttachmentControllerTest.php b/tests/Feature/Controllers/SecretAttachmentControllerTest.php new file mode 100644 index 0000000..60078ba --- /dev/null +++ b/tests/Feature/Controllers/SecretAttachmentControllerTest.php @@ -0,0 +1,278 @@ +tenant = TenantKey::create($keys); + + $this->user = User::factory()->create(); + $this->actingAs($this->user, 'sanctum'); + + $this->secret = new Secret; + $this->secret->tenant_id = $this->tenant->id; + $this->secret->owner_id = $this->user->id; + $this->secret->title_plain = 'Test Secret'; + $this->secret->save(); +}); + +afterEach(function (): void { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +describe('POST /v1/secrets/{secret}/attachments - Upload', function () { + test('uploads file successfully', function (): void { + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + $response = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", [ + 'file' => $file, + ]); + + $response->assertStatus(201) + ->assertJsonStructure([ + 'data' => [ + 'id', + 'filename', + 'file_size', + 'mime_type', + 'download_url', + 'uploaded_by', + 'created_at', + ], + ]); + + // Verify attachment created in DB + $this->assertDatabaseHas('secret_attachments', [ + 'secret_id' => $this->secret->id, + 'mime_type' => 'application/pdf', + 'uploaded_by' => $this->user->id, + ]); + + // Verify file encrypted in storage + $attachment = SecretAttachment::latest()->first(); + Storage::disk('local')->assertExists($attachment->storage_path); + }); + + test('validates file is required', function (): void { + $response = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", []); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + }); + + test('validates file size limit', function (): void { + $maxSize = config('attachments.max_file_size'); + $file = UploadedFile::fake()->create('large.pdf', ($maxSize / 1024) + 1, 'application/pdf'); + + $response = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", [ + 'file' => $file, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + }); + + test('validates allowed mime types', function (): void { + $file = UploadedFile::fake()->create('executable.exe', 100, 'application/x-msdownload'); + + $response = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", [ + 'file' => $file, + ]); + + $response->assertStatus(422) + ->assertJsonValidationErrors(['file']); + }); + + test('requires authorization (non-owner)', function (): void { + $otherUser = User::factory()->create(); + $this->actingAs($otherUser, 'sanctum'); + + $file = UploadedFile::fake()->create('document.pdf', 100); + + $response = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", [ + 'file' => $file, + ]); + + $response->assertStatus(403); + }); +}); + +describe('GET /v1/secrets/{secret}/attachments - List', function () { + test('lists attachments for secret', function (): void { + // Create 2 attachments + $att1 = new SecretAttachment; + $att1->id = \Illuminate\Support\Str::uuid(); + $att1->secret_id = $this->secret->id; + $att1->tenant_id = $this->tenant->id; + $att1->filename_plain = 'file1.pdf'; + $att1->file_size = 1024; + $att1->mime_type = 'application/pdf'; + $att1->storage_path = 'test/path1.enc'; + $att1->checksum_sha256 = hash('sha256', 'test1'); + $att1->uploaded_by = $this->user->id; + $att1->save(); + + $att2 = new SecretAttachment; + $att2->id = \Illuminate\Support\Str::uuid(); + $att2->secret_id = $this->secret->id; + $att2->tenant_id = $this->tenant->id; + $att2->filename_plain = 'file2.jpg'; + $att2->file_size = 2048; + $att2->mime_type = 'image/jpeg'; + $att2->storage_path = 'test/path2.enc'; + $att2->checksum_sha256 = hash('sha256', 'test2'); + $att2->uploaded_by = $this->user->id; + $att2->save(); + + $response = $this->getJson("/v1/secrets/{$this->secret->id}/attachments"); + + $response->assertStatus(200) + ->assertJsonCount(2, 'data') + ->assertJsonStructure([ + 'data' => [ + '*' => ['id', 'filename', 'file_size', 'mime_type', 'download_url', 'created_at'], + ], + ]); + }); + + test('returns empty array when no attachments', function (): void { + $response = $this->getJson("/v1/secrets/{$this->secret->id}/attachments"); + + $response->assertStatus(200) + ->assertJson(['data' => []]); + }); + + test('requires authorization (non-owner)', function (): void { + $otherUser = User::factory()->create(); + $this->actingAs($otherUser, 'sanctum'); + + $response = $this->getJson("/v1/secrets/{$this->secret->id}/attachments"); + + $response->assertStatus(403); + }); +}); + +describe('GET /v1/attachments/{attachment}/download - Download', function () { + test('downloads attachment with correct headers', function (): void { + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + // Upload first + $uploadResponse = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", [ + 'file' => $file, + ]); + + $attachmentId = $uploadResponse->json('data.id'); + + // Download + $response = $this->get("/v1/attachments/{$attachmentId}/download"); + + $response->assertStatus(200) + ->assertHeader('Content-Type', 'application/pdf') + ->assertHeader('Content-Disposition', 'attachment; filename="document.pdf"'); + }); + + test('requires authorization (non-owner)', function (): void { + $attachment = new SecretAttachment; + $attachment->id = \Illuminate\Support\Str::uuid(); + $attachment->secret_id = $this->secret->id; + $attachment->tenant_id = $this->tenant->id; + $attachment->filename_plain = 'test.pdf'; + $attachment->file_size = 1024; + $attachment->mime_type = 'application/pdf'; + $attachment->storage_path = 'test/path.enc'; + $attachment->checksum_sha256 = hash('sha256', 'test'); + $attachment->uploaded_by = $this->user->id; + $attachment->save(); + + $otherUser = User::factory()->create(); + $this->actingAs($otherUser, 'sanctum'); + + $response = $this->get("/v1/attachments/{$attachment->id}/download"); + + $response->assertStatus(403); + }); +}); + +describe('DELETE /v1/attachments/{attachment} - Delete', function () { + test('deletes attachment successfully', function (): void { + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + + // Upload first + $uploadResponse = $this->postJson("/v1/secrets/{$this->secret->id}/attachments", [ + 'file' => $file, + ]); + + $attachmentId = $uploadResponse->json('data.id'); + + // Delete + $response = $this->deleteJson("/v1/attachments/{$attachmentId}"); + + $response->assertStatus(204); + + // Verify deleted from DB + $this->assertDatabaseMissing('secret_attachments', [ + 'id' => $attachmentId, + ]); + }); + + test('deletes attachment and verifies cascade', function (): void { + $file = UploadedFile::fake()->create('document.pdf', 100, 'application/pdf'); + Storage::put('test/path.enc', 'encrypted content'); + + $attachment = new SecretAttachment; + $attachment->id = \Illuminate\Support\Str::uuid(); + $attachment->secret_id = $this->secret->id; + $attachment->tenant_id = $this->tenant->id; + $attachment->filename_plain = 'document.pdf'; + $attachment->file_size = 100; + $attachment->mime_type = 'application/pdf'; + $attachment->storage_path = 'test/path.enc'; + $attachment->checksum_sha256 = hash('sha256', 'test'); + $attachment->uploaded_by = $this->user->id; + $attachment->save(); + + $response = $this->deleteJson("/v1/attachments/{$attachment->id}"); + + $response->assertStatus(204); + Storage::assertMissing($attachment->storage_path); + }); + + test('requires authorization (non-owner)', function (): void { + $attachment = new SecretAttachment; + $attachment->id = \Illuminate\Support\Str::uuid(); + $attachment->secret_id = $this->secret->id; + $attachment->tenant_id = $this->tenant->id; + $attachment->filename_plain = 'test.pdf'; + $attachment->file_size = 1024; + $attachment->mime_type = 'application/pdf'; + $attachment->storage_path = 'test/path.enc'; + $attachment->checksum_sha256 = hash('sha256', 'test'); + $attachment->uploaded_by = $this->user->id; + $attachment->save(); + + $otherUser = User::factory()->create(); + $this->actingAs($otherUser, 'sanctum'); + + $response = $this->deleteJson("/v1/attachments/{$attachment->id}"); + + $response->assertStatus(403); + }); +}); diff --git a/tests/Feature/Migrations/CreateSecretAttachmentsTableTest.php b/tests/Feature/Migrations/CreateSecretAttachmentsTableTest.php new file mode 100644 index 0000000..84cc3ba --- /dev/null +++ b/tests/Feature/Migrations/CreateSecretAttachmentsTableTest.php @@ -0,0 +1,82 @@ + +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use Illuminate\Foundation\Testing\RefreshDatabase; +use Illuminate\Support\Facades\Schema; + +uses(RefreshDatabase::class); + +test('secret_attachments table exists', function (): void { + expect(Schema::hasTable('secret_attachments'))->toBeTrue(); +}); + +test('secret_attachments has correct columns', function (): void { + expect(Schema::hasColumns('secret_attachments', [ + 'id', + 'secret_id', + 'filename_enc', + 'file_size', + 'mime_type', + 'storage_path', + 'checksum_sha256', + 'uploaded_by', + 'created_at', + 'updated_at', + ]))->toBeTrue(); +}); + +test('secret_attachments has UUID primary key', function (): void { + $columns = Schema::getColumns('secret_attachments'); + $idColumn = collect($columns)->firstWhere('name', 'id'); + + expect($idColumn)->not->toBeNull(); + expect($idColumn['type_name'])->toBe('uuid'); + + // Check primary key via indexes + $indexes = Schema::getIndexes('secret_attachments'); + $primaryKey = collect($indexes)->first(fn ($idx) => $idx['primary'] ?? false); + + expect($primaryKey)->not->toBeNull(); + expect($primaryKey['columns'])->toContain('id'); +}); + +test('secret_attachments has correct column types', function (): void { + $columns = Schema::getColumns('secret_attachments'); + $columnTypes = collect($columns)->mapWithKeys(fn ($col) => [$col['name'] => $col['type_name']]); + + expect($columnTypes['secret_id'])->toBe('uuid'); + expect($columnTypes['tenant_id'])->toBe('int8'); // Required for EncryptedWithDek cast + expect($columnTypes['filename_enc'])->toBe('text'); + expect($columnTypes['file_size'])->toBe('int8'); + expect($columnTypes['mime_type'])->toBe('varchar'); + expect($columnTypes['storage_path'])->toBe('text'); + expect($columnTypes['checksum_sha256'])->toBe('varchar'); + expect($columnTypes['uploaded_by'])->toBe('uuid'); +}); + +test('secret_attachments has foreign key constraints', function (): void { + $foreignKeys = Schema::getForeignKeys('secret_attachments'); + $foreignKeyColumns = collect($foreignKeys)->pluck('columns')->flatten()->toArray(); + + expect($foreignKeyColumns)->toContain('secret_id'); + expect($foreignKeyColumns)->toContain('tenant_id'); + expect($foreignKeyColumns)->toContain('uploaded_by'); +}); + +test('secret_attachments has correct indexes', function (): void { + $indexes = Schema::getIndexes('secret_attachments'); + $indexColumns = collect($indexes)->pluck('columns')->flatten()->unique()->toArray(); + + expect($indexColumns)->toContain('secret_id'); +}); + +test('secret_id foreign key cascades on deletion', function (): void { + $foreignKeys = Schema::getForeignKeys('secret_attachments'); + $secretFk = collect($foreignKeys)->firstWhere('columns', ['secret_id']); + + expect($secretFk)->not->toBeNull(); + expect($secretFk['on_delete'])->toBe('cascade'); +}); diff --git a/tests/Feature/Models/SecretAttachmentTest.php b/tests/Feature/Models/SecretAttachmentTest.php new file mode 100644 index 0000000..959f591 --- /dev/null +++ b/tests/Feature/Models/SecretAttachmentTest.php @@ -0,0 +1,168 @@ + +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use App\Models\Secret; +use App\Models\SecretAttachment; +use App\Models\TenantKey; +use App\Models\User; +use Illuminate\Foundation\Testing\RefreshDatabase; + +uses(RefreshDatabase::class); + +beforeEach(function (): void { + // Use process-specific KEK file for parallel test isolation + TenantKey::setKekPath(getTestKekPath()); + TenantKey::generateKek(); + + // Create tenant + $keys = TenantKey::generateEnvelopeKeys(); + $this->tenant = TenantKey::create($keys); + + // Create user + $this->user = User::factory()->create(); +}); + +afterEach(function () { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +test('secret attachment has correct fillable fields', function (): void { + $model = new SecretAttachment; + + expect($model->getFillable())->toContain('secret_id'); + expect($model->getFillable())->toContain('filename_enc'); + expect($model->getFillable())->toContain('file_size'); + expect($model->getFillable())->toContain('mime_type'); + expect($model->getFillable())->toContain('storage_path'); + expect($model->getFillable())->toContain('checksum_sha256'); + expect($model->getFillable())->toContain('uploaded_by'); +}); + +test('secret attachment hides encrypted fields', function (): void { + $model = new SecretAttachment; + + expect($model->getHidden())->toContain('filename_enc'); + expect($model->getHidden())->toContain('storage_path'); +}); + +test('secret attachment uses UUID primary key', function (): void { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Test Secret for Attachment', + ]); + + $attachment = createTestAttachment([ + 'secret_id' => $secret->id, + 'tenant_id' => $this->tenant->id, + 'filename_plain' => 'test.pdf', + 'file_size' => 1024, + 'mime_type' => 'application/pdf', + 'storage_path' => 'attachments/test/123.enc', + 'checksum_sha256' => hash('sha256', 'test'), + 'uploaded_by' => $this->user->id, + ]); + + expect($attachment->id)->toBeString(); + expect(strlen($attachment->id))->toBe(36); // UUID format +}); + +test('secret attachment encrypts filename with EncryptedWithDek cast', function (): void { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Secret with Encrypted Attachment', + ]); + + $attachment = createTestAttachment([ + 'secret_id' => $secret->id, + 'tenant_id' => $this->tenant->id, + 'filename_plain' => 'secret-document.pdf', + 'file_size' => 2048, + 'mime_type' => 'application/pdf', + 'storage_path' => 'attachments/test/456.enc', + 'checksum_sha256' => hash('sha256', 'content'), + 'uploaded_by' => $this->user->id, + ]); + + // filename_enc should be encrypted JSON in database + $raw = $attachment->getRawOriginal('filename_enc'); + expect($raw)->toBeString(); + expect($raw)->toContain('ciphertext'); + expect($raw)->toContain('nonce'); + + // filename_plain should decrypt correctly + expect($attachment->filename_plain)->toBe('secret-document.pdf'); +}); + +test('secret attachment belongs to secret', function (): void { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Secret with Relationship', + ]); + + $attachment = createTestAttachment([ + 'secret_id' => $secret->id, + 'tenant_id' => $this->tenant->id, + 'filename_plain' => 'test.jpg', + 'file_size' => 512, + 'mime_type' => 'image/jpeg', + 'storage_path' => 'attachments/test/789.enc', + 'checksum_sha256' => hash('sha256', 'image'), + 'uploaded_by' => $this->user->id, + ]); + + expect($attachment->secret)->toBeInstanceOf(Secret::class); + expect($attachment->secret->id)->toBe($secret->id); +}); + +test('secret attachment belongs to user (uploaded_by)', function (): void { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Secret for Uploader Test', + ]); + + $attachment = createTestAttachment([ + 'secret_id' => $secret->id, + 'tenant_id' => $this->tenant->id, + 'filename_plain' => 'report.docx', + 'file_size' => 4096, + 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'storage_path' => 'attachments/test/abc.enc', + 'checksum_sha256' => hash('sha256', 'document'), + 'uploaded_by' => $this->user->id, + ]); + + expect($attachment->uploader)->toBeInstanceOf(User::class); + expect($attachment->uploader->id)->toBe($this->user->id); +}); + +test('secret attachment has download_url accessor', function (): void { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Secret for Download URL Test', + ]); + + $attachment = createTestAttachment([ + 'secret_id' => $secret->id, + 'tenant_id' => $this->tenant->id, + 'filename_plain' => 'invoice.pdf', + 'file_size' => 1536, + 'mime_type' => 'application/pdf', + 'storage_path' => 'attachments/test/def.enc', + 'checksum_sha256' => hash('sha256', 'invoice'), + 'uploaded_by' => $this->user->id, + ]); + + expect($attachment->download_url)->toBeString(); + expect($attachment->download_url)->toContain('/v1/attachments/'); + expect($attachment->download_url)->toContain($attachment->id); + expect($attachment->download_url)->toContain('/download'); +}); diff --git a/tests/Feature/Policies/SecretAttachmentPolicyTest.php b/tests/Feature/Policies/SecretAttachmentPolicyTest.php new file mode 100644 index 0000000..28a7990 --- /dev/null +++ b/tests/Feature/Policies/SecretAttachmentPolicyTest.php @@ -0,0 +1,81 @@ + +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use App\Models\Secret; +use App\Models\SecretAttachment; +use App\Models\TenantKey; +use App\Models\User; +use App\Policies\SecretAttachmentPolicy; +use Illuminate\Foundation\Testing\RefreshDatabase; + +uses(RefreshDatabase::class); + +beforeEach(function (): void { + TenantKey::setKekPath(getTestKekPath()); + TenantKey::generateKek(); + + $keys = TenantKey::generateEnvelopeKeys(); + $this->tenant = TenantKey::create($keys); + + $this->owner = User::factory()->create(); + $this->otherUser = User::factory()->create(); + $this->policy = new SecretAttachmentPolicy; + + $this->secret = new Secret; + $this->secret->tenant_id = $this->tenant->id; + $this->secret->owner_id = $this->owner->id; + $this->secret->title_plain = 'Test Secret'; + $this->secret->save(); + + $this->attachment = new SecretAttachment; + $this->attachment->id = \Illuminate\Support\Str::uuid(); + $this->attachment->secret_id = $this->secret->id; + $this->attachment->tenant_id = $this->tenant->id; + $this->attachment->filename_plain = 'test.pdf'; + $this->attachment->file_size = 1024; + $this->attachment->mime_type = 'application/pdf'; + $this->attachment->storage_path = 'test/path.enc'; + $this->attachment->checksum_sha256 = hash('sha256', 'test'); + $this->attachment->uploaded_by = $this->owner->id; + $this->attachment->save(); +}); + +afterEach(function (): void { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +test('owner can view any attachments', function (): void { + expect($this->policy->viewAny($this->owner, $this->secret))->toBeTrue(); +}); + +test('non-owner cannot view any attachments', function (): void { + expect($this->policy->viewAny($this->otherUser, $this->secret))->toBeFalse(); +}); + +test('owner can view specific attachment', function (): void { + expect($this->policy->view($this->owner, $this->attachment))->toBeTrue(); +}); + +test('non-owner cannot view specific attachment', function (): void { + expect($this->policy->view($this->otherUser, $this->attachment))->toBeFalse(); +}); + +test('owner can create attachments', function (): void { + expect($this->policy->create($this->owner, $this->secret))->toBeTrue(); +}); + +test('non-owner cannot create attachments', function (): void { + expect($this->policy->create($this->otherUser, $this->secret))->toBeFalse(); +}); + +test('owner can delete attachments', function (): void { + expect($this->policy->delete($this->owner, $this->attachment))->toBeTrue(); +}); + +test('non-owner cannot delete attachments', function (): void { + expect($this->policy->delete($this->otherUser, $this->attachment))->toBeFalse(); +}); diff --git a/tests/Feature/Requests/StoreSecretAttachmentRequestTest.php b/tests/Feature/Requests/StoreSecretAttachmentRequestTest.php new file mode 100644 index 0000000..a7b5031 --- /dev/null +++ b/tests/Feature/Requests/StoreSecretAttachmentRequestTest.php @@ -0,0 +1,78 @@ +rules()); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->has('file'))->toBeTrue(); +}); + +test('form request validates file must be a file', function () { + $request = new StoreSecretAttachmentRequest; + + $validator = Validator::make(['file' => 'not-a-file'], $request->rules()); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->has('file'))->toBeTrue(); +}); + +test('form request validates file size limit from config', function () { + $request = new StoreSecretAttachmentRequest; + + // Create file larger than max size (default 10MB = 10240KB) + $file = UploadedFile::fake()->create('large-file.pdf', 10241); // 10241KB > 10240KB + + $validator = Validator::make(['file' => $file], $request->rules()); + + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->has('file'))->toBeTrue(); +}); + +test('form request validates allowed mime types from config', function () { + $request = new StoreSecretAttachmentRequest; + + // Create file with disallowed mime type (assume .exe is not allowed) + $file = UploadedFile::fake()->create('virus.exe', 100); + + $validator = Validator::make(['file' => $file], $request->rules()); + + // This test assumes config has mime type restrictions + // If config allows all types, this test will fail - that's intentional + // to catch security issues + expect($validator->fails())->toBeTrue(); + expect($validator->errors()->has('file'))->toBeTrue(); +}); + +test('form request accepts valid file with allowed mime type', function () { + $request = new StoreSecretAttachmentRequest; + + // Create valid PDF file (should be in allowed types) + $file = UploadedFile::fake()->create('document.pdf', 100); + + $validator = Validator::make(['file' => $file], $request->rules()); + + expect($validator->passes())->toBeTrue(); +}); + +test('form request has custom error messages', function () { + $request = new StoreSecretAttachmentRequest; + + expect($request->messages())->toBeArray(); + expect($request->messages())->not->toBeEmpty(); +}); + +test('form request authorization always returns true for authenticated users', function () { + $request = new StoreSecretAttachmentRequest; + + // Authorization is handled by Policy, not Form Request + expect($request->authorize())->toBeTrue(); +}); diff --git a/tests/Feature/Resources/SecretAttachmentResourceTest.php b/tests/Feature/Resources/SecretAttachmentResourceTest.php new file mode 100644 index 0000000..e036c5d --- /dev/null +++ b/tests/Feature/Resources/SecretAttachmentResourceTest.php @@ -0,0 +1,156 @@ +tenant = TenantKey::create($keys); + + // Create user + $this->user = User::factory()->create(); +}); + +afterEach(function () { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +test('resource transforms single attachment correctly', function () { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Test Secret', + ]); + + $attachment = createTestAttachment([ + 'tenant_id' => $this->tenant->id, + 'secret_id' => $secret->id, + 'uploaded_by' => $this->user->id, + 'filename_plain' => 'test-document.pdf', + 'file_size' => 1024, + 'mime_type' => 'application/pdf', + 'storage_path' => 'test-storage-path', + 'checksum_sha256' => hash('sha256', 'test-content'), + ]); + + $resource = SecretAttachmentResource::make($attachment); + $array = $resource->toArray(request()); + + expect($array)->toHaveKeys([ + 'id', + 'filename', + 'file_size', + 'mime_type', + 'download_url', + 'uploaded_by', + 'created_at', + ]); + + expect($array['id'])->toBe($attachment->id); + expect($array['filename'])->toBe('test-document.pdf'); + expect($array['file_size'])->toBe(1024); + expect($array['mime_type'])->toBe('application/pdf'); + expect($array['uploaded_by'])->toBe($this->user->id); + expect($array['download_url'])->toBeString(); + expect($array['created_at'])->toBeString(); +}); + +test('resource transforms collection of attachments correctly', function () { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Test Secret', + ]); + + $attachment1 = createTestAttachment([ + 'tenant_id' => $this->tenant->id, + 'secret_id' => $secret->id, + 'uploaded_by' => $this->user->id, + 'filename_plain' => 'file1.pdf', + 'file_size' => 1024, + 'mime_type' => 'application/pdf', + 'storage_path' => 'test-storage-path-1', + 'checksum_sha256' => hash('sha256', 'test-content-1'), + ]); + + $attachment2 = createTestAttachment([ + 'tenant_id' => $this->tenant->id, + 'secret_id' => $secret->id, + 'uploaded_by' => $this->user->id, + 'filename_plain' => 'file2.pdf', + 'file_size' => 2048, + 'mime_type' => 'application/pdf', + 'storage_path' => 'test-storage-path-2', + 'checksum_sha256' => hash('sha256', 'test-content-2'), + ]); + + $collection = SecretAttachmentResource::collection([$attachment1, $attachment2]); + $array = $collection->toArray(request()); + + expect($array)->toHaveCount(2); + expect($array[0]['filename'])->toBe('file1.pdf'); + expect($array[1]['filename'])->toBe('file2.pdf'); +}); + +test('resource formats created_at as ISO8601 string', function () { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Test Secret', + ]); + + $attachment = createTestAttachment([ + 'tenant_id' => $this->tenant->id, + 'secret_id' => $secret->id, + 'uploaded_by' => $this->user->id, + 'filename_plain' => 'test.pdf', + 'file_size' => 1024, + 'mime_type' => 'application/pdf', + 'storage_path' => 'test-storage-path', + 'checksum_sha256' => hash('sha256', 'test-content'), + ]); + + $resource = SecretAttachmentResource::make($attachment); + $array = $resource->toArray(request()); + + // ISO 8601 format: 2025-11-16T15:30:00+00:00 + expect($array['created_at'])->toMatch('/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[+-]\d{2}:\d{2}$/'); +}); + +test('resource decrypts filename_plain correctly', function () { + $secret = createTestSecret([ + 'tenant_id' => $this->tenant->id, + 'owner_id' => $this->user->id, + 'title_plain' => 'Test Secret', + ]); + + $attachment = createTestAttachment([ + 'tenant_id' => $this->tenant->id, + 'secret_id' => $secret->id, + 'uploaded_by' => $this->user->id, + 'filename_plain' => 'sensitive-document.pdf', + 'file_size' => 1024, + 'mime_type' => 'application/pdf', + 'storage_path' => 'test-storage-path', + 'checksum_sha256' => hash('sha256', 'test-content'), + ]); + + $resource = SecretAttachmentResource::make($attachment); + $array = $resource->toArray(request()); + + // Verify decryption works (filename should be decrypted) + expect($array['filename'])->toBe('sensitive-document.pdf'); +}); diff --git a/tests/Feature/SecretTest.php b/tests/Feature/SecretTest.php index ba778df..025d081 100644 --- a/tests/Feature/SecretTest.php +++ b/tests/Feature/SecretTest.php @@ -181,3 +181,26 @@ expect($result->notes_tsv)->not->toBeNull(); }); }); + +describe('Secret Model - Relationships', function () { + test('has attachments relationship', function (): void { + $secret = new Secret; + $secret->tenant_id = $this->tenant->id; + $secret->owner_id = $this->user->id; + $secret->title_plain = 'Test Secret'; + $secret->save(); + + expect($secret->attachments())->toBeInstanceOf(\Illuminate\Database\Eloquent\Relations\HasMany::class); + expect($secret->attachments)->toBeEmpty(); + }); + + test('attachment_count accessor returns zero for secrets without attachments', function (): void { + $secret = new Secret; + $secret->tenant_id = $this->tenant->id; + $secret->owner_id = $this->user->id; + $secret->title_plain = 'Test Secret'; + $secret->save(); + + expect($secret->attachment_count)->toBe(0); + }); +}); diff --git a/tests/Feature/Services/AttachmentStorageServiceTest.php b/tests/Feature/Services/AttachmentStorageServiceTest.php new file mode 100644 index 0000000..03f9c37 --- /dev/null +++ b/tests/Feature/Services/AttachmentStorageServiceTest.php @@ -0,0 +1,91 @@ +tenant = TenantKey::create($keys); + $this->user = User::factory()->create(); + + $this->service = new AttachmentStorageService; +}); + +afterEach(function (): void { + cleanupTestKekFile(); + TenantKey::setKekPath(null); +}); + +test('service stores file with encryption', function (): void { + $secret = new Secret; + $secret->tenant_id = $this->tenant->id; + $secret->owner_id = $this->user->id; + $secret->title_plain = 'Secret with Attachment'; + $secret->save(); + + $file = UploadedFile::fake()->create('test-document.pdf', 100, 'application/pdf'); + + $attachment = $this->service->store($file, $secret, $this->user); + + expect($attachment)->toBeInstanceOf(SecretAttachment::class); + expect($attachment->filename_plain)->toBe('test-document.pdf'); + expect($attachment->file_size)->toBe(102400); + expect($attachment->mime_type)->toBe('application/pdf'); + expect($attachment->checksum_sha256)->toBeString(); + expect($attachment->storage_path)->toStartWith('attachments/'); + expect($attachment->uploaded_by)->toBe($this->user->id); +}); + +test('service encrypts file content in storage', function (): void { + $secret = new Secret; + $secret->tenant_id = $this->tenant->id; + $secret->owner_id = $this->user->id; + $secret->title_plain = 'Secret for Encryption Test'; + $secret->save(); + + $fileContent = 'This is secret file content that must be encrypted'; + $file = UploadedFile::fake()->createWithContent('secret.txt', $fileContent); + + $attachment = $this->service->store($file, $secret, $this->user); + + $encryptedBlob = Storage::disk('local')->get($attachment->storage_path); + + $decoded = json_decode($encryptedBlob, true); + expect($decoded)->toBeArray(); + expect($decoded)->toHaveKey('ciphertext'); + expect($decoded)->toHaveKey('nonce'); + expect($encryptedBlob)->not->toContain($fileContent); +}); + +test('service retrieves and decrypts file content', function (): void { + $secret = new Secret; + $secret->tenant_id = $this->tenant->id; + $secret->owner_id = $this->user->id; + $secret->title_plain = 'Secret for Decryption Test'; + $secret->save(); + + $originalContent = 'Original file content to be encrypted and decrypted'; + $file = UploadedFile::fake()->createWithContent('original.txt', $originalContent); + + $attachment = $this->service->store($file, $secret, $this->user); + $decryptedContent = $this->service->retrieve($attachment); + + expect($decryptedContent)->toBe($originalContent); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 617ab31..72ffe19 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -97,3 +97,38 @@ function assignTemporalRole( // Clear relationship cache $user->unsetRelation('roles'); } + +/** + * Create Secret with proper encryption pattern. + * Sets tenant_id BEFORE title_plain to ensure correct encryption context. + */ +function createTestSecret(array $attributes): \App\Models\Secret +{ + $secret = new \App\Models\Secret; + $secret->tenant_id = $attributes['tenant_id']; + $secret->owner_id = $attributes['owner_id']; + $secret->title_plain = $attributes['title_plain'] ?? 'Test Secret'; + $secret->save(); + + return $secret; +} + +/** + * Create SecretAttachment with proper encryption pattern. + * Sets tenant_id BEFORE filename_plain to ensure correct encryption context. + */ +function createTestAttachment(array $attributes): \App\Models\SecretAttachment +{ + $attachment = new \App\Models\SecretAttachment; + $attachment->secret_id = $attributes['secret_id']; + $attachment->tenant_id = $attributes['tenant_id']; + $attachment->filename_plain = $attributes['filename_plain']; + $attachment->file_size = $attributes['file_size']; + $attachment->mime_type = $attributes['mime_type']; + $attachment->storage_path = $attributes['storage_path']; + $attachment->checksum_sha256 = $attributes['checksum_sha256']; + $attachment->uploaded_by = $attributes['uploaded_by']; + $attachment->save(); + + return $attachment; +}