mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-10-31 19:03:43 +01:00
- An activation code's device was saved before its lock was checked: a request with the code, no MAC and a device id of its own left that id on the code and was then refused, and the code's own box was refused from then on. The binding is saved once the request has passed. - A refused code at the web player's sign-in was counted nowhere, the one sign-in that could be tried without limit. It is counted as the code API counts it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AneTzAHPPkYWPQjwyZ2td9
184 lines
7.4 KiB
PHP
184 lines
7.4 KiB
PHP
<?php
|
|
|
|
namespace XcVm\Tests\Unit;
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use XcVm\Core\Database\DatabaseHandler;
|
|
use XcVm\Domain\Line\ActiveCodeService;
|
|
use XcVm\Infrastructure\Database\DatabaseFactory;
|
|
|
|
/**
|
|
* A DatabaseHandler that answers the handful of statements activateCode issues
|
|
* from in-memory tables, and never opens a connection.
|
|
*/
|
|
class ActivationScriptedDb extends DatabaseHandler {
|
|
public array $codes = [];
|
|
public array $lines = [];
|
|
public array $packages = [];
|
|
/** Returned by the next code lookup instead of the table row: a read taken before another request's write. */
|
|
public ?array $staleCode = null;
|
|
|
|
private array $rows = [];
|
|
private int $affected = 0;
|
|
|
|
public function __construct() {
|
|
$this->dbh = true; // Database's helpers only test it for truthiness
|
|
}
|
|
|
|
public function query($query, $buffered = false) {
|
|
$rBinds = array_slice(func_get_args(), 1);
|
|
$this->rows = [];
|
|
$this->affected = 0;
|
|
|
|
if (str_contains($query, 'FROM `activation_codes` WHERE `activation_code` = ?')) {
|
|
if ($this->staleCode !== null) {
|
|
$this->rows = [$this->staleCode];
|
|
$this->staleCode = null;
|
|
} else {
|
|
$this->rows = array_values(array_filter($this->codes, fn($r) => $r['activation_code'] === $rBinds[0]));
|
|
}
|
|
} elseif (str_contains($query, 'FROM `lines` WHERE `id` = ?')) {
|
|
$this->rows = isset($this->lines[$rBinds[0]]) ? [$this->lines[$rBinds[0]]] : [];
|
|
} elseif (str_contains($query, 'FROM `users_packages` WHERE `id` = ?')) {
|
|
$this->rows = isset($this->packages[$rBinds[0]]) ? [$this->packages[$rBinds[0]]] : [];
|
|
} elseif (preg_match('/^UPDATE `activation_codes` SET ((?:`(?:mac|device_id)` = \?(?:, )?)+) WHERE `id` = \?;$/', trim($query), $rBinding)) {
|
|
// A later sign-in's binding of the device it came from.
|
|
preg_match_all('/`(mac|device_id)`/', $rBinding[1], $rColumns);
|
|
$rID = array_pop($rBinds);
|
|
foreach ($rColumns[1] as $i => $rColumn) {
|
|
$this->codes[$rID][$rColumn] = $rBinds[$i];
|
|
}
|
|
$this->affected = 1;
|
|
} elseif (str_starts_with(trim($query), 'UPDATE `activation_codes`')) {
|
|
[$rAt, $rMac, $rDevice, $rID] = $rBinds;
|
|
$rRow = &$this->codes[$rID];
|
|
// The claim: when the statement carries the "still unactivated" guard,
|
|
// a row another request already activated is left alone.
|
|
if (str_contains($query, '(`status` = 1 OR `activated_at` IS NULL)') && $rRow['status'] != 1 && $rRow['activated_at'] !== null) {
|
|
return true;
|
|
}
|
|
$rRow['status'] = 2;
|
|
$rRow['activated_at'] = $rAt;
|
|
$rRow['mac'] = $rMac ?? $rRow['mac'];
|
|
$rRow['device_id'] = $rDevice ?? $rRow['device_id'];
|
|
$this->affected = 1;
|
|
} elseif (str_starts_with(trim($query), 'UPDATE `lines`')) {
|
|
[$rExp, $rIP, $rNow, $rID] = $rBinds;
|
|
$this->lines[$rID]['exp_date'] = $rExp;
|
|
$this->affected = 1;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
public function num_rows() {
|
|
return $this->rows ? count($this->rows) : $this->affected;
|
|
}
|
|
|
|
public function get_row() {
|
|
return $this->rows[0] ?? [];
|
|
}
|
|
}
|
|
|
|
class ActiveCodeActivationTest extends TestCase {
|
|
private ActivationScriptedDb $db;
|
|
|
|
protected function setUp(): void {
|
|
if (!defined('SERVER_ID')) {
|
|
define('SERVER_ID', 1);
|
|
}
|
|
$GLOBALS['rSettings'] = ['keep_protocol' => 0, 'use_mdomain_in_lists' => 0];
|
|
$GLOBALS['rServers'] = [1 => [
|
|
'server_protocol' => 'http', 'enable_proxy' => 0, 'domain_name' => 'panel.test',
|
|
'server_ip' => '10.0.0.1', 'http_broadcast_port' => 80, 'https_broadcast_port' => 443, 'server_type' => 0,
|
|
]];
|
|
|
|
$this->db = new ActivationScriptedDb();
|
|
$this->db->packages[3] = ['id' => 3, 'package_name' => 'P', 'official_duration' => 1, 'official_duration_in' => 'months'];
|
|
$this->db->lines[7] = ['id' => 7, 'username' => 'u7', 'password' => 'p7', 'exp_date' => time() + 86400, 'max_connections' => 1];
|
|
DatabaseFactory::set($this->db);
|
|
}
|
|
|
|
private function code(array $rOver): array {
|
|
return array_merge([
|
|
'id' => 1, 'activation_code' => 'ABCD234567', 'status' => 2, 'subscriber_id' => 7, 'package_id' => 3,
|
|
'is_trial' => 0, 'mac' => null, 'device_id' => null, 'activated_at' => time() - 3600, 'dns_base' => '',
|
|
], $rOver);
|
|
}
|
|
|
|
/** A code locked to a device must not hand its credentials to a request that simply names no device. */
|
|
public function testLockedCodeRefusesARequestWithoutAMac(): void {
|
|
$this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC']);
|
|
|
|
$rRes = ActiveCodeService::activateCode('ABCD234567', ['ip' => '192.0.2.9']);
|
|
|
|
$this->assertSame('DEVICE_MISMATCH', $rRes['status']);
|
|
$this->assertArrayNotHasKey('credentials', $rRes);
|
|
}
|
|
|
|
public function testLockedCodeServesItsOwnDevice(): void {
|
|
$this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC']);
|
|
|
|
$rRes = ActiveCodeService::activateCode('abcd234567', ['mac' => '00:1a:79:aa:bb:cc']);
|
|
|
|
$this->assertSame('SUCCESS', $rRes['status']);
|
|
$this->assertSame('u7', $rRes['credentials']['username']);
|
|
$this->assertFalse($rRes['is_new_activation']);
|
|
}
|
|
|
|
/**
|
|
* A request the device lock refuses binds nothing. The binding was saved
|
|
* before the lock was checked: the code, no MAC and a device id of the
|
|
* caller's choosing left that id on the code, and its own box was refused
|
|
* from then on.
|
|
*/
|
|
public function testARefusedRequestLeavesTheCodeAsItWas(): void {
|
|
$this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC']);
|
|
|
|
$this->assertSame('DEVICE_MISMATCH', ActiveCodeService::activateCode('ABCD234567', ['device_id' => 'EVIL-BOX'])['status']);
|
|
$this->assertNull($this->db->codes[1]['device_id'], 'nothing bound by the refused request');
|
|
|
|
$rOwn = ActiveCodeService::activateCode('ABCD234567', ['mac' => '00:1A:79:AA:BB:CC', 'device_id' => 'REAL-BOX']);
|
|
$this->assertSame('SUCCESS', $rOwn['status'], 'its own box still signs in');
|
|
$this->assertSame('REAL-BOX', $this->db->codes[1]['device_id'], 'and binds its own id');
|
|
}
|
|
|
|
public function testUnboundCodeServesAnyone(): void {
|
|
$this->db->codes[1] = $this->code([]);
|
|
|
|
$this->assertSame('SUCCESS', ActiveCodeService::activateCode('ABCD234567', [])['status']);
|
|
}
|
|
|
|
public function testFirstActivationBindsTheDeviceAndStartsTheCountdown(): void {
|
|
$this->db->codes[1] = $this->code(['status' => 1, 'activated_at' => null]);
|
|
$this->db->lines[7]['exp_date'] = null;
|
|
|
|
$rRes = ActiveCodeService::activateCode('ABCD234567', ['mac' => '00:1A:79:AA:BB:CC']);
|
|
|
|
$this->assertSame('SUCCESS', $rRes['status']);
|
|
$this->assertTrue($rRes['is_new_activation']);
|
|
$this->assertSame('00:1A:79:AA:BB:CC', $this->db->codes[1]['mac']);
|
|
$this->assertGreaterThan(time() + 27 * 86400, $this->db->lines[7]['exp_date']);
|
|
}
|
|
|
|
/**
|
|
* Two requests read a fresh code at once. The first binds its device; the
|
|
* second must not re-bind the code to itself or restart the countdown — it
|
|
* meets the lock like any later request.
|
|
*/
|
|
public function testTheLoserOfAnActivationRaceMeetsTheLock(): void {
|
|
$rActivatedAt = time() - 2;
|
|
$rExp = time() + 30 * 86400 - 2;
|
|
$this->db->codes[1] = $this->code(['mac' => '00:1A:79:AA:BB:CC', 'activated_at' => $rActivatedAt]);
|
|
$this->db->lines[7]['exp_date'] = $rExp;
|
|
$this->db->staleCode = $this->code(['status' => 1, 'activated_at' => null]); // what the loser read
|
|
|
|
$rRes = ActiveCodeService::activateCode('ABCD234567', ['mac' => '00:1A:79:DD:EE:FF']);
|
|
|
|
$this->assertSame('DEVICE_MISMATCH', $rRes['status']);
|
|
$this->assertSame('00:1A:79:AA:BB:CC', $this->db->codes[1]['mac'], 'the winner keeps the device');
|
|
$this->assertSame($rActivatedAt, $this->db->codes[1]['activated_at']);
|
|
$this->assertSame($rExp, $this->db->lines[7]['exp_date'], 'the countdown is not restarted');
|
|
}
|
|
}
|