Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 0 additions & 1 deletion codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ ignore:
- "js/**/*"
- "tests/**/*"
- "vendor/**/*"
- "lib/Controller/**/*"
- "src/main.ts"
- "src/editor/editor-frame.ts"
- "src/editor/editor-page.ts"
Expand Down
134 changes: 134 additions & 0 deletions tests/Unit/Controller/AssetControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

declare(strict_types=1);

namespace OCA\ExeLearning\Tests\Unit\Controller;

use OCA\ExeLearning\Controller\AssetController;
use OCA\ExeLearning\Service\ElpxPackageService;
use OCA\ExeLearning\Service\ZipEntryService;
use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;

final class AssetControllerTest extends TestCase {
private IUserSession $userSession;
private ElpxPackageService $packages;
private ZipEntryService $zipEntries;
private AssetController $controller;

protected function setUp(): void {
$this->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('<html></html>');

$response = $this->controller->fetch('42', 'index.html');

self::assertSame(Http::STATUS_OK, $response->getStatus());
self::assertSame('<html></html>', $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;
}
}
129 changes: 129 additions & 0 deletions tests/Unit/Controller/PackageControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
<?php

declare(strict_types=1);

namespace OCA\ExeLearning\Tests\Unit\Controller;

use OCA\ExeLearning\Controller\PackageController;
use OCA\ExeLearning\Service\ElpxPackageService;
use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;

final class PackageControllerTest extends TestCase {
private IUserSession $userSession;
private ElpxPackageService $packages;
private PackageController $controller;

protected function setUp(): void {
$this->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;
}
}
91 changes: 91 additions & 0 deletions tests/Unit/Controller/ThumbnailControllerTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
<?php

declare(strict_types=1);

namespace OCA\ExeLearning\Tests\Unit\Controller;

use OCA\ExeLearning\Controller\ThumbnailController;
use OCA\ExeLearning\Service\ElpxPackageService;
use OCA\ExeLearning\Service\ZipEntryService;
use OCP\AppFramework\Http;
use OCP\Files\File;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\IRequest;
use OCP\IUser;
use OCP\IUserSession;
use PHPUnit\Framework\TestCase;

final class ThumbnailControllerTest extends TestCase {
private IUserSession $userSession;
private ElpxPackageService $packages;
private ZipEntryService $zipEntries;
private ThumbnailController $controller;

protected function setUp(): void {
$this->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);
}
}
Loading
Loading