admin = User::factory()->create(); $this->token = $this->admin->createToken('t', [ Permission::Upload->value, Permission::EditFiles->value, Permission::EditOthersFiles->value, ])->plainTextToken; }); test('the listing returns files with an explicit field set', function () { $file = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'Quarterly report']); $response = $this->withToken($this->token)->getJson('/api/v1/files')->assertOk(); $response->assertJsonPath('data.0.id', $file->id) ->assertJsonPath('data.0.name', 'Quarterly report') ->assertJsonPath('data.0.mime_type', $file->mime_type); // Storage layout must never leave the server: a caller downloads // through the download endpoint, and publishing where the bytes sit is // the map you would want in order to reach them another way. $body = $response->json('data.0'); expect($body)->not->toHaveKey('path') ->and($body)->not->toHaveKey('disk'); }); test('no response ever carries credential columns', function () { File::factory()->create(['uploaded_by' => $this->admin->id]); $bodies = [ $this->withToken($this->token)->getJson('/api/v1/files')->getContent(), $this->withToken($this->token)->getJson('/api/v1/me')->getContent(), ]; foreach ($bodies as $body) { expect($body)->not->toContain('password') ->and($body)->not->toContain('two_factor_secret') ->and($body)->not->toContain('two_factor_recovery_codes') ->and($body)->not->toContain('remember_token'); } }); test('a token without any view ability cannot list files', function () { $limited = staffWithPermissions([Permission::ViewNews->value]); $token = $limited->createToken('t', [Permission::ViewNews->value])->plainTextToken; $this->withToken($token)->getJson('/api/v1/files')->assertForbidden(); }); /* * The listing must be built from ViewableFileScope, never File::query(). * A client-scoped staff member sees their own uploads plus their assigned * clients' files — and nothing else — exactly as in the UI. */ test('a client-scoped staff token sees only its own scope', function () { $client = User::factory()->client()->create(); $manager = User::factory()->role(SystemRole::ClientManager)->create(); $manager->assignedClients()->sync([$client->id]); $own = File::factory()->create(['uploaded_by' => $manager->id, 'name' => 'mine']); $unrelated = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'not mine']); $shared = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'shared with my client']); $this->actingAs($this->admin)->post("/files/{$shared->id}/assignments", ['type' => 'client', 'id' => $client->id]); $token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken; $ids = $this->withToken($token)->getJson('/api/v1/files')->assertOk()->json('data.*.id'); expect($ids)->toContain($own->id) ->and($ids)->toContain($shared->id) ->and($ids)->not->toContain($unrelated->id); // And direct access respects the same boundary. $this->withToken($token)->getJson("/api/v1/files/{$unrelated->id}")->assertForbidden(); }); test('filters narrow the listing', function () { $category = Category::query()->create(['name' => 'Invoices']); $other = User::factory()->create(); $match = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'invoice march']); $match->categories()->attach($category->id); $byOther = File::factory()->create(['uploaded_by' => $other->id, 'name' => 'someone elses']); $expired = File::factory()->create(['uploaded_by' => $this->admin->id, 'expires_at' => now()->subDay()]); $ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id'); expect($ids('search=invoice'))->toBe([$match->id]) ->and($ids("category_id={$category->id}"))->toBe([$match->id]) ->and($ids("uploaded_by={$other->id}"))->toBe([$byOther->id]) ->and($ids('expired=1'))->toBe([$expired->id]) ->and($ids('expired=0'))->not->toContain($expired->id); }); test('the new filters narrow the listing the same way the staff library does', function () { $uploader = User::factory()->role(SystemRole::Uploader)->create(); $grabbed = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'grabbed']); $untouched = File::factory()->create(['uploaded_by' => $uploader->id, 'name' => 'untouched']); $superseded = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'draft one']); $current = File::factory()->create([ 'uploaded_by' => $this->admin->id, 'name' => 'draft two', 'previous_file_id' => $superseded->id, 'version_root_id' => $superseded->id, ]); ActivityLog::query()->create([ 'actor_id' => $this->admin->id, 'action' => Action::FileDownloaded, 'subject_type' => $grabbed->getMorphClass(), 'subject_id' => $grabbed->id, 'created_at' => now(), ]); $ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id'); expect($ids('downloads=any'))->toBe([$grabbed->id]) ->and($ids('downloads=none'))->not->toContain($grabbed->id) ->and($ids('downloads=none'))->toContain($untouched->id) ->and($ids('version=outdated'))->toBe([$superseded->id]) ->and($ids('version=current'))->toContain($current->id) ->and($ids('version=current'))->not->toContain($superseded->id) ->and($ids("role_id={$uploader->role_id}"))->toBe([$untouched->id]); }); test('visibility asks the effective question and public still asks the column', function () { $folder = app(FolderService::class)->create('Brochures', null); $folder->update(['public' => true]); $flagged = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'flagged', 'public' => true]); $inherited = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'inherited', 'public' => false, 'folder_id' => $folder->id]); $private = File::factory()->create(['uploaded_by' => $this->admin->id, 'name' => 'plain', 'public' => false]); $ids = fn (string $query) => $this->withToken($this->token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data.*.id'); // The whole reason both exist. `public` reads the column, so the file // that is public only by inheritance is absent -- and callers already // depend on that answer, which is why its meaning was left alone. expect($ids('public=1'))->toBe([$flagged->id]) ->and($ids('public=1'))->not->toContain($inherited->id); // `visibility` reads File::isEffectivelyPublic(), which is what the // badge on the staff row means, so the inherited one counts. expect($ids('visibility=public'))->toContain($flagged->id) ->and($ids('visibility=public'))->toContain($inherited->id) ->and($ids('visibility=private'))->toBe([$private->id]); }); test('a client-scoped token cannot use the new filters to reach past its own library', function () { $manager = User::factory()->role(SystemRole::ClientManager)->create(); $token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken; // Nothing here is the manager's: a file they did not upload, for a // client they do not hold. Every filter must still come back empty -- // a filter narrows a library, it never widens one. $stranger = User::factory()->create(); File::factory()->create(['uploaded_by' => $stranger->id, 'name' => 'not theirs', 'public' => true]); foreach (['downloads=none', 'version=current', 'visibility=public', 'visibility=private', "role_id={$stranger->role_id}"] as $query) { expect($this->withToken($token)->getJson("/api/v1/files?{$query}")->assertOk()->json('data')) ->toBe([], "filter '{$query}' leaked past the client-scoped boundary"); } }); test('a malformed updated_since is rejected rather than ignored', function () { // Silently ignoring it would make a polling client re-read the whole // library every tick and never find out why. $this->withToken($this->token)->getJson('/api/v1/files?updated_since=not-a-date') ->assertStatus(422) ->assertJsonPath('type', 'validation_failed'); }); test('per_page is capped', function () { File::factory()->count(3)->create(['uploaded_by' => $this->admin->id]); $max = (int) config('api.pagination.max_per_page'); $this->withToken($this->token)->getJson('/api/v1/files?per_page='.($max + 1)) ->assertStatus(422); }); /* * The polling contract: walking with updated_since + cursor must visit * every row exactly once, including rows created or touched mid-walk. */ test('the updated_since walk returns every file exactly once', function () { $since = now()->subMinute(); foreach (range(1, 7) as $i) { File::factory()->create([ 'uploaded_by' => $this->admin->id, 'name' => "file {$i}", 'updated_at' => now()->addSeconds($i), ]); } $seen = []; $url = '/api/v1/files?per_page=2&updated_since='.urlencode($since->toIso8601String()); for ($page = 0; $page < 10 && $url !== null; $page++) { $body = $this->withToken($this->token)->getJson($url)->assertOk()->json(); $seen = array_merge($seen, array_column($body['data'], 'id')); $url = $body['links']['next'] ?? null; } expect($seen)->toHaveCount(7) ->and(array_unique($seen))->toHaveCount(7) // Ascending by updated_at, so a caller can take the last value as // the next poll's watermark. ->and($seen)->toBe(File::query()->orderBy('updated_at')->orderBy('id')->pluck('id')->all()); }); test('updated_since excludes files older than the watermark', function () { $old = File::factory()->create(['uploaded_by' => $this->admin->id, 'updated_at' => now()->subDays(2)]); $fresh = File::factory()->create(['uploaded_by' => $this->admin->id, 'updated_at' => now()]); $ids = $this->withToken($this->token) ->getJson('/api/v1/files?updated_since='.urlencode(now()->subHour()->toIso8601String())) ->assertOk()->json('data.*.id'); expect($ids)->toBe([$fresh->id]) ->and($ids)->not->toContain($old->id); }); test('show includes the relations an integration needs', function () { $client = User::factory()->client()->create(['name' => 'Acme']); $file = File::factory()->create(['uploaded_by' => $this->admin->id]); $this->actingAs($this->admin)->post("/files/{$file->id}/assignments", ['type' => 'client', 'id' => $client->id]); $this->withToken($this->token)->getJson("/api/v1/files/{$file->id}") ->assertOk() ->assertJsonPath('data.id', $file->id) ->assertJsonPath('data.uploaded_by.name', $this->admin->name) ->assertJsonPath('data.assignments.0.type', 'client') ->assertJsonPath('data.assignments.0.name', 'Acme'); }); test('downloading over the API authorizes and audits like the web route', function () { $file = File::factory()->create(['uploaded_by' => $this->admin->id]); $this->withToken($this->token)->get("/api/v1/files/{$file->id}/download") ->assertOk() ->assertHeader('X-Accel-Redirect', '/protected-files/'.$file->path); expect(ActivityLog::query() ->where('action', Action::FileDownloaded) ->where('subject_id', $file->id) ->exists())->toBeTrue(); }); test('a token cannot download a file outside its scope', function () { $manager = User::factory()->role(SystemRole::ClientManager)->create(); $token = $manager->createToken('t', [Permission::Upload->value])->plainTextToken; $unrelated = File::factory()->create(['uploaded_by' => $this->admin->id]); $this->withToken($token)->getJson("/api/v1/files/{$unrelated->id}/download")->assertForbidden(); }); test('a file says where it stands with the virus scanner, and can be filtered by it', function () { $pending = File::factory()->create([ 'uploaded_by' => $this->admin->id, 'scan_status' => App\Modules\Files\Scanning\ScanStatus::Pending, ]); File::factory()->create(['uploaded_by' => $this->admin->id]); $this->withToken($this->token)->getJson("/api/v1/files/{$pending->id}") ->assertOk() ->assertJsonPath('data.scan.status', 'pending') ->assertJsonPath('data.scan.available', false); // The download says "not yet" rather than "no": 423, and the caller // can poll the field above. $this->withToken($this->token)->get("/api/v1/files/{$pending->id}/download")->assertStatus(423); $ids = $this->withToken($this->token)->getJson('/api/v1/files?scan_status=pending') ->assertOk()->json('data.*.id'); expect($ids)->toBe([$pending->id]); });