From 32aea1f8635f44560c4368bc84ad43ee192c3ffd Mon Sep 17 00:00:00 2001 From: Jake Barnby Date: Sun, 17 May 2026 00:33:03 +1200 Subject: [PATCH] fix: preserve legacy schedule access --- .../Modules/Projects/Http/Schedules/Get.php | 4 +- .../Modules/Projects/Http/Schedules/XList.php | 16 +- .../Modules/Projects/SchedulesTest.php | 159 ++++++++++++++++++ 3 files changed, 176 insertions(+), 3 deletions(-) create mode 100644 tests/unit/Platform/Modules/Projects/SchedulesTest.php diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php index f89eecd1a0..05c4a925db 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/Get.php @@ -67,9 +67,11 @@ class Get extends Action throw new Exception(Exception::SCHEDULE_NOT_FOUND); } + $projectInternalId = $schedule->getAttribute('projectInternalId'); + if ( $schedule->getAttribute('projectId') !== $project->getId() - || $schedule->getAttribute('projectInternalId') !== $project->getSequence() + || ($projectInternalId !== null && $projectInternalId !== '' && $projectInternalId !== $project->getSequence()) ) { throw new Exception(Exception::SCHEDULE_NOT_FOUND); } diff --git a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php index d0e6ad3036..7f82162a2c 100644 --- a/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php +++ b/src/Appwrite/Platform/Modules/Projects/Http/Schedules/XList.php @@ -77,7 +77,10 @@ class XList extends Action } $queries[] = Query::equal('projectId', [$project->getId()]); - $queries[] = Query::equal('projectInternalId', [$project->getSequence()]); + $queries[] = Query::or([ + Query::equal('projectInternalId', [$project->getSequence()]), + Query::isNull('projectInternalId'), + ]); $cursor = Query::getCursorQueries($queries, false); $cursor = \reset($cursor); @@ -94,7 +97,7 @@ class XList extends Action if ( $cursorDocument->isEmpty() || $cursorDocument->getAttribute('projectId') !== $project->getId() - || $cursorDocument->getAttribute('projectInternalId') !== $project->getSequence() + || !$this->matchesProjectInternalId($cursorDocument, $project) ) { throw new Exception(Exception::GENERAL_CURSOR_NOT_FOUND, "Schedule '{$scheduleId}' for the 'cursor' value not found."); } @@ -116,4 +119,13 @@ class XList extends Action 'total' => $total, ]), Response::MODEL_SCHEDULE_LIST); } + + private function matchesProjectInternalId(Document $schedule, Document $project): bool + { + $projectInternalId = $schedule->getAttribute('projectInternalId'); + + return $projectInternalId === null + || $projectInternalId === '' + || $projectInternalId === $project->getSequence(); + } } diff --git a/tests/unit/Platform/Modules/Projects/SchedulesTest.php b/tests/unit/Platform/Modules/Projects/SchedulesTest.php new file mode 100644 index 0000000000..ad714d3d99 --- /dev/null +++ b/tests/unit/Platform/Modules/Projects/SchedulesTest.php @@ -0,0 +1,159 @@ +document = $document; + $this->model = $model; + } +} + +class SchedulesTest extends TestCase +{ + private Authorization $authorization; + private Database $database; + private Document $project; + + protected function setUp(): void + { + $this->authorization = new Authorization(); + $this->authorization->addRole(Role::any()->toString()); + + $this->database = new Database(new Memory(), new Cache(new NoCache())); + $this->database + ->setAuthorization($this->authorization) + ->setDatabase('scheduleTests') + ->setNamespace('schedules_' . \uniqid()); + + $permissions = [ + Permission::create(Role::any()), + Permission::read(Role::any()), + Permission::update(Role::any()), + Permission::delete(Role::any()), + ]; + + $this->database->create(); + $this->database->createCollection('projects', [], [], $permissions, false); + $this->database->createCollection('schedules', [], [], $permissions, false); + $this->database->createAttribute('schedules', 'projectId', Database::VAR_STRING, 255, true); + $this->database->createAttribute('schedules', 'projectInternalId', Database::VAR_ID, 0, false); + + $this->project = $this->database->createDocument('projects', new Document([ + '$id' => 'project-a', + ])); + } + + protected function tearDown(): void + { + $this->authorization->cleanRoles(); + $this->authorization->addRole(Role::any()->toString()); + } + + public function testGetAllowsLegacyScheduleWithoutProjectInternalId(): void + { + $this->createSchedule('legacy'); + + $response = new CapturingSchedulesResponse(); + + (new Get())->action('project-a', 'legacy', $response, $this->database); + + $this->assertSame('legacy', $response->document->getId()); + $this->assertSame(Response::MODEL_SCHEDULE, $response->model); + } + + public function testGetRejectsScheduleWithMismatchedProjectInternalId(): void + { + $this->createSchedule('mismatch', projectInternalId: '999'); + + $this->expectException(AppwriteException::class); + + (new Get())->action('project-a', 'mismatch', new CapturingSchedulesResponse(), $this->database); + } + + public function testListIncludesLegacyAndCurrentProjectSchedules(): void + { + $this->createSchedule('legacy'); + $this->createSchedule('current', projectInternalId: $this->project->getSequence()); + $this->createSchedule('mismatch', projectInternalId: '999'); + $this->createSchedule('other-project', projectId: 'project-b'); + + $response = new CapturingSchedulesResponse(); + + (new XList())->action('project-a', [], true, $response, $this->database); + + $schedules = $response->document->getAttribute('schedules'); + $scheduleIds = \array_map( + fn (Document $schedule) => $schedule->getId(), + $schedules, + ); + + $this->assertContains('legacy', $scheduleIds); + $this->assertContains('current', $scheduleIds); + $this->assertNotContains('mismatch', $scheduleIds); + $this->assertNotContains('other-project', $scheduleIds); + $this->assertSame(2, $response->document->getAttribute('total')); + $this->assertSame(Response::MODEL_SCHEDULE_LIST, $response->model); + } + + public function testListCursorAllowsLegacyScheduleWithoutProjectInternalId(): void + { + $legacy = $this->createSchedule('legacy'); + $this->createSchedule('current', projectInternalId: $this->project->getSequence()); + + $response = new CapturingSchedulesResponse(); + + (new XList())->action( + 'project-a', + [Query::cursorAfter($legacy)->toString()], + true, + $response, + $this->database, + ); + + $this->assertSame(Response::MODEL_SCHEDULE_LIST, $response->model); + } + + private function createSchedule( + string $id, + string $projectId = 'project-a', + int|string|null $projectInternalId = null, + ): Document { + $attributes = [ + '$id' => $id, + 'projectId' => $projectId, + ]; + + if ($projectInternalId !== null) { + $attributes['projectInternalId'] = $projectInternalId; + } + + return $this->database->createDocument('schedules', new Document($attributes)); + } +}