Files
XC_VM/tests/Unit/TicketVisibilityTest.php
T
AbdoAhmedElbanaaandDivarion_D 5c6f9f920c fix(tickets): overhaul ticket lifecycle, visibility and admin interactions
Imported from Rosmi720/XC_VM@260ca7ab, with the scoping and permission gaps
closed before merge.

Original work:
- Keep tickets listed through a LEFT JOIN so one does not vanish when its
  author's user row is edited or deleted; username falls back to 'Unknown'.
- Stop double-escaping message bodies: the repository returned
  htmlspecialchars() output that both ticket_view templates escape again, so
  markup showed up as entities. The repository now returns raw text and the
  views keep their nl2br(htmlspecialchars()).
- Drop the str_repeat('  ', ...) padding hack that rendered literal
  ' ' inside short replies.
- Resolve last_reply and the derived status flags without undefined-key
  warnings on tickets that have no replies yet.
- Add Close / Re-Open to the admin ticket view with a confirmation dialog, a
  quick reply form under the thread, and a Reply entry in the tickets table.
- Add English and Arabic strings for the confirmation dialogs.

Changed on import:
- Tenant isolation: the original widened the admin branch to every ticket on
  the server whenever $rAdmin was set, and admin/tickets.php always sets it.
  Since users_groups.is_admin is a flag several groups can carry, that exposed
  every tenant's tickets to any admin-panel user. Only the super-admin group
  (member_group_id = 1) now gets the unscoped list; any other admin stays
  scoped to the users it owns. TicketVisibilityTest covers all three scopes.
- Gate Close / Re-Open, the quick reply form and the Reply links behind the
  same Authorization::check('adv', 'ticket') the tickets table already used.
- Quote 'Unknown' as a string literal, not "Unknown", which ANSI_QUOTES would
  read as an identifier.
- Escape the textarea placeholder.
- Drop the admin/functions.php hunk: that file no longer exists after the PSR-4
  move and AdminApiStage already populates $GLOBALS['rAdminUserInfo']. The
  post.php fallback is kept, since post.php can be served standalone.

Verified: 857 tests, make gates, CRAP gate.
2026-09-22 16:32:39 +03:00

123 lines
3.9 KiB
PHP

<?php
namespace XcVm\Tests\Unit;
use PHPUnit\Framework\TestCase;
use TestDb;
use XcVm\Domain\User\TicketRepository;
use XcVm\Infrastructure\Database\DatabaseFactory;
/**
* Tenant isolation for the ticket list.
*
* `users_groups`.`is_admin` is a group flag that more than one group can carry,
* so reaching the admin panel must not mean seeing every tenant's tickets: only
* the super-admin group (member_group_id = 1) gets the whole server, while any
* other admin stays scoped to the users it owns.
*
* Hierarchy seeded: super-admin(1); sub-admin(2) owns reseller(3) who owns
* sub-reseller(4); stranger-admin(90) owns stranger-reseller(91), an unrelated
* branch that must stay invisible to the sub-admin.
*/
final class TicketVisibilityTest extends TestCase {
private TestDb $db;
protected function setUp(): void {
$this->db = new TestDb();
$this->db->exec(
'CREATE TABLE `users` (
`id` INTEGER PRIMARY KEY,
`username` TEXT,
`owner_id` INTEGER,
`member_group_id` INTEGER
);
INSERT INTO `users` (`id`,`username`,`owner_id`,`member_group_id`) VALUES
(1,"superadmin",0,1),
(2,"subadmin",1,3),
(3,"reseller",2,4),
(4,"subreseller",3,4),
(90,"strangeradmin",1,3),
(91,"strangerreseller",90,4);
CREATE TABLE `tickets` (
`id` INTEGER PRIMARY KEY,
`member_id` INTEGER,
`title` TEXT,
`status` INTEGER DEFAULT 1,
`admin_read` INTEGER DEFAULT 0,
`user_read` INTEGER DEFAULT 0
);
INSERT INTO `tickets` (`id`,`member_id`,`title`) VALUES
(10,2,"from subadmin"),
(11,3,"from reseller"),
(12,4,"from subreseller"),
(13,91,"from stranger branch");
CREATE TABLE `tickets_replies` (
`id` INTEGER PRIMARY KEY,
`ticket_id` INTEGER,
`date` INTEGER,
`admin_reply` INTEGER DEFAULT 0,
`message` TEXT
);'
);
DatabaseFactory::set($this->db);
$GLOBALS['rPermissions'] = ['all_reports' => []];
}
protected function tearDown(): void {
DatabaseFactory::reset();
unset($GLOBALS['rUserInfo'], $GLOBALS['rPermissions']);
}
public function testSuperAdminSeesEveryTicketOnTheServer(): void {
$GLOBALS['rUserInfo'] = ['id' => 1, 'member_group_id' => 1];
$this->assertSame([10, 11, 12, 13], $this->ticketIds(TicketRepository::getAll(1, true)));
}
public function testNonSuperAdminDoesNotSeeAnotherAdminsBranch(): void {
// Regression guard: an `$rAdmin` call used to widen to every ticket on the
// server, exposing other admins' tenants to any admin-panel user.
$GLOBALS['rUserInfo'] = ['id' => 2, 'member_group_id' => 3];
$rIds = $this->ticketIds(TicketRepository::getAll(2, true));
$this->assertSame([10, 11], $rIds, 'own ticket plus the users it owns');
$this->assertNotContains(13, $rIds, 'stranger branch must stay hidden');
}
public function testResellerSeesOwnTicketsPlusReports(): void {
$GLOBALS['rUserInfo'] = ['id' => 3, 'member_group_id' => 4];
$GLOBALS['rPermissions'] = ['all_reports' => [4]];
$this->assertSame([11, 12], $this->ticketIds(TicketRepository::getAll(3)));
}
public function testMissingIdStillListsEverything(): void {
$GLOBALS['rUserInfo'] = ['id' => 1, 'member_group_id' => 1];
$this->assertSame([10, 11, 12, 13], $this->ticketIds(TicketRepository::getAll(null, true)));
}
public function testTicketSurvivesItsAuthorBeingDeleted(): void {
$this->db->exec('DELETE FROM `users` WHERE `id` = 91;');
$GLOBALS['rUserInfo'] = ['id' => 1, 'member_group_id' => 1];
$rRows = TicketRepository::getAll(1, true);
$rOrphan = array_values(array_filter($rRows, static fn(array $r): bool => (int) $r['id'] === 13));
$this->assertCount(1, $rOrphan, 'the ticket must not vanish with its author');
$this->assertSame('Unknown', $rOrphan[0]['username']);
}
/**
* @param array<int, array<string, mixed>> $rRows
* @return int[] Ticket ids, ascending.
*/
private function ticketIds(array $rRows): array {
$rIds = array_map(static fn(array $r): int => (int) $r['id'], $rRows);
sort($rIds);
return $rIds;
}
}