[full-ci] feat: implement OIDC authentication option (#1676)

* feat: implement Bearer Token authentication option

* fix
This commit is contained in:
Viktor Scharf
2025-10-27 11:17:44 +01:00
committed by GitHub
parent c887947a85
commit f04f6ad470
10 changed files with 518 additions and 182 deletions
+2
View File
@@ -338,6 +338,7 @@ config = {
"FRONTEND_READONLY_USER_ATTRIBUTES": "user.onPremisesSamAccountName,user.displayName,user.mail,user.passwordProfile,user.accountEnabled,user.appRoleAssignments",
"OC_LDAP_SERVER_WRITE_ENABLED": False,
"OC_EXCLUDE_RUN_SERVICES": "idm",
"OC_LDAP_USER_ENABLED_ATTRIBUTE": "",
},
},
},
@@ -1076,6 +1077,7 @@ def localApiTests(name, suites, storage = "decomposed", extra_environment = {},
"WITH_REMOTE_PHP": with_remote_php,
"COLLABORATION_SERVICE_URL": "http://wopi-fakeoffice:9300",
"OC_STORAGE_PATH": "$HOME/.opencloud/storage/users",
"USE_BEARER_TOKEN": True,
}
for item in extra_environment:
+2 -30
View File
@@ -197,34 +197,6 @@ class GraphHelper {
return $baseUrl . '/graph/v1beta1/' . $path;
}
/**
* @param string $baseUrl
* @param string $xRequestId
* @param string $method
* @param string $path
* @param string|null $body
* @param array|null $headers
*
* @return RequestInterface
*/
public static function createRequest(
string $baseUrl,
string $xRequestId,
string $method,
string $path,
?string $body = null,
?array $headers = []
): RequestInterface {
$fullUrl = self::getFullUrl($baseUrl, $path);
return HttpRequestHelper::createRequest(
$fullUrl,
$xRequestId,
$method,
$headers,
$body
);
}
/**
* @param string $baseUrl
* @param string $xRequestId
@@ -1908,7 +1880,7 @@ class GraphHelper {
string $permissionsId
): ResponseInterface {
$url = self::getBetaFullUrl($baseUrl, "drives/$spaceId/items/$itemId/permissions/$permissionsId");
return HttpRequestHelper::sendRequestOnce(
return HttpRequestHelper::sendRequest(
$url,
$xRequestId,
'PATCH',
@@ -2264,7 +2236,7 @@ class GraphHelper {
): ResponseInterface {
$url = self::getBetaFullUrl($baseUrl, "drives/$spaceId/root/permissions/$permissionsId");
return HttpRequestHelper::sendRequestOnce(
return HttpRequestHelper::sendRequest(
$url,
$xRequestId,
'PATCH',
@@ -74,6 +74,7 @@ class HttpRequestHelper {
* than download it all up-front.
* @param int|null $timeout
* @param Client|null $client
* @param string|null $bearerToken
*
* @return ResponseInterface
* @throws GuzzleException
@@ -90,7 +91,8 @@ class HttpRequestHelper {
?CookieJar $cookies = null,
bool $stream = false,
?int $timeout = 0,
?Client $client = null
?Client $client = null,
?string $bearerToken = null
): ResponseInterface {
if ($client === null) {
$client = self::createClient(
@@ -99,7 +101,8 @@ class HttpRequestHelper {
$config,
$cookies,
$stream,
$timeout
$timeout,
$bearerToken
);
}
@@ -200,6 +203,13 @@ class HttpRequestHelper {
} else {
$debugResponses = false;
}
// use basic auth for 'public' user or no user
if ($user === 'public' || $user === null || $user === '') {
$bearerToken = null;
} else {
$useBearerToken = TokenHelper::useBearerToken();
$bearerToken = $useBearerToken ? TokenHelper::getTokens($user, $password, $url)['access_token'] : null;
}
$sendRetryLimit = self::numRetriesOnHttpTooEarly();
$sendCount = 0;
@@ -217,7 +227,8 @@ class HttpRequestHelper {
$cookies,
$stream,
$timeout,
$client
$client,
$bearerToken,
);
if ($response->getStatusCode() >= 400
@@ -348,6 +359,7 @@ class HttpRequestHelper {
* @param bool $stream Set to true to stream a response rather
* than download it all up-front.
* @param int|null $timeout
* @param string|null $bearerToken
*
* @return Client
*/
@@ -357,10 +369,13 @@ class HttpRequestHelper {
?array $config = null,
?CookieJar $cookies = null,
?bool $stream = false,
?int $timeout = 0
?int $timeout = 0,
?string $bearerToken = null
): Client {
$options = [];
if ($user !== null) {
if ($bearerToken !== null) {
$options['headers']['Authorization'] = 'Bearer ' . $bearerToken;
} elseif ($user !== null) {
$options['auth'] = [$user, $password];
}
if ($config !== null) {
@@ -0,0 +1,403 @@
<?php
/**
* @author Viktor Scharf <v.scharf@opencloud.eu>
* @copyright Copyright (c) 2025 Viktor Scharf <v.scharf@opencloud.eu>
*
* This code 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 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/>
*
*/
namespace TestHelpers;
use GuzzleHttp\Client;
use GuzzleHttp\Cookie\CookieJar;
use GuzzleHttp\Exception\GuzzleException;
use Exception;
/**
* Helper for obtaining bearer tokens for users
*/
class TokenHelper {
private const LOGON_URL = '/signin/v1/identifier/_/logon';
private const REDIRECT_URL = '/oidc-callback.html';
private const TOKEN_URL = '/konnect/v1/token';
// Static cache [username => token_data]
private static array $tokenCache = [];
/**
* @return bool
*/
public static function useBearerToken(): bool {
return \getenv('USE_BEARER_TOKEN') === 'true';
}
/**
* Extracts base URL from a full URL
*
* @param string $url
*
* @return string the base URL
*/
private static function extractBaseUrl(string $url): string {
return preg_replace('#(https?://[^/]+).*#', '$1', $url);
}
/**
* Get access and refresh tokens for a user
* Uses cache to avoid unnecessary requests
*
* @param string $username
* @param string $password
* @param string $url
*
* @return array ['access_token' => string, 'refresh_token' => string, 'expires_at' => int]
* @throws GuzzleException
* @throws Exception
*/
public static function getTokens(string $username, string $password, string $url): array {
// Extract base URL. I need to send $url to get correct server in case of multiple servers (ocm suite)
$baseUrl = self::extractBaseUrl($url);
$cacheKey = $username . '|' . $baseUrl;
// Check cache
if (isset(self::$tokenCache[$cacheKey])) {
$cachedToken = self::$tokenCache[$cacheKey];
// Check if access token has expired
if (time() < $cachedToken['expires_at']) {
return $cachedToken;
}
$refreshedToken = self::refreshToken($cachedToken['refresh_token'], $baseUrl);
$tokenData = [
'access_token' => $refreshedToken['access_token'],
'refresh_token' => $refreshedToken['refresh_token'],
'expires_at' => time() + 300 // 5 minutes
];
self::$tokenCache[$cacheKey] = $tokenData;
return $tokenData;
}
// Get new tokens
$cookieJar = new CookieJar();
$continueUrl = self::getAuthorizedEndPoint($username, $password, $baseUrl, $cookieJar);
$code = self::getCode($continueUrl, $baseUrl, $cookieJar);
$tokens = self::getToken($code, $baseUrl, $cookieJar);
$tokenData = [
'access_token' => $tokens['access_token'],
'refresh_token' => $tokens['refresh_token'],
'expires_at' => time() + 290 // set expiry to 290 seconds to allow for some buffer
];
// Save to cache
self::$tokenCache[$cacheKey] = $tokenData;
return $tokenData;
}
/**
* Refresh token
*
* @param string $refreshToken
* @param string $baseUrl
*
* @return array
* @throws GuzzleException
* @throws Exception
*/
private static function refreshToken(string $refreshToken, string $baseUrl): array {
$client = new Client(
[
'verify' => false,
'http_errors' => false,
'allow_redirects' => false
]
);
$response = $client->post(
$baseUrl . self::TOKEN_URL,
[
'form_params' => [
'client_id' => 'web',
'refresh_token' => $refreshToken,
'grant_type' => 'refresh_token'
]
]
);
if ($response->getStatusCode() !== 200) {
throw new Exception(
\sprintf(
'Token refresh failed: Expected status code 200 but received %d. Message: %s',
$response->getStatusCode(),
$response->getReasonPhrase()
)
);
}
$data = json_decode($response->getBody()->getContents(), true);
if (!isset($data['access_token']) || !isset($data['refresh_token'])) {
throw new Exception('Missing tokens in refresh response');
}
return [
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token']
];
}
/**
* Clear cached tokens for a specific user
*
* @param string $username
* @param string $url
*
* @return void
*/
public static function clearUserTokens(string $username, string $url): void {
$baseUrl = self::extractBaseUrl($url);
$cacheKey = $username . '|' . $baseUrl;
unset(self::$tokenCache[$cacheKey]);
}
/**
* Clear all cached tokens
*
* @return void
*/
public static function clearAllTokens(): void {
self::$tokenCache = [];
}
/**
* @param string $username
* @param string $password
* @param string $baseUrl
* @param CookieJar $cookieJar
*
* @return \Psr\Http\Message\ResponseInterface
* @throws GuzzleException
*/
public static function makeLoginRequest(
string $username,
string $password,
string $baseUrl,
CookieJar $cookieJar
): \Psr\Http\Message\ResponseInterface {
$client = new Client(
[
'verify' => false,
'http_errors' => false,
'allow_redirects' => false,
'cookies' => $cookieJar
]
);
return $client->post(
$baseUrl . self::LOGON_URL,
[
'headers' => [
'Kopano-Konnect-XSRF' => '1',
'Referer' => $baseUrl,
'Content-Type' => 'application/json'
],
'json' => [
'params' => [$username, $password, '1'],
'hello' => [
'scope' => 'openid profile offline_access email',
'client_id' => 'web',
'redirect_uri' => $baseUrl . self::REDIRECT_URL,
'flow' => 'oidc'
]
]
]
);
}
/**
* Step 1: Login and get continue_uri
*
* @param string $username
* @param string $password
* @param string $baseUrl
* @param CookieJar $cookieJar
*
* @return string
* @throws GuzzleException
* @throws Exception
*/
private static function getAuthorizedEndPoint(
string $username,
string $password,
string $baseUrl,
CookieJar $cookieJar
): string {
$response = self::makeLoginRequest($username, $password, $baseUrl, $cookieJar);
if ($response->getStatusCode() !== 200) {
throw new Exception(
\sprintf(
'Logon failed: Expected status code 200 but received %d. Message: %s',
$response->getStatusCode(),
$response->getReasonPhrase()
)
);
}
$data = json_decode($response->getBody()->getContents(), true);
if (!isset($data['hello']['continue_uri'])) {
throw new Exception('Missing continue_uri in logon response');
}
return $data['hello']['continue_uri'];
}
/**
* Step 2: Authorization and get code
*
* @param string $continueUrl
* @param string $baseUrl
* @param CookieJar $cookieJar
*
* @return string
* @throws GuzzleException
* @throws Exception
*/
private static function getCode(string $continueUrl, string $baseUrl, CookieJar $cookieJar): string {
$client = new Client(
[
'verify' => false,
'http_errors' => false,
'allow_redirects' => false, // Disable automatic redirects
'cookies' => $cookieJar
]
);
$params = [
'client_id' => 'web',
'prompt' => 'none',
'redirect_uri' => $baseUrl . self::REDIRECT_URL,
'response_mode' => 'query',
'response_type' => 'code',
'scope' => 'openid profile offline_access email'
];
$response = $client->get(
$continueUrl,
[
'query' => $params
]
);
if ($response->getStatusCode() !== 302) {
// Add debugging to understand what is happening
$body = $response->getBody()->getContents();
throw new Exception(
\sprintf(
'Authorization failed: Expected status code 302 but received %d. Message: %s. Body: %s',
$response->getStatusCode(),
$response->getReasonPhrase(),
$body
)
);
}
$location = $response->getHeader('Location')[0] ?? '';
if (empty($location)) {
throw new Exception('Missing Location header in authorization response');
}
parse_str(parse_url($location, PHP_URL_QUERY), $queryParams);
// Check for errors
if (isset($queryParams['error'])) {
throw new Exception(
\sprintf(
'Authorization error: %s - %s',
$queryParams['error'],
urldecode($queryParams['error_description'] ?? 'No description')
)
);
}
if (!isset($queryParams['code'])) {
throw new Exception('Missing auth code in redirect URL. Location: ' . $location);
}
return $queryParams['code'];
}
/**
* Step 3: Get token
*
* @param string $code
* @param string $baseUrl
* @param CookieJar $cookieJar
*
* @return array
*
* @throws GuzzleException
* @throws Exception
*
*/
private static function getToken(string $code, string $baseUrl, CookieJar $cookieJar): array {
$client = new Client(
[
'verify' => false,
'http_errors' => false,
'allow_redirects' => false,
'cookies' => $cookieJar
]
);
$response = $client->post(
$baseUrl . self::TOKEN_URL,
[
'form_params' => [
'client_id' => 'web',
'code' => $code,
'redirect_uri' => $baseUrl . self::REDIRECT_URL,
'grant_type' => 'authorization_code'
]
]
);
if ($response->getStatusCode() !== 200) {
throw new Exception(
\sprintf(
'Token request failed: Expected status code 200 but received %d. Message: %s',
$response->getStatusCode(),
$response->getReasonPhrase()
)
);
}
$data = json_decode($response->getBody()->getContents(), true);
if (!isset($data['access_token']) || !isset($data['refresh_token'])) {
throw new Exception('Missing tokens in response');
}
return [
'access_token' => $data['access_token'],
'refresh_token' => $data['refresh_token']
];
}
}
@@ -25,6 +25,7 @@ use Behat\Behat\Context\Context;
use Psr\Http\Message\ResponseInterface;
use TestHelpers\HttpRequestHelper;
use TestHelpers\BehatHelper;
use TestHelpers\TokenHelper;
use TestHelpers\WebDavHelper;
/**
@@ -714,4 +715,27 @@ class AuthContext implements Context {
);
$this->featureContext->setResponse($response);
}
/**
* @When user :user should not be able to log in with wrong password :password
*
* @param string $user
* @param string $password
*
* @return void
*/
public function userShouldNotBeAbleToLogInWithWrongPassword(
string $user,
string $password
): void {
TokenHelper::clearUserTokens($user, $this->featureContext->getBaseUrl());
$response = TokenHelper::makeLoginRequest(
$user,
$password,
$this->featureContext->getBaseUrl(),
new \GuzzleHttp\Cookie\CookieJar()
);
// why is not 401 returned?
$this->featureContext->theHTTPStatusCodeShouldBe(204, 'should not be able to log in', $response);
}
}
@@ -17,6 +17,7 @@ use TestHelpers\GraphHelper;
use TestHelpers\WebDavHelper;
use TestHelpers\HttpRequestHelper;
use TestHelpers\BehatHelper;
use TestHelpers\TokenHelper;
require_once 'bootstrap.php';
@@ -2864,6 +2865,7 @@ class GraphContext implements Context {
);
$this->featureContext->theHTTPStatusCodeShouldBe(200, '', $response);
$this->featureContext->updateUsernameInCreatedUserList($byUser, $userName);
TokenHelper::clearUserTokens($byUser, $this->featureContext->getBaseUrl());
}
/**
+61 -145
View File
@@ -31,6 +31,7 @@ use TestHelpers\WebDavHelper;
use TestHelpers\GraphHelper;
use Laminas\Ldap\Exception\LdapException;
use Laminas\Ldap\Ldap;
use TestHelpers\TokenHelper;
/**
* Functions for provisioning of users and groups
@@ -558,110 +559,65 @@ trait Provisioning {
*/
public function usersHaveBeenCreated(
TableNode $table,
bool $useDefault=true,
bool $initialize=true
bool $useDefault = true,
bool $initialize = true
) {
$this->verifyTableNodeColumns($table, ['username'], ['displayname', 'email', 'password']);
$table = $table->getColumnsHash();
$users = $this->buildUsersAttributesArray($useDefault, $table);
$requests = [];
$client = HttpRequestHelper::createClient(
$this->getAdminUsername(),
$this->getAdminPassword()
);
foreach ($users as $userAttributes) {
$userName = $userAttributes['userid'];
$password = $userAttributes['password'];
$displayName = $userAttributes['displayName'];
$email = $userAttributes['email'];
if ($this->isTestingWithLdap()) {
$this->createLdapUser($userAttributes);
} else {
$attributesToCreateUser['userid'] = $userAttributes['userid'];
$attributesToCreateUser['password'] = $userAttributes['password'];
$attributesToCreateUser['displayname'] = $userAttributes['displayName'];
if ($userAttributes['email'] === null) {
Assert::assertArrayHasKey(
'userid',
$userAttributes,
__METHOD__ . " userAttributes array does not have key 'userid'"
try {
$this->createLdapUser($userAttributes);
} catch (LdapException $exception) {
throw new Exception(
__METHOD__ . " cannot create a LDAP user with provided data. Error: $exception"
);
$attributesToCreateUser['email'] = $userAttributes['userid'] . '@opencloud.eu';
} else {
$attributesToCreateUser['email'] = $userAttributes['email'];
}
$body = GraphHelper::prepareCreateUserPayload(
$attributesToCreateUser['userid'],
$attributesToCreateUser['password'],
$attributesToCreateUser['email'],
$attributesToCreateUser['displayname']
);
$request = GraphHelper::createRequest(
} else {
// Use the same logic as userHasBeenCreated for email generation
if ($email === null) {
$email = $this->getEmailAddressForUser($userName);
if ($email === null) {
// escape @ & space if present in userId
$email = \str_replace(["@", " "], "", $userName) . '@opencloud.eu';
}
}
$userName = $this->getActualUsername($userName);
$userName = \trim($userName);
$response = GraphHelper::createUser(
$this->getBaseUrl(),
$this->getStepLineRef(),
"POST",
'users',
$body,
$this->getAdminUsername(),
$this->getAdminPassword(),
$userName,
$password,
$email,
$displayName,
);
// Add the request to the $requests array so that they can be sent in parallel.
$requests[] = $request;
Assert::assertEquals(
201,
$response->getStatusCode(),
__METHOD__ . " cannot create user '$userName' using Graph API.\nResponse:" .
json_encode($this->getJsonDecodedResponse($response))
);
$userId = $this->getJsonDecodedResponse($response)['id'];
}
}
$exceptionToThrow = null;
if (!$this->isTestingWithLdap()) {
$results = HttpRequestHelper::sendBatchRequest($requests, $client);
// Check all requests to inspect failures.
foreach ($results as $key => $e) {
if ($e instanceof ClientException) {
$responseBody = $this->getJsonDecodedResponse($e->getResponse());
$httpStatusCode = $e->getResponse()->getStatusCode();
$graphStatusCode = $responseBody['error']['code'];
$messageText = $responseBody['error']['message'];
$exceptionToThrow = new Exception(
__METHOD__ .
" Unexpected failure when creating the user '" .
$users[$key]['userid'] . "'" .
"\nHTTP status $httpStatusCode " .
"\nGraph status $graphStatusCode " .
"\nError message $messageText"
);
}
}
}
$this->addUserToCreatedUsersList($userName, $password, $displayName, $email, $userId ?? null);
// Create requests for setting displayname and email for the newly created users.
// These values cannot be set while creating the user, so we have to edit the newly created user to set these values.
foreach ($users as $userAttributes) {
if (!$this->isTestingWithLdap()) {
// for graph api, we need to save the user id to be able to add it in some group
// can be fetched with the "onPremisesSamAccountName" i.e. userid
$response = $this->graphContext->adminHasRetrievedUserUsingTheGraphApi($userAttributes['userid']);
$userAttributes['id'] = $this->getJsonDecodedResponse($response)['id'];
} else {
$userAttributes['id'] = null;
}
$this->addUserToCreatedUsersList(
$userAttributes['userid'],
$userAttributes['password'],
$userAttributes['displayName'],
$userAttributes['email'],
$userAttributes['id']
);
}
if (isset($exceptionToThrow)) {
throw $exceptionToThrow;
}
foreach ($users as $user) {
Assert::assertTrue(
$this->userExists($user["userid"]),
"User '" . $user["userid"] . "' should exist but does not exist"
);
}
if ($initialize) {
foreach ($users as $user) {
$this->initializeUser($user['userid'], $user['password']);
if ($initialize) {
$this->initializeUser($userName, $password);
}
}
}
@@ -841,45 +797,16 @@ trait Provisioning {
*/
public function userHasBeenDeleted(string $user): void {
$user = $this->getActualUsername($user);
if ($this->userExists($user)) {
if ($this->isTestingWithLdap() && \in_array($user, $this->ldapCreatedUsers)) {
$this->deleteLdapUser($user);
} else {
$response = $this->deleteUser($user);
$this->theHTTPStatusCodeShouldBe(204, "", $response);
WebDavHelper::removeSpaceIdReferenceForUser($user);
}
if ($this->isTestingWithLdap() && \in_array($user, $this->ldapCreatedUsers)) {
$this->deleteLdapUser($user);
} else {
$response = $this->deleteUser($user);
$this->theHTTPStatusCodeShouldBe(204, "", $response);
WebDavHelper::removeSpaceIdReferenceForUser($user);
}
Assert::assertFalse(
$this->userExists($user),
"User '$user' should not exist but does exist"
);
$this->rememberThatUserIsNotExpectedToExist($user);
}
/**
* @Given these users have been initialized:
* expects a table of users with the heading
* "|username|password|"
*
* @param TableNode $table
*
* @return void
*/
public function theseUsersHaveBeenInitialized(TableNode $table): void {
foreach ($table as $row) {
if (!isset($row ['password'])) {
$password = $this->getPasswordForUser($row ['username']);
} else {
$password = $row ['password'];
}
$this->initializeUser(
$row ['username'],
$password
);
}
}
/**
* get all the existing groups
*
@@ -961,13 +888,14 @@ trait Provisioning {
$url = $this->getBaseUrl()
. "/ocs/v$this->ocsApiVersion.php/cloud/users/$user";
}
HttpRequestHelper::get(
$url,
$this->getStepLineRef(),
$user,
$password
);
if ($password !== '') {
HttpRequestHelper::get(
$url,
$this->getStepLineRef(),
$user,
$password
);
}
}
/**
@@ -1162,12 +1090,6 @@ trait Provisioning {
}
$this->addUserToCreatedUsersList($user, $password, $displayName, $email, $userId);
Assert::assertTrue(
$this->userExists($user),
"User '$user' should exist but does not exist"
);
$this->initializeUser($user, $password);
}
@@ -1999,21 +1921,15 @@ trait Provisioning {
$this->usingServer('LOCAL');
foreach ($this->createdUsers as $userData) {
$user = $userData['actualUsername'];
TokenHelper::clearUserTokens($user, $this->getBaseUrl());
$this->deleteUser($user);
Assert::assertFalse(
$this->userExists($user),
"User '$user' should not exist but does exist"
);
$this->rememberThatUserIsNotExpectedToExist($user);
}
$this->usingServer('REMOTE');
foreach ($this->createdRemoteUsers as $userData) {
$user = $userData['actualUsername'];
TokenHelper::clearUserTokens($user, $this->getBaseUrl());
$this->deleteUser($user);
Assert::assertFalse(
$this->userExists($user),
"User '$user' should not exist but does exist"
);
$this->rememberThatUserIsNotExpectedToExist($user);
}
$this->usingServer($previousServer);
@@ -219,7 +219,7 @@ Feature: edit user
When the user "Brian" resets the password of user "Carol" to "newpassword" using the Graph API
Then the HTTP status code should be "403"
And the content of file "resetpassword.txt" for user "Carol" using password "1234" should be "test file for reset password"
But user "Carol" using password "newpassword" should not be able to download file "resetpassword.txt"
And user "Carol" should not be able to log in with wrong password "newpassword"
Examples:
| user-role | user-role-2 |
| Space Admin | Space Admin |
@@ -542,6 +542,7 @@ Feature: enable or disable sync of incoming shares
| sharee | Brian |
| shareType | user |
| permissionsRole | Viewer |
And user "Brian" has a share "textfile0.txt" synced
And the user "Admin" has deleted a user "Alice"
When user "Brian" disables sync of share "textfile0.txt" using the Graph API
Then the HTTP status code should be "204"
@@ -820,6 +821,7 @@ Feature: enable or disable sync of incoming shares
| sharee | Brian |
| shareType | user |
| permissionsRole | Viewer |
And user "Brian" has a share "<resource>" synced
And user "Brian" has disabled sync of last shared resource
When user "Brian" disables sync of share "<resource>" using the Graph API
Then the HTTP status code should be "409"
@@ -14,7 +14,7 @@ Feature: reset user password via CLI command
But the command output should not contain "Failed to update user password: entry does not exist"
And the administrator has started the server
And user "Alice" should be able to create folder "newFolder" using password "newpass"
But user "Alice" should not be able to create folder "anotherFolder" using password "%alt1%"
But user "Alice" should not be able to log in with wrong password "%alt1%"
Scenario: try to reset password of non-existing user