diff --git a/codecov.yml b/codecov.yml index 1cad411..a92d6a7 100644 --- a/codecov.yml +++ b/codecov.yml @@ -15,7 +15,6 @@ ignore: - "js/**/*" - "tests/**/*" - "vendor/**/*" - - "lib/Controller/**/*" - "src/main.ts" - "src/editor/editor-frame.ts" - "src/editor/editor-page.ts" diff --git a/tests/Unit/Controller/AssetControllerTest.php b/tests/Unit/Controller/AssetControllerTest.php new file mode 100644 index 0000000..4fed135 --- /dev/null +++ b/tests/Unit/Controller/AssetControllerTest.php @@ -0,0 +1,134 @@ +userSession = $this->createMock(IUserSession::class); + $this->packages = $this->createMock(ElpxPackageService::class); + $this->zipEntries = $this->createMock(ZipEntryService::class); + $this->controller = new AssetController( + 'exelearning', + $this->createMock(IRequest::class), + $this->userSession, + $this->packages, + $this->zipEntries, + ); + } + + public function testRejectsAnonymousRequests(): void { + $this->userSession->method('getUser')->willReturn(null); + + $response = $this->controller->fetch('42', 'index.html'); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + self::assertSame(['error' => 'Not authenticated'], $response->getData()); + } + + public function testRejectsInvalidSessionId(): void { + $this->authenticate(); + + $response = $this->controller->fetch('0', 'index.html'); + + self::assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + self::assertSame(['error' => 'Invalid session'], $response->getData()); + } + + public function testRejectsUnsafeEntryPath(): void { + $this->authenticate(); + $this->zipEntries->method('normalizeEntry')->with('../secret')->willReturn(null); + + $response = $this->controller->fetch('42', '../secret'); + + self::assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + self::assertSame(['error' => 'Unsafe path'], $response->getData()); + } + + public function testMapsMissingAndForbiddenPackagesToHttpErrors(): void { + $user = $this->authenticate(); + $this->zipEntries->method('normalizeEntry')->willReturnArgument(0); + $this->packages->expects(self::exactly(2)) + ->method('getForUserById') + ->with($user->getUID(), 42) + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('No read permission')), + ); + + $missing = $this->controller->fetch('42', 'index.html'); + $forbidden = $this->controller->fetch('42', 'index.html'); + + self::assertSame(Http::STATUS_NOT_FOUND, $missing->getStatus()); + self::assertSame(['error' => 'File not found'], $missing->getData()); + self::assertSame(Http::STATUS_FORBIDDEN, $forbidden->getStatus()); + self::assertSame(['error' => 'No read permission'], $forbidden->getData()); + } + + public function testReturnsNotFoundWhenArchiveEntryIsMissing(): void { + $this->authenticate(); + $file = $this->createMock(File::class); + $this->zipEntries->method('normalizeEntry')->willReturn('missing.css'); + $this->packages->method('getForUserById')->willReturn($file); + $this->zipEntries->method('readEntry')->with($file, 'missing.css')->willReturn(null); + + $response = $this->controller->fetch('42', 'missing.css'); + + self::assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + self::assertSame(['error' => 'Entry not found'], $response->getData()); + } + + public function testServesKnownMimeWithSecurityHeaders(): void { + $this->authenticate(); + $file = $this->createMock(File::class); + $this->zipEntries->method('normalizeEntry')->willReturn('index.html'); + $this->packages->method('getForUserById')->willReturn($file); + $this->zipEntries->method('readEntry')->willReturn(''); + + $response = $this->controller->fetch('42', 'index.html'); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('', $response->getData()); + self::assertSame('text/html; charset=utf-8', $response->getHeaders()['Content-Type']); + self::assertSame('nosniff', $response->getHeaders()['X-Content-Type-Options']); + self::assertStringContainsString("frame-ancestors 'self'", $response->getHeaders()['Content-Security-Policy']); + self::assertSame('private, max-age=300', $response->getHeaders()['Cache-Control']); + } + + public function testUsesOctetStreamForUnknownExtension(): void { + $this->authenticate(); + $file = $this->createMock(File::class); + $this->zipEntries->method('normalizeEntry')->willReturn('data/custom.bin'); + $this->packages->method('getForUserById')->willReturn($file); + $this->zipEntries->method('readEntry')->willReturn('bytes'); + + $response = $this->controller->fetch('42', 'data/custom.bin'); + + self::assertSame('application/octet-stream', $response->getHeaders()['Content-Type']); + } + + private function authenticate(): IUser { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + return $user; + } +} diff --git a/tests/Unit/Controller/PackageControllerTest.php b/tests/Unit/Controller/PackageControllerTest.php new file mode 100644 index 0000000..415a4ba --- /dev/null +++ b/tests/Unit/Controller/PackageControllerTest.php @@ -0,0 +1,129 @@ +userSession = $this->createMock(IUserSession::class); + $this->packages = $this->createMock(ElpxPackageService::class); + $this->controller = new PackageController( + 'exelearning', + $this->createMock(IRequest::class), + $this->userSession, + $this->packages, + ); + } + + public function testByFileIdRequiresAuthentication(): void { + $this->userSession->method('getUser')->willReturn(null); + + $response = $this->controller->byFileId(42); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $response->getStatus()); + self::assertSame(['error' => 'Not authenticated'], $response->getData()); + } + + public function testByFileIdMapsLookupErrors(): void { + $this->authenticate(); + $this->packages->expects(self::exactly(2)) + ->method('getForUserById') + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->byFileId(42)->getStatus()); + $forbidden = $this->controller->byFileId(42); + self::assertSame(Http::STATUS_FORBIDDEN, $forbidden->getStatus()); + self::assertSame(['error' => 'denied'], $forbidden->getData()); + } + + public function testByFileIdStreamsPackageWithHeaders(): void { + $this->authenticate(); + $file = $this->packageFile('Lesson ΓΌ.elpx', 'package-bytes'); + $this->packages->method('getForUserById')->with('alice', 42)->willReturn($file); + + $response = $this->controller->byFileId(42); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertIsResource($response->getStream()); + self::assertSame('application/vnd.exelearning.elpx', $response->getHeaders()['Content-Type']); + self::assertSame('13', $response->getHeaders()['Content-Length']); + self::assertSame('inline; filename="Lesson%20%C3%BC.elpx"', $response->getHeaders()['Content-Disposition']); + self::assertSame('private, no-cache, no-store, must-revalidate', $response->getHeaders()['Cache-Control']); + } + + public function testByPathRequiresAuthentication(): void { + $this->userSession->method('getUser')->willReturn(null); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $this->controller->byPath('lesson.elpx')->getStatus()); + } + + public function testByPathRejectsEmptyAndNulPaths(): void { + $this->authenticate(); + + self::assertSame(Http::STATUS_BAD_REQUEST, $this->controller->byPath('')->getStatus()); + self::assertSame(Http::STATUS_BAD_REQUEST, $this->controller->byPath("folder/\0file.elpx")->getStatus()); + } + + public function testByPathMapsLookupErrors(): void { + $this->authenticate(); + $this->packages->expects(self::exactly(2)) + ->method('getForUserByPath') + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->byPath('missing.elpx')->getStatus()); + self::assertSame(Http::STATUS_FORBIDDEN, $this->controller->byPath('denied.elpx')->getStatus()); + } + + public function testByPathStreamsResolvedPackage(): void { + $this->authenticate(); + $file = $this->packageFile('lesson.elpx', 'abc'); + $this->packages->method('getForUserByPath')->with('alice', 'folder/lesson.elpx')->willReturn($file); + + $response = $this->controller->byPath('folder/lesson.elpx'); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('3', $response->getHeaders()['Content-Length']); + self::assertSame('nosniff', $response->getHeaders()['X-Content-Type-Options']); + } + + private function authenticate(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + } + + private function packageFile(string $name, string $contents): File { + $stream = fopen('php://temp', 'w+b'); + self::assertIsResource($stream); + fwrite($stream, $contents); + rewind($stream); + + $file = $this->createMock(File::class); + $file->method('getName')->willReturn($name); + $file->method('getSize')->willReturn(strlen($contents)); + $file->method('fopen')->with('rb')->willReturn($stream); + return $file; + } +} diff --git a/tests/Unit/Controller/ThumbnailControllerTest.php b/tests/Unit/Controller/ThumbnailControllerTest.php new file mode 100644 index 0000000..207009f --- /dev/null +++ b/tests/Unit/Controller/ThumbnailControllerTest.php @@ -0,0 +1,91 @@ +userSession = $this->createMock(IUserSession::class); + $this->packages = $this->createMock(ElpxPackageService::class); + $this->zipEntries = $this->createMock(ZipEntryService::class); + $this->controller = new ThumbnailController( + 'exelearning', + $this->createMock(IRequest::class), + $this->userSession, + $this->packages, + $this->zipEntries, + ); + } + + public function testRequiresAuthentication(): void { + $this->userSession->method('getUser')->willReturn(null); + + self::assertSame(Http::STATUS_UNAUTHORIZED, $this->controller->byFileId(42)->getStatus()); + } + + public function testMapsPackageLookupErrors(): void { + $this->authenticate(); + $this->packages->expects(self::exactly(2)) + ->method('getForUserById') + ->willReturnOnConsecutiveCalls( + self::throwException(new NotFoundException('missing')), + self::throwException(new NotPermittedException('denied')), + ); + + self::assertSame(Http::STATUS_NOT_FOUND, $this->controller->byFileId(42)->getStatus()); + $forbidden = $this->controller->byFileId(42); + self::assertSame(Http::STATUS_FORBIDDEN, $forbidden->getStatus()); + self::assertSame(['error' => 'denied'], $forbidden->getData()); + } + + public function testReturnsNotFoundWithoutScreenshot(): void { + $this->authenticate(); + $file = $this->createMock(File::class); + $this->packages->method('getForUserById')->willReturn($file); + $this->zipEntries->method('readEntry')->with($file, 'screenshot.png')->willReturn(null); + + $response = $this->controller->byFileId(42); + + self::assertSame(Http::STATUS_NOT_FOUND, $response->getStatus()); + self::assertSame(['error' => 'No screenshot'], $response->getData()); + } + + public function testReturnsPngWithPrivateCacheHeaders(): void { + $this->authenticate(); + $file = $this->createMock(File::class); + $this->packages->method('getForUserById')->willReturn($file); + $this->zipEntries->method('readEntry')->willReturn('png-bytes'); + + $response = $this->controller->byFileId(42); + + self::assertSame(Http::STATUS_OK, $response->getStatus()); + self::assertSame('png-bytes', $response->getData()); + self::assertSame('image/png', $response->getHeaders()['Content-Type']); + self::assertSame('nosniff', $response->getHeaders()['X-Content-Type-Options']); + self::assertSame('private, max-age=3600', $response->getHeaders()['Cache-Control']); + } + + private function authenticate(): void { + $user = $this->createMock(IUser::class); + $user->method('getUID')->willReturn('alice'); + $this->userSession->method('getUser')->willReturn($user); + } +} diff --git a/tests/bootstrap-standalone.php b/tests/bootstrap-standalone.php index 66f50f5..c0f85f8 100644 --- a/tests/bootstrap-standalone.php +++ b/tests/bootstrap-standalone.php @@ -43,6 +43,62 @@ public function boot(IBootContext $context): void; } '); } +if (!interface_exists('OCP\\IRequest', false)) { + eval('namespace OCP; interface IRequest {}'); +} +if (!interface_exists('OCP\\IUser', false)) { + eval('namespace OCP; interface IUser { public function getUID(); }'); +} +if (!interface_exists('OCP\\IUserSession', false)) { + eval('namespace OCP; interface IUserSession { public function getUser(); }'); +} +if (!class_exists('OCP\\AppFramework\\Controller', false)) { + eval(' + namespace OCP\\AppFramework; + class Controller { + protected \\OCP\\IRequest $request; + public function __construct(string $appName, \\OCP\\IRequest $request) { + $this->request = $request; + } + } + '); +} +if (!class_exists('OCP\\AppFramework\\Http', false)) { + eval(' + namespace OCP\\AppFramework; + class Http { + public const STATUS_OK = 200; + public const STATUS_BAD_REQUEST = 400; + public const STATUS_UNAUTHORIZED = 401; + public const STATUS_FORBIDDEN = 403; + public const STATUS_NOT_FOUND = 404; + public const STATUS_PRECONDITION_FAILED = 412; + public const STATUS_INTERNAL_SERVER_ERROR = 500; + } + '); +} +if (!class_exists('OCP\\AppFramework\\Http\\DataResponse', false)) { + eval(' + namespace OCP\\AppFramework\\Http; + class DataResponse { + protected array $headers; + public function __construct( + protected mixed $data = null, + protected int $status = 200, + array $headers = [], + ) { $this->headers = $headers; } + public function getData(): mixed { return $this->data; } + public function getStatus(): int { return $this->status; } + public function addHeader(string $name, string $value): void { $this->headers[$name] = $value; } + public function getHeaders(): array { return $this->headers; } + } + class DataDisplayResponse extends DataResponse {} + class StreamResponse extends DataResponse { + public function __construct($stream) { parent::__construct($stream, 200); } + public function getStream() { return $this->data; } + } + '); +} if (!interface_exists('OCP\\IPreview', false)) { eval('namespace OCP; interface IPreview {}'); } diff --git a/tests/phpunit.xml b/tests/phpunit.xml index 70dcf75..7e18de9 100644 --- a/tests/phpunit.xml +++ b/tests/phpunit.xml @@ -15,6 +15,9 @@ ../lib/AppInfo + ../lib/Controller/AssetController.php + ../lib/Controller/PackageController.php + ../lib/Controller/ThumbnailController.php ../lib/Preview ../lib/Service