Merge pull request #65 from nextcloud/test-behat

Add integration tests with behat
This commit is contained in:
Julius Härtl
2017-04-29 13:08:18 +02:00
committed by GitHub
13 changed files with 833 additions and 7 deletions

View File

@@ -125,10 +125,29 @@ pipeline:
- cd ../server/
- php occ app:enable deck
- cd apps/$APP_NAME
- make test
- phpunit -c tests/phpunit.xml --coverage-clover build/php-unit.coverage.xml
- phpunit -c tests/phpunit.integration.xml --coverage-clover build/php-integration.coverage.xml
when:
matrix:
TESTS: php7.1
integration:
image: nextcloudci/integration-php7.0:integration-php7.0-3
environment:
- APP_NAME=deck
- CORE_BRANCH=master
- DB=sqlite
commands:
# Pre-setup steps
- wget https://raw.githubusercontent.com/nextcloud/travis_ci/master/before_install.sh
- bash ./before_install.sh $APP_NAME $CORE_BRANCH $DB
- cd ../server/
- php occ app:enable deck
- cd apps/$APP_NAME
- cd tests/integration
- ./run.sh
when:
matrix:
TESTS: integration
jsbuild:
image: mhart/alpine-node:6.8.0
commands:
@@ -149,5 +168,6 @@ matrix:
- TESTS: php7.0
- TESTS: php7.1
- TESTS: jsbuild
- TESTS: integration
branches: [ master, stable* ]

2
.gitignore vendored
View File

