feat: scan
This commit is contained in:
@@ -0,0 +1,282 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Admin;
|
||||
use App\Models\Device;
|
||||
use App\Models\MnemonicFinding;
|
||||
use App\Models\Note;
|
||||
use App\Models\Photo;
|
||||
use App\Services\MnemonicScanService;
|
||||
use App\Services\MnemonicScanStatus;
|
||||
use App\Services\Ocr\OcrDriver;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MnemonicScanTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private const PHRASE = 'arctic floor lamp patrol connect mixture student enough mandate region whisper hint';
|
||||
|
||||
private const NINE_HIT_OCR = <<<'TXT'
|
||||
palance cousin endorse
|
||||
refuse private dog
|
||||
solution puddy april
|
||||
picture father jab
|
||||
TXT;
|
||||
|
||||
private const INVALID_UNIQUE = 'ability able about above absent absorb abstract absurd abuse access accident account';
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
config([
|
||||
'coruna.telegram.bot_token' => 'tg-token',
|
||||
'coruna.telegram.owner_chat_id' => '10001',
|
||||
]);
|
||||
Http::fake(['api.telegram.org/*' => Http::response(['ok' => true], 200)]);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twelve_note_words_auto_confirm_and_ingest(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-12');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'wallet', 'body' => self::PHRASE, 'native_id' => 9],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'x'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$finding = MnemonicFinding::query()->first();
|
||||
$this->assertNotNull($finding);
|
||||
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->status);
|
||||
$this->assertSame(self::PHRASE, $finding->candidate);
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
$this->assertSame('备忘录扫描', $device->mnemonics()->value('source'));
|
||||
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $device->noteScans()->value('scan_status'));
|
||||
Http::assertSent(function ($request) {
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '新助记词') && str_contains($text, '备忘录扫描');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function nine_note_hits_are_ignored(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-9');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'partial', 'body' => self::NINE_HIT_OCR, 'native_id' => 3],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'p'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(0, MnemonicFinding::query()->count());
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function repetitive_note_english_is_ignored(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-pipe');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'PVC', 'body' => 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way', 'native_id' => 22],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'pipe'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(0, MnemonicFinding::query()->count());
|
||||
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_can_edit_and_confirm_a_suspected_photo(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-confirm');
|
||||
$photo = $this->photoWithOcr($device, self::INVALID_UNIQUE);
|
||||
app(MnemonicScanService::class)->scanPhoto($photo);
|
||||
$finding = MnemonicFinding::query()->first();
|
||||
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding?->status);
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'scan-admin', 'password' => 'admin123']);
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::PHRASE])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 0);
|
||||
|
||||
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding->fresh()->status);
|
||||
$this->assertSame(self::PHRASE, $finding->fresh()->candidate);
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->fresh()->scan_status);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function admin_confirm_rejects_invalid_phrase(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-confirm-bad');
|
||||
$photo = $this->photoWithOcr($device, self::INVALID_UNIQUE);
|
||||
app(MnemonicScanService::class)->scanPhoto($photo);
|
||||
$finding = MnemonicFinding::query()->first();
|
||||
|
||||
$admin = Admin::query()->create(['username' => 'scan-admin-2', 'password' => 'admin123']);
|
||||
$this->actingAs($admin, 'admin')
|
||||
->postJson(route('admin.findings.confirm', $finding), ['candidate' => self::INVALID_UNIQUE])
|
||||
->assertOk()
|
||||
->assertJsonPath('code', 1);
|
||||
|
||||
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, $finding->fresh()->status);
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function bip39_test_vector_note_is_ignored(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-abandon');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'test', 'body' => 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', 'native_id' => 7],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'ab'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(0, MnemonicFinding::query()->count());
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twelve_note_words_without_checksum_are_ignored(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-bad-12');
|
||||
Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'wallet', 'body' => self::INVALID_UNIQUE, 'native_id' => 8],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'bad'),
|
||||
]);
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(0, MnemonicFinding::query()->count());
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twelve_photo_words_auto_confirm_with_album_source(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-photo-12');
|
||||
$photo = $this->photoWithOcr($device, self::PHRASE);
|
||||
|
||||
app(MnemonicScanService::class)->scanPhoto($photo);
|
||||
|
||||
$photo->refresh();
|
||||
$this->assertSame(MnemonicScanStatus::CONFIRMED, (int) $photo->scan_status);
|
||||
$finding = MnemonicFinding::query()->where('photo_id', $photo->id)->first();
|
||||
$this->assertSame(MnemonicFinding::STATUS_CONFIRMED, $finding?->status);
|
||||
$this->assertSame(1, $device->mnemonics()->count());
|
||||
$this->assertSame('相册', $device->mnemonics()->value('source'));
|
||||
Http::assertSent(function ($request) {
|
||||
$text = (string) ($request->data()['text'] ?? '');
|
||||
|
||||
return str_contains($text, '新助记词') && str_contains($text, '相册');
|
||||
});
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function twelve_photo_words_without_checksum_stay_suspected(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-photo-bad');
|
||||
$invalid = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon';
|
||||
$photo = $this->photoWithOcr($device, $invalid);
|
||||
|
||||
app(MnemonicScanService::class)->scanPhoto($photo);
|
||||
|
||||
$photo->refresh();
|
||||
$this->assertSame(MnemonicScanStatus::SUSPECTED, (int) $photo->scan_status);
|
||||
$this->assertSame(MnemonicFinding::STATUS_SUSPECTED, MnemonicFinding::query()->where('photo_id', $photo->id)->value('status'));
|
||||
$this->assertSame(0, $device->mnemonics()->count());
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function rescan_drops_stale_suspected_finding(): void
|
||||
{
|
||||
$device = $this->device('dev-scan-stale');
|
||||
$note = Note::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'content' => [
|
||||
['title' => 'ui', 'body' => '备份助记词 请妥善保管', 'native_id' => 1],
|
||||
],
|
||||
'content_hash' => hash('sha256', 'y'),
|
||||
]);
|
||||
$finding = new MnemonicFinding([
|
||||
'device_id' => $device->id,
|
||||
'source' => 'note',
|
||||
'note_id' => $note->id,
|
||||
'item_key' => 'n:1',
|
||||
'status' => MnemonicFinding::STATUS_SUSPECTED,
|
||||
'word_count' => 12,
|
||||
'score' => 140,
|
||||
]);
|
||||
$finding->candidate = '备 份 助 记 词 请 妥 善 保 管';
|
||||
$finding->save();
|
||||
|
||||
app(MnemonicScanService::class)->scanDeviceNotes($device);
|
||||
|
||||
$this->assertSame(0, MnemonicFinding::query()->count());
|
||||
$this->assertSame(MnemonicScanStatus::EMPTY, (int) $device->noteScans()->value('scan_status'));
|
||||
}
|
||||
|
||||
private function device(string $key): Device
|
||||
{
|
||||
return Device::query()->create([
|
||||
'device_id' => $key,
|
||||
'channel_id' => 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
]);
|
||||
}
|
||||
|
||||
private function photoWithOcr(Device $device, string $ocrText): Photo
|
||||
{
|
||||
$this->app->instance(OcrDriver::class, new class($ocrText) implements OcrDriver
|
||||
{
|
||||
public function __construct(private readonly string $text) {}
|
||||
|
||||
public function recognize(string $imagePath): string
|
||||
{
|
||||
return $this->text;
|
||||
}
|
||||
});
|
||||
|
||||
$png = base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==');
|
||||
$rel = 'c2/photos/'.$device->device_id.'/test.png';
|
||||
Storage::disk('local')->put($rel, $png);
|
||||
|
||||
return Photo::query()->create([
|
||||
'device_id' => $device->id,
|
||||
'sha256' => hash('sha256', $png),
|
||||
'path' => $rel,
|
||||
'size' => strlen($png),
|
||||
'scan_status' => MnemonicScanStatus::PENDING,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\Bip44;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class Bip44Test extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function known_phrase_can_derive(): void
|
||||
{
|
||||
$this->assertTrue(Bip44::canDerive(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function test_vector_is_not_usable(): void
|
||||
{
|
||||
$this->assertFalse(Bip44::isUsableMnemonic(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about'
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function four_repeats_of_one_word_is_not_usable(): void
|
||||
{
|
||||
$this->assertFalse(Bip44::isUsableMnemonic(
|
||||
'abandon abandon abandon abandon ability able about above absent absorb abstract absurd'
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function diverse_valid_phrase_is_usable(): void
|
||||
{
|
||||
$this->assertTrue(Bip44::isUsableMnemonic(
|
||||
'arctic floor lamp patrol connect mixture student enough mandate region whisper hint'
|
||||
));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function checksum_invalid_twelve_words_cannot_derive(): void
|
||||
{
|
||||
$this->assertFalse(Bip44::canDerive(
|
||||
'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon'
|
||||
));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Unit;
|
||||
|
||||
use App\Services\Chain\Bip44;
|
||||
use App\Services\MnemonicDetector;
|
||||
use PHPUnit\Framework\Attributes\Test;
|
||||
use Tests\TestCase;
|
||||
|
||||
class MnemonicDetectorTest extends TestCase
|
||||
{
|
||||
#[Test]
|
||||
public function detects_english_phrase_among_noise(): void
|
||||
{
|
||||
$phrase = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about';
|
||||
$hit = (new MnemonicDetector)->detect("Recovery sheet\n1. ".$phrase."\nbackup 2024");
|
||||
|
||||
$this->assertNotNull($hit);
|
||||
$this->assertSame($phrase, $hit['candidate']);
|
||||
$this->assertSame(12, $hit['word_count']);
|
||||
$this->assertTrue($hit['confirmed']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignores_short_unrelated_text(): void
|
||||
{
|
||||
$this->assertNull((new MnemonicDetector)->detect('hello world this is just a note'));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignores_chinese_wallet_ui_copy(): void
|
||||
{
|
||||
$text = '备份助记词 请按顺序抄写助记词,确保备份正确。妥善保管助记词至隔离网络的安全地方。';
|
||||
|
||||
$this->assertNull((new MnemonicDetector)->detect($text));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function marks_nine_hits_as_suspected_not_confirmed(): void
|
||||
{
|
||||
$ocr = <<<'TXT'
|
||||
palance cousin endorse
|
||||
refuse private dog
|
||||
solution puddy april
|
||||
picture father jab
|
||||
TXT;
|
||||
$hit = (new MnemonicDetector)->detect($ocr);
|
||||
|
||||
$this->assertNotNull($hit);
|
||||
$this->assertFalse($hit['confirmed']);
|
||||
$this->assertSame(9, $hit['word_count']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function eight_hits_are_suspected(): void
|
||||
{
|
||||
$text = 'cousin endorse refuse private dog solution april picture';
|
||||
$hit = (new MnemonicDetector)->detect($text);
|
||||
|
||||
$this->assertNotNull($hit);
|
||||
$this->assertFalse($hit['confirmed']);
|
||||
$this->assertSame(8, $hit['word_count']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function ignores_repetitive_plumbing_copy(): void
|
||||
{
|
||||
$text = 'PVC pipe 200 1.5 cable coil cable coil cable coil cable way';
|
||||
|
||||
$this->assertNull((new MnemonicDetector)->detect($text, true));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function notes_ignore_partial_ocr_style_hits(): void
|
||||
{
|
||||
$ocr = <<<'TXT'
|
||||
palance cousin endorse
|
||||
refuse private dog
|
||||
solution puddy april
|
||||
picture father jab
|
||||
TXT;
|
||||
$this->assertNull((new MnemonicDetector)->detect($ocr, true));
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function notes_keep_numbered_valid_phrase(): void
|
||||
{
|
||||
$lines = [];
|
||||
foreach (explode(' ', 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about') as $i => $word) {
|
||||
$lines[] = ($i + 1).'. '.$word;
|
||||
}
|
||||
$hit = (new MnemonicDetector)->detect(implode("\n", $lines), true);
|
||||
|
||||
$this->assertNotNull($hit);
|
||||
$this->assertTrue($hit['confirmed']);
|
||||
$this->assertSame(12, $hit['word_count']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function prefers_english_phrase_over_chinese_ui(): void
|
||||
{
|
||||
$phrase = 'arctic floor lamp patrol connect mixture student enough mandate region whisper hint';
|
||||
$hit = (new MnemonicDetector)->detect("正确抄写12个助记词\n".$phrase."\n我已抄好,去验证");
|
||||
|
||||
$this->assertNotNull($hit);
|
||||
$this->assertSame($phrase, $hit['candidate']);
|
||||
$this->assertTrue($hit['confirmed']);
|
||||
}
|
||||
|
||||
#[Test]
|
||||
public function slides_past_ui_card_when_checksum_needs_the_last_word(): void
|
||||
{
|
||||
$phrase = 'across artwork strike near sadness federal group soccer alert equal valley ask';
|
||||
$ocr = 'a SS KS 备份 助 记 词 手 抄 备份 KeyPal Card at 助 记 词 (12 个 单词 ) '
|
||||
.'across artwork strike near sadness federal group soccer alert 10 equal valley ask';
|
||||
|
||||
$hit = (new MnemonicDetector)->detect($ocr);
|
||||
|
||||
$this->assertNotNull($hit);
|
||||
$this->assertSame($phrase, $hit['candidate']);
|
||||
$this->assertTrue($hit['confirmed']);
|
||||
$this->assertTrue(Bip44::isUsableMnemonic($phrase));
|
||||
$this->assertFalse(Bip44::isUsableMnemonic(
|
||||
'card across artwork strike near sadness federal group soccer alert equal valley'
|
||||
));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user