From 6ab6fe5a539a01669c417d88c64b066fb902761c Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:11:44 +0100 Subject: [PATCH 1/6] test: extend stubs for page controller coverage --- tests/bootstrap-standalone.php | 49 ++++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/tests/bootstrap-standalone.php b/tests/bootstrap-standalone.php index c0f85f8..a5deb62 100644 --- a/tests/bootstrap-standalone.php +++ b/tests/bootstrap-standalone.php @@ -52,6 +52,23 @@ public function boot(IBootContext $context): void; if (!interface_exists('OCP\\IUserSession', false)) { eval('namespace OCP; interface IUserSession { public function getUser(); }'); } +if (!interface_exists('OCP\\IURLGenerator', false)) { + eval(' + namespace OCP; + interface IURLGenerator { + public function linkTo(string $app, string $file); + public function linkToRoute(string $routeName, array $arguments = []); + } + '); +} +if (!interface_exists('OCP\\AppFramework\\Services\\IInitialState', false)) { + eval(' + namespace OCP\\AppFramework\\Services; + interface IInitialState { + public function provideInitialState(string $key, mixed $value): void; + } + '); +} if (!class_exists('OCP\\AppFramework\\Controller', false)) { eval(' namespace OCP\\AppFramework; @@ -77,6 +94,21 @@ class Http { } '); } +if (!class_exists('OCP\\AppFramework\\Http\\ContentSecurityPolicy', false)) { + eval(' + namespace OCP\\AppFramework\\Http; + class ContentSecurityPolicy { + public array $workerSrc = []; + public array $scriptDomains = []; + public array $connectDomains = []; + public array $frameDomains = []; + public function addAllowedWorkerSrcDomain(string $domain): void { $this->workerSrc[] = $domain; } + public function addAllowedScriptDomain(string $domain): void { $this->scriptDomains[] = $domain; } + public function addAllowedConnectDomain(string $domain): void { $this->connectDomains[] = $domain; } + public function addAllowedFrameDomain(string $domain): void { $this->frameDomains[] = $domain; } + } + '); +} if (!class_exists('OCP\\AppFramework\\Http\\DataResponse', false)) { eval(' namespace OCP\\AppFramework\\Http; @@ -93,6 +125,18 @@ public function addHeader(string $name, string $value): void { $this->headers[$n public function getHeaders(): array { return $this->headers; } } class DataDisplayResponse extends DataResponse {} + class TemplateResponse extends DataResponse { + public const RENDER_AS_USER = "user"; + public ?ContentSecurityPolicy $contentSecurityPolicy = null; + public function __construct( + public string $appName, + public string $templateName, + array $params = [], + public string $renderAs = self::RENDER_AS_USER, + ) { parent::__construct($params, 200); } + public function setContentSecurityPolicy(ContentSecurityPolicy $policy): void { $this->contentSecurityPolicy = $policy; } + public function getContentSecurityPolicy(): ?ContentSecurityPolicy { return $this->contentSecurityPolicy; } + } class StreamResponse extends DataResponse { public function __construct($stream) { parent::__construct($stream, 200); } public function getStream() { return $this->data; } @@ -130,6 +174,11 @@ public function getStorage(); public function getInternalPath(); public function fopen($mode); public function getSize(); + public function getId(); + public function getPath(); + public function getMTime(); + public function getEtag(); + public function isUpdateable(); } '); } From 32c78efd42a3c9217167396b31b6b4ba2e5ed9b3 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:12:19 +0100 Subject: [PATCH 2/6] test: cover service worker controller --- tests/Unit/Controller/SwControllerTest.php | 26 ++++++++++++++++++++++ 1 file changed, 26 insertions(+) create mode 100644 tests/Unit/Controller/SwControllerTest.php diff --git a/tests/Unit/Controller/SwControllerTest.php b/tests/Unit/Controller/SwControllerTest.php new file mode 100644 index 0000000..04dfc78 --- /dev/null +++ b/tests/Unit/Controller/SwControllerTest.php @@ -0,0 +1,26 @@ +createMock(IRequest::class)); + + $response = $controller->index(); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertIsString($response->getData()); + self::assertStringContainsString('addEventListener', $response->getData()); + self::assertSame('text/javascript; charset=utf-8', $response->getHeaders()['Content-Type']); + self::assertSame('/apps/exelearning/', $response->getHeaders()['Service-Worker-Allowed']); + self::assertSame('public, max-age=300', $response->getHeaders()['Cache-Control']); + self::assertSame('nosniff', $response->getHeaders()['X-Content-Type-Options']); + } +} From 987fda8636ce7ca778083963fd665c181ca7f15d Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:12:21 +0100 Subject: [PATCH 3/6] test: cover template controller --- .../Controller/TemplateControllerTest.php | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 tests/Unit/Controller/TemplateControllerTest.php diff --git a/tests/Unit/Controller/TemplateControllerTest.php b/tests/Unit/Controller/TemplateControllerTest.php new file mode 100644 index 0000000..ca63342 --- /dev/null +++ b/tests/Unit/Controller/TemplateControllerTest.php @@ -0,0 +1,48 @@ +createMock(IUserSession::class); + $session->method('getUser')->willReturn(null); + $controller = new TemplateController( + 'exelearning', + $this->createMock(IRequest::class), + $session, + ); + + $response = $controller->blank(); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + self::assertSame(['error' => 'Not authenticated'], $response->getData()); + } + + public function testStreamsBundledBlankPackage(): void { + $session = $this->createMock(IUserSession::class); + $session->method('getUser')->willReturn($this->createMock(IUser::class)); + $controller = new TemplateController( + 'exelearning', + $this->createMock(IRequest::class), + $session, + ); + + $response = $controller->blank(); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertIsResource($response->getStream()); + self::assertSame('application/vnd.exelearning.elpx', $response->getHeaders()['Content-Type']); + self::assertGreaterThan(0, (int)$response->getHeaders()['Content-Length']); + self::assertSame('nosniff', $response->getHeaders()['X-Content-Type-Options']); + self::assertSame('private, max-age=300', $response->getHeaders()['Cache-Control']); + } +} From fc2655c6bf3e7918f27731f23e1fea8befbb0bf2 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:12:23 +0100 Subject: [PATCH 4/6] test: cover view controller --- tests/Unit/Controller/ViewControllerTest.php | 161 +++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 tests/Unit/Controller/ViewControllerTest.php diff --git a/tests/Unit/Controller/ViewControllerTest.php b/tests/Unit/Controller/ViewControllerTest.php new file mode 100644 index 0000000..a659c02 --- /dev/null +++ b/tests/Unit/Controller/ViewControllerTest.php @@ -0,0 +1,161 @@ +session = $this->createMock(IUserSession::class); + $this->packages = $this->createMock(ElpxPackageService::class); + $this->initialState = new InitialStateRecorder(); + $this->urlGenerator = $this->createMock(IURLGenerator::class); + $this->urlGenerator->method('linkToRoute') + ->willReturn('/apps/exelearning/editor/iframe'); + $this->controller = new ViewController( + 'exelearning', + $this->createMock(IRequest::class), + $this->session, + $this->packages, + $this->initialState, + $this->urlGenerator, + ); + } + + public function testRequiresAuthentication(): void { + $this->session->method('getUser')->willReturn(null); + + $response = $this->controller->index(fileId: 42); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + self::assertSame(['error' => 'Not authenticated'], $response->getData()); + } + + public function testProvidesFileStateWhenResolvedById(): void { + $this->authenticate(); + $file = $this->packageFile(); + $this->packages->expects(self::once()) + ->method('getForUserById') + ->with('alice', 42) + ->willReturn($file); + + $response = $this->controller->index(fileId: 42); + + self::assertSame([ + 'id' => 42, + 'name' => 'lesson.elpx', + 'path' => '/Lessons/lesson.elpx', + 'mtime' => 123456, + 'etag' => 'etag-1', + 'writable' => true, + ], $this->initialState->states['file']); + $this->assertPageStateAndPolicy($response, 'preview'); + } + + public function testProvidesFileStateWhenResolvedByPathAndEditorMode(): void { + $this->authenticate(); + $file = $this->packageFile(); + $this->packages->expects(self::once()) + ->method('getForUserByPath') + ->with('alice', 'Lessons/lesson.elpx') + ->willReturn($file); + + $response = $this->controller->index(path: 'Lessons/lesson.elpx', mode: 'editor'); + + self::assertSame(42, $this->initialState->states['file']['id']); + $this->assertPageStateAndPolicy($response, 'editor'); + } + + public function testLookupErrorsStillRenderPageWithoutFileState(): void { + $this->authenticate(); + $this->packages->expects(self::exactly(2)) + ->method('getForUserById') + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + $missing = $this->controller->index(fileId: 42); + $denied = $this->controller->index(fileId: 43); + + self::assertSame(Http::STATUS_OK, $missing->getStatus()); + self::assertSame(Http::STATUS_OK, $denied->getStatus()); + self::assertArrayNotHasKey('file', $this->initialState->states); + } + + public function testPathLookupErrorAndEmptySelectionRenderPreview(): void { + $this->authenticate(); + $this->packages->method('getForUserByPath')->willThrowException(new NotFoundException('missing')); + + $failed = $this->controller->index(path: 'missing.elpx'); + self::assertSame(Http::STATUS_OK, $failed->getStatus()); + self::assertArrayNotHasKey('file', $this->initialState->states); + + $this->initialState->states = []; + $empty = $this->controller->index(path: ''); + $this->assertPageStateAndPolicy($empty, 'preview'); + } + + private function authenticate(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->session->method('getUser')->willReturn($user); + } + + private function packageFile(): File { + $file = $this->createMock(File::class); + $file->method('getId')->willReturn(42); + $file->method('getName')->willReturn('lesson.elpx'); + $file->method('getPath')->willReturn('/Lessons/lesson.elpx'); + $file->method('getMTime')->willReturn(123456); + $file->method('getEtag')->willReturn('etag-1'); + $file->method('isUpdateable')->willReturn(true); + return $file; + } + + private function assertPageStateAndPolicy(object $response, string $mode): void { + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame(false, $this->initialState->states['editorAvailable']); + self::assertSame('/apps/exelearning/editor/iframe', $this->initialState->states['editorIframeUrl']); + self::assertSame($mode, $this->initialState->states['initialMode']); + self::assertSame([[Application::APP_ID, 'exelearning-view']], Util::$scripts); + + $policy = $response->getContentSecurityPolicy(); + self::assertNotNull($policy); + self::assertSame(["'self'"], $policy->workerSrc); + self::assertSame(["'self'"], $policy->scriptDomains); + self::assertSame(["'self'"], $policy->connectDomains); + self::assertSame(["'self'"], $policy->frameDomains); + } +} + +final class InitialStateRecorder implements IInitialState { + /** @var array */ + public array $states = []; + + public function provideInitialState(string $key, mixed $value): void { + $this->states[$key] = $value; + } +} From c3ede291188f73f650d7e49bab2e29f0a4d2390f Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:12:29 +0100 Subject: [PATCH 5/6] test: include page controllers in PHP coverage --- tests/phpunit.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 7e18de9..3917521 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -17,7 +17,10 @@ ../lib/AppInfo ../lib/Controller/AssetController.php ../lib/Controller/PackageController.php + ../lib/Controller/SwController.php + ../lib/Controller/TemplateController.php ../lib/Controller/ThumbnailController.php + ../lib/Controller/ViewController.php ../lib/Preview ../lib/Service From 4f08f71ece40987863f640e0a3c3c2917564d5e4 Mon Sep 17 00:00:00 2001 From: Ernesto Serrano Date: Tue, 22 Sep 2026 05:13:09 +0100 Subject: [PATCH 6/6] test: reset script registry between view cases --- tests/Unit/Controller/ViewControllerTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/Unit/Controller/ViewControllerTest.php b/tests/Unit/Controller/ViewControllerTest.php index a659c02..47ae3fc 100644 --- a/tests/Unit/Controller/ViewControllerTest.php +++ b/tests/Unit/Controller/ViewControllerTest.php @@ -114,6 +114,7 @@ public function testPathLookupErrorAndEmptySelectionRenderPreview(): void { self::assertArrayNotHasKey('file', $this->initialState->states); $this->initialState->states = []; + Util::reset(); $empty = $this->controller->index(path: ''); $this->assertPageStateAndPolicy($empty, 'preview'); }