@@ -2,3 +2,5 @@ js/node_modules/*
js/vendor/
build/
js/public/
tests/integration/vendor/
tests/integration/composer.lock

View File

@@ -26,7 +26,7 @@ before_script:
- cd apps/deck
script:
- make test
- make test-unit
after_failure:
- cat ../../data/nextcloud.log

View File

@@ -69,8 +69,9 @@ appstore: clean-build build
echo $(appstore_package_name).tar.gz
test: test-unit test-integration
test:
test-unit:
mkdir -p build/
ifeq (, $(shell which phpunit 2> /dev/null))
@echo "No phpunit command available, downloading a copy from the web"
@@ -83,6 +84,9 @@ else
phpunit -c tests/phpunit.integration.xml --coverage-clover build/php-integration.coverage.xml
endif
test-js:
cd js && run test
test-integration:
cd tests/integration && ./run.sh
test-js: install-deps
cd js && run test

View File

@@ -0,0 +1,23 @@
{
"require-dev": {
"phpunit/phpunit": "~4.6",
"behat/behat": "^3.0",
"guzzlehttp/guzzle": "~5.0",
"jarnaiz/behat-junit-formatter": "^1.3",
"sabre/dav": "3.2"
},
"autoload": {
"files": [
"../../../../build/integration/features/bootstrap/Auth.php",
"../../../../build/integration/features/bootstrap/Provisioning.php",
"../../../../build/integration/features/bootstrap/Sharing.php",
"../../../../build/integration/features/bootstrap/Trashbin.php",
"../../../../build/integration/features/bootstrap/WebDav.php"
],
"psr-0": {
"": [
"features/bootstrap/"
]
}
}
}

View File

@@ -0,0 +1,12 @@
default:
suites:
test:
paths:
- %paths.base%/../features/
contexts:
- FeatureContext:
baseUrl: http://localhost:8080/index.php/ocs/
admin:
- admin
- admin
regular_user_password: 123456

View File

@@ -0,0 +1,36 @@
Feature: acl
Routes should check for permissions when a user sends a requests
Background:
Given user "admin" exists
And user "user0" exists
And user "user1" exists
And user "user2" exists
Given group "group0" exists
And group "group1" exists
Given user "user1" belongs to group "group1"
Scenario: Request the main frontend page
Given Logging in using web as "user0"
When Sending a "GET" to "/index.php/apps/deck" without requesttoken
Then the HTTP status code should be "200"
Scenario: Fetch the board list
Given Logging in using web as "user0"
When Sending a "GET" to "/index.php/apps/deck/boards" with requesttoken
Then the HTTP status code should be "200"
And the Content-Type should be "application/json; charset=utf-8"
Scenario: Fetch board details of owned board
Given Logging in using web as "admin"
And creates a board named "MyPrivateAdminBoard" with color "fafafa"
When "admin" fetches the board named "MyPrivateAdminBoard"
Then the HTTP status code should be "200"
And the Content-Type should be "application/json; charset=utf-8"
Scenario: Fetch board details of an other users board
Given Logging in using web as "admin"
And creates a board named "MyPrivateAdminBoard" with color "fafafa"
When "user0" fetches the board named "MyPrivateAdminBoard"
Then the HTTP status code should be "403"
And the Content-Type should be "application/json; charset=utf-8"

View File

@@ -0,0 +1,450 @@
<?php
/**
*
* @author Christoph Wurst <christoph@owncloud.com>
* @author Joas Schilling <coding@schilljs.com>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Sergio Bertolin <sbertolin@solidgear.es>
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\ClientException;
use GuzzleHttp\Message\ResponseInterface;
require __DIR__ . '/../../vendor/autoload.php';
trait BasicStructure {
use Auth;
/** @var string */
private $currentUser = '';
/** @var string */
private $currentServer = '';
/** @var string */
private $baseUrl = '';
/** @var int */
private $apiVersion = 1;
/** @var ResponseInterface */
private $response = null;
/** @var CookieJar */
private $cookieJar;
/** @var string */
private $requestToken;
public function __construct($baseUrl, $admin, $regular_user_password) {
// Initialize your context here
$this->baseUrl = $baseUrl;
$this->adminUser = $admin;
$this->regularUser = $regular_user_password;
$this->localBaseUrl = $this->baseUrl;
$this->remoteBaseUrl = $this->baseUrl;
$this->currentServer = 'LOCAL';
$this->cookieJar = new CookieJar();
// in case of ci deployment we take the server url from the environment
$testServerUrl = getenv('TEST_SERVER_URL');
if ($testServerUrl !== false) {
$this->baseUrl = $testServerUrl;
$this->localBaseUrl = $testServerUrl;
}
// federated server url from the environment
$testRemoteServerUrl = getenv('TEST_SERVER_FED_URL');
if ($testRemoteServerUrl !== false) {
$this->remoteBaseUrl = $testRemoteServerUrl;
}
}
/**
* @Given /^using api version "(\d+)"$/
* @param string $version
*/
public function usingApiVersion($version) {
$this->apiVersion = (int) $version;
}
/**
* @Given /^As an "([^"]*)"$/
* @param string $user
*/
public function asAn($user) {
$this->currentUser = $user;
}
/**
* @Given /^Using server "(LOCAL|REMOTE)"$/
* @param string $server
* @return string Previous used server
*/
public function usingServer($server) {
$previousServer = $this->currentServer;
if ($server === 'LOCAL'){
$this->baseUrl = $this->localBaseUrl;
$this->currentServer = 'LOCAL';
return $previousServer;
} else {
$this->baseUrl = $this->remoteBaseUrl;
$this->currentServer = 'REMOTE';
return $previousServer;
}
}
/**
* @When /^sending "([^"]*)" to "([^"]*)"$/
* @param string $verb
* @param string $url
*/
public function sendingTo($verb, $url) {
$this->sendingToWith($verb, $url, null);
}
/**
* Parses the xml answer to get ocs response which doesn't match with
* http one in v1 of the api.
* @param ResponseInterface $response
* @return string
*/
public function getOCSResponse($response) {
return $response->xml()->meta[0]->statuscode;
}
/**
* This function is needed to use a vertical fashion in the gherkin tables.
* @param array $arrayOfArrays
* @return array
*/
public function simplifyArray($arrayOfArrays){
$a = array_map(function($subArray) { return $subArray[0]; }, $arrayOfArrays);
return $a;
}
/**
* @When /^sending "([^"]*)" to "([^"]*)" with$/
* @param string $verb
* @param string $url
* @param \Behat\Gherkin\Node\TableNode $body
*/
public function sendingToWith($verb, $url, $body) {
$fullUrl = $this->baseUrl . "v{$this->apiVersion}.php" . $url;
$client = new Client();
$options = [];
if ($this->currentUser === 'admin') {
$options['auth'] = $this->adminUser;
} else {
$options['auth'] = [$this->currentUser, $this->regularUser];
}
$options['headers'] = [
'OCS_APIREQUEST' => 'true'
];
if ($body instanceof \Behat\Gherkin\Node\TableNode) {
$fd = $body->getRowsHash();
$options['body'] = $fd;
}
// TODO: Fix this hack!
if ($verb === 'PUT' && $body === null) {
$options['body'] = [
'foo' => 'bar',
];
}
try {
$this->response = $client->send($client->createRequest($verb, $fullUrl, $options));
} catch (ClientException $ex) {
$this->response = $ex->getResponse();
}
}
/**
* @When /^sending "([^"]*)" with exact url to "([^"]*)"$/
* @param string $verb
* @param string $url
*/
public function sendingToDirectUrl($verb, $url) {
$this->sendingToWithDirectUrl($verb, $url, null);
}
public function sendingToWithDirectUrl($verb, $url, $body) {
$fullUrl = substr($this->baseUrl, 0, -5) . $url;
$client = new Client();
$options = [];
if ($this->currentUser === 'admin') {
$options['auth'] = $this->adminUser;
} else {
$options['auth'] = [$this->currentUser, $this->regularUser];
}
if ($body instanceof \Behat\Gherkin\Node\TableNode) {
$fd = $body->getRowsHash();
$options['body'] = $fd;
}
try {
$this->response = $client->send($client->createRequest($verb, $fullUrl, $options));
} catch (ClientException $ex) {
$this->response = $ex->getResponse();
}
}
public function isExpectedUrl($possibleUrl, $finalPart){
$baseUrlChopped = substr($this->baseUrl, 0, -4);
$endCharacter = strlen($baseUrlChopped) + strlen($finalPart);
return (substr($possibleUrl,0,$endCharacter) == "$baseUrlChopped" . "$finalPart");
}
/**
* @Then /^the OCS status code should be "([^"]*)"$/
* @param int $statusCode
*/
public function theOCSStatusCodeShouldBe($statusCode) {
PHPUnit_Framework_Assert::assertEquals($statusCode, $this->getOCSResponse($this->response));
}
/**
* @Then /^the HTTP status code should be "([^"]*)"$/
* @param int $statusCode
*/
public function theHTTPStatusCodeShouldBe($statusCode) {
PHPUnit_Framework_Assert::assertEquals($statusCode, $this->response->getStatusCode());
}
/**
* @Then /^the Content-Type should be "([^"]*)"$/
* @param string $contentType
*/
public function theContentTypeShouldbe($contentType) {
PHPUnit_Framework_Assert::assertEquals($contentType, $this->response->getHeader('Content-Type'));
}
/**
* @param ResponseInterface $response
*/
private function extracRequestTokenFromResponse(ResponseInterface $response) {
$this->requestToken = substr(preg_replace('/(.*)data-requesttoken="(.*)">(.*)/sm', '\2', $response->getBody()->getContents()), 0, 89);
}
/**
* @Given Logging in using web as :user
* @param string $user
*/
public function loggingInUsingWebAs($user) {
$loginUrl = substr($this->baseUrl, 0, -5) . '/login';
// Request a new session and extract CSRF token
$client = new Client();
$response = $client->get(
$loginUrl,
[
'cookies' => $this->cookieJar,
]
);
$this->extracRequestTokenFromResponse($response);
// Login and extract new token
$password = ($user === 'admin') ? 'admin' : '123456';
$client = new Client();
$response = $client->post(
$loginUrl,
[
'body' => [
'user' => $user,
'password' => $password,
'requesttoken' => $this->requestToken,
],
'cookies' => $this->cookieJar,
]
);
$this->extracRequestTokenFromResponse($response);
}
/**
* @When Sending a :method to :url with requesttoken
* @param string $method
* @param string $url
*/
public function sendingAToWithRequesttoken($method, $url) {
$baseUrl = substr($this->baseUrl, 0, -5);
$client = new Client();
$request = $client->createRequest(
$method,
$baseUrl . $url,
[
'cookies' => $this->cookieJar,
]
);
$request->addHeader('requesttoken', $this->requestToken);
try {
$this->response = $client->send($request);
} catch (ClientException $e) {
$this->response = $e->getResponse();
}
}
/**
* @When Sending a :method to :url without requesttoken
* @param string $method
* @param string $url
*/
public function sendingAToWithoutRequesttoken($method, $url) {
$baseUrl = substr($this->baseUrl, 0, -5);
$client = new Client();
$request = $client->createRequest(
$method,
$baseUrl . $url,
[
'cookies' => $this->cookieJar,
]
);
try {
$this->response = $client->send($request);
} catch (ClientException $e) {
$this->response = $e->getResponse();
}
}
public static function removeFile($path, $filename){
if (file_exists("$path" . "$filename")) {
unlink("$path" . "$filename");
}
}
/**
* @Given User :user modifies text of :filename with text :text
* @param string $user
* @param string $filename
* @param string $text
*/
public function modifyTextOfFile($user, $filename, $text) {
self::removeFile("../../data/$user/files", "$filename");
file_put_contents("../../data/$user/files" . "$filename", "$text");
}
public function createFileSpecificSize($name, $size) {
$file = fopen("work/" . "$name", 'w');
fseek($file, $size - 1 ,SEEK_CUR);
fwrite($file,'a'); // write a dummy char at SIZE position
fclose($file);
}
public function createFileWithText($name, $text){
$file = fopen("work/" . "$name", 'w');
fwrite($file, $text);
fclose($file);
}
/**
* @Given file :filename of size :size is created in local storage
* @param string $filename
* @param string $size
*/
public function fileIsCreatedInLocalStorageWithSize($filename, $size) {
$this->createFileSpecificSize("local_storage/$filename", $size);
}
/**
* @Given file :filename with text :text is created in local storage
* @param string $filename
* @param string $text
*/
public function fileIsCreatedInLocalStorageWithText($filename, $text) {
$this->createFileWithText("local_storage/$filename", $text);
}
/**
* @When Sleep for :seconds seconds
* @param int $seconds
*/
public function sleepForSeconds($seconds) {
sleep((int)$seconds);
}
/**
* @BeforeSuite
*/
public static function addFilesToSkeleton(){
for ($i=0; $i<5; $i++){
file_put_contents("../../core/skeleton/" . "textfile" . "$i" . ".txt", "Nextcloud test text file\n");
}
if (!file_exists("../../core/skeleton/FOLDER")) {
mkdir("../../core/skeleton/FOLDER", 0777, true);
}
if (!file_exists("../../core/skeleton/PARENT")) {
mkdir("../../core/skeleton/PARENT", 0777, true);
}
file_put_contents("../../core/skeleton/PARENT/" . "parent.txt", "Nextcloud test text file\n");
if (!file_exists("../../core/skeleton/PARENT/CHILD")) {
mkdir("../../core/skeleton/PARENT/CHILD", 0777, true);
}
file_put_contents("../../core/skeleton/PARENT/CHILD/" . "child.txt", "Nextcloud test text file\n");
}
/**
* @AfterSuite
*/
public static function removeFilesFromSkeleton(){
for ($i=0; $i<5; $i++){
self::removeFile("../../core/skeleton/", "textfile" . "$i" . ".txt");
}
if (is_dir("../../core/skeleton/FOLDER")) {
rmdir("../../core/skeleton/FOLDER");
}
self::removeFile("../../core/skeleton/PARENT/CHILD/", "child.txt");
if (is_dir("../../core/skeleton/PARENT/CHILD")) {
rmdir("../../core/skeleton/PARENT/CHILD");
}
self::removeFile("../../core/skeleton/PARENT/", "parent.txt");
if (is_dir("../../core/skeleton/PARENT")) {
rmdir("../../core/skeleton/PARENT");
}
}
/**
* @BeforeScenario @local_storage
*/
public static function removeFilesFromLocalStorageBefore(){
$dir = "./work/local_storage/";
$di = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$ri = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST);
foreach ( $ri as $file ) {
$file->isDir() ? rmdir($file) : unlink($file);
}
}
/**
* @AfterScenario @local_storage
*/
public static function removeFilesFromLocalStorageAfter(){
$dir = "./work/local_storage/";
$di = new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS);
$ri = new RecursiveIteratorIterator($di, RecursiveIteratorIterator::CHILD_FIRST);
foreach ( $ri as $file ) {
$file->isDir() ? rmdir($file) : unlink($file);
}
}
}

View File

@@ -0,0 +1,194 @@
<?php
use Behat\Behat\Context\Context;
use Behat\Gherkin\Node\TableNode;
use GuzzleHttp\Client;
use Behat\Behat\Tester\Exception\PendingException;
use Behat\Gherkin\Node\PyStringNode;
use GuzzleHttp\Exception\ClientException;
require_once __DIR__ . '/../../vendor/autoload.php';
class FeatureContext implements Context {
use WebDav;
/** @var string */
private $mappedUserId;
private $lastInsertIds = array();
/**
* @BeforeSuite
*/
public static function addFilesToSkeleton() {
}
/**
* @When :user requests the deck list
*/
/**
* @When Sending a :method to :url with JSON
*/
public function sendingAToWithJSON($method, $url, \Behat\Gherkin\Node\PyStringNode $data) {
$baseUrl = substr($this->baseUrl, 0, -5);
$client = new Client;
$request = $client->createRequest(
$method,
$baseUrl . $url,
[
'cookies' => $this->cookieJar,
'json' => json_decode($data)
]
);
$request->addHeader('requesttoken', $this->requestToken);
try {
$this->response = $client->send($request);
} catch (ClientException $e) {
$this->response = $e->getResponse();
}
}
/**
* @When :user creates a new deck with name :name
*/
public function createsANewDeckWithName($user, $content) {
$client = new GuzzleHttp\Client();
$this->response = $client->post(
'http://localhost:8080/index.php/apps/deck/boards',
[
'form_params' => [
'name' => $name,
],
'auth' => [
$this->mappedUserId,
'test',
],
]
);
}
/**
* @Then the response should have a status code :code
* @param string $code
* @throws InvalidArgumentException
*/
public function theResponseShouldHaveAStatusCode($code) {
$currentCode = $this->response->getStatusCode();
if ($currentCode !== (int)$code) {
throw new InvalidArgumentException(
sprintf(
'Expected %s as code got %s',
$code,
$currentCode
)
);
}
}
/**
* @Then the response should be a JSON array with the following mandatory values
* @param TableNode $table
* @throws InvalidArgumentException
*/
public function theResponseShouldBeAJsonArrayWithTheFollowingMandatoryValues(TableNode $table) {
$expectedValues = $table->getColumnsHash();
$realResponseArray = json_decode($this->response->getBody()->getContents(), true);
foreach ($expectedValues as $value) {
if ((string)$realResponseArray[$value['key']] !== (string)$value['value']) {
throw new InvalidArgumentException(
sprintf(
'Expected %s for key %s got %s',
(string)$value['value'],
$value['key'],
(string)$realResponseArray[$value['key']]
)
);
}
}
}
/**
* @Then the response should be a JSON array with a length of :length
* @param int $length
* @throws InvalidArgumentException
*/
public function theResponseShouldBeAJsonArrayWithALengthOf($length) {
$realResponseArray = json_decode($this->response->getBody()->getContents(), true);
PHPUnit_Framework_Assert::assertEquals($realResponseArray, "foo");
if((int)count($realResponseArray) !== (int)$length) {
throw new InvalidArgumentException(
sprintf(
'Expected %d as length got %d',
$length,
count($realResponseArray)
)
);
}
}
/**
* @Then /^I should get:$/
*
* @param PyStringNode $string
* @throws \Exception
*/
public function iShouldGet(PyStringNode $string)
{
if ((string) $string !== trim($this->cliOutput)) {
throw new Exception(sprintf(
'Expected "%s" but received "%s".',
$string,
$this->cliOutput
));
}
return;
}
private function sendJSONrequest($method, $url, $data) {
$baseUrl = substr($this->baseUrl, 0, -5);
$client = new Client;
$request = $client->createRequest(
$method,
$baseUrl . $url,
[
'cookies' => $this->cookieJar,
'json' => $data
]
);
$request->addHeader('requesttoken', $this->requestToken);
try {
$this->response = $client->send($request);
} catch (ClientException $e) {
$this->response = $e->getResponse();
}
}
/**
* @Given /^creates a board named "([^"]*)" with color "([^"]*)"$/
*/
public function createsABoardNamedWithColor($title, $color) {
$this->sendJSONrequest('POST', '/index.php/apps/deck/boards', [
'title' => $title,
'color' => $color
]
);
$response = json_decode($this->response->getBody()->getContents(), true);
$this->lastInsertIds[$title] = $response['id'];
}
/**
* @When /^"([^"]*)" fetches the board named "([^"]*)"$/
*/
public function fetchesTheBoardNamed($user, $boardName) {
$this->loggingInUsingWebAs($user);
$id = $this->lastInsertIds[$boardName];
$this->sendJSONrequest('GET', '/index.php/apps/deck/boards/'.$id, []);
}
}

View File

@@ -0,0 +1,37 @@
Feature: decks
Background:
Given user "user0" exists
Scenario: Request the main frontend page
Given Logging in using web as "admin"
When Sending a "GET" to "/index.php/apps/deck" without requesttoken
Then the HTTP status code should be "200"
Scenario: Fetch the board list
Given Logging in using web as "admin"
When Sending a "GET" to "/index.php/apps/deck/boards" with requesttoken
Then the HTTP status code should be "200"
And the Content-Type should be "application/json; charset=utf-8"
Scenario: Fetch board details of a nonexisting board
Given Logging in using web as "admin"
When Sending a "GET" to "/index.php/apps/deck/boards/13379" with requesttoken
Then the HTTP status code should be "403"
And the Content-Type should be "application/json; charset=utf-8"
Scenario: Create a new board
Given Logging in using web as "admin"
When Sending a "POST" to "/index.php/apps/deck/boards" with JSON
"""
{
"title": "MyBoard",
"color": "000000"
}
"""
Then the HTTP status code should be "200"
And the Content-Type should be "application/json; charset=utf-8"
And the response should be a JSON array with the following mandatory values
|key|value|
|title|MyBoard|
|color|000000|

45
tests/integration/run.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/usr/bin/env bash
OC_PATH=../../../../
OCC=${OC_PATH}occ
SCENARIO_TO_RUN=$1
HIDE_OC_LOGS=$2
# Nextcloud integration tests composer
(
cd ${OC_PATH}build/integration
composer install
)
INSTALLED=$($OCC status | grep installed: | cut -d " " -f 5)
if [ "$INSTALLED" == "true" ]; then
$OCC app:enable deck
else
if [ "$SCENARIO_TO_RUN" != "setup_features/setup.feature" ]; then
echo "Nextcloud instance needs to be installed" >&2
exit 1
fi
fi
composer install
composer dump-autoload
# avoid port collision on jenkins - use $EXECUTOR_NUMBER
if [ -z "$EXECUTOR_NUMBER" ]; then
EXECUTOR_NUMBER=0
fi
PORT=$((8080 + $EXECUTOR_NUMBER))
echo $PORT
php -S localhost:$PORT -t $OC_PATH &
PHPPID=$!
echo $PHPPID
export TEST_SERVER_URL="http://localhost:$PORT/ocs/"
vendor/bin/behat
RESULT=$?
kill $PHPPID
echo "runsh: Exit code: $RESULT"
exit $RESULT

View File

@@ -1,7 +1,10 @@
<phpunit bootstrap="bootstrap.php" colors="true">
<testsuites>
<testsuite name="integration">
<directory>./integration</directory>
<testsuite name="integration-database">
<directory>./integration/database</directory>
</testsuite>
<testsuite name="integration-app">
<directory>./integration/app</directory>
</testsuite>
</testsuites>
<filter>