diff --git a/apps/settings/lib/Controller/AuthSettingsController.php b/apps/settings/lib/Controller/AuthSettingsController.php index ae2f2e3e2056f..6224fcccb5034 100644 --- a/apps/settings/lib/Controller/AuthSettingsController.php +++ b/apps/settings/lib/Controller/AuthSettingsController.php @@ -60,11 +60,12 @@ public function __construct( /** * @param bool $qrcodeLogin If set to true, the returned token could be (depending on server settings) a onetime password, that can only be used to get the actual app password a single time + * @param list|null $scopes Scopes the app password is limited to, null for all access. Ignored for QR code logins */ #[NoSubAdminRequired] #[NoAdminRequired] #[PasswordConfirmationRequired(strict: true)] - public function create(string $name = '', bool $qrcodeLogin = false): JSONResponse { + public function create(string $name = '', bool $qrcodeLogin = false, ?array $scopes = null): JSONResponse { if ($this->checkAppToken()) { return $this->getServiceNotAvailableResponse(); } @@ -111,7 +112,11 @@ public function create(string $name = '', bool $qrcodeLogin = false): JSONRespon return $this->getServiceNotAvailableResponse(); } else { $type = IToken::PERMANENT_TOKEN; - $scope = null; + try { + $scope = $scopes === null ? null : $this->tokenScopes->updateScope([], [TokenScopes::KEY => $scopes]); + } catch (\InvalidArgumentException) { + return new JSONResponse([], Http::STATUS_BAD_REQUEST); + } } if (mb_strlen($name) > 128) { @@ -275,7 +280,7 @@ public function update(int $id, array $scope, string $name): JSONResponse { } $this->tokenProvider->updateToken($token); - return new JSONResponse([]); + return new JSONResponse(['scope' => $newScope]); } /** diff --git a/apps/settings/src/authtokens/components/AuthToken.spec.ts b/apps/settings/src/authtokens/components/AuthToken.spec.ts index b1a565e770a39..3614defdd8773 100644 --- a/apps/settings/src/authtokens/components/AuthToken.spec.ts +++ b/apps/settings/src/authtokens/components/AuthToken.spec.ts @@ -18,8 +18,10 @@ vi.mock('@nextcloud/dialogs', () => ({ showConfirmation: showConfirmationMock, })) +import NcActionCheckbox from '@nextcloud/vue/components/NcActionCheckbox' import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import AuthToken from './AuthToken.vue' +import AuthTokenAccessDialog from './AuthTokenAccessDialog.vue' import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue' import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue' import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' @@ -343,3 +345,27 @@ describe('Desktop Firefox regression tests', () => { }) }) }) + +describe('AuthToken access', () => { + it('shows what an app password can reach', () => { + const wrapper = mountAuthToken(makeToken({ scope: { filesystem: true, scopes: ['files:files:read', 'files:files:write', 'files:dav:read', 'files:dav:write', 'files_sharing:shares:write'] } })) + + expect(wrapper.find('.auth-token__access').text()).toBe('Files') + }) + + it('keeps the filesystem toggle for tokens without scopes only', () => { + const stubs = { NcActions: { template: '
' } } + expect(mountAuthToken(makeToken(), { stubs }).findComponent(NcActionCheckbox).exists()).toBe(true) + expect(mountAuthToken(makeToken({ scope: { filesystem: false, scopes: ['dav:calendar:read'] } }), { stubs }).findComponent(NcActionCheckbox).exists()).toBe(false) + }) + + it('opens the access dialog for the token', async () => { + const token = makeToken() + const wrapper = mountAuthToken(token, { stubs: { AuthTokenAccessDialog: true } }) + + ;(wrapper.vm as unknown as { editAccess: () => void }).editAccess() + await nextTick() + + expect(wrapper.findComponent(AuthTokenAccessDialog).props('token')).toEqual(token) + }) +}) diff --git a/apps/settings/src/authtokens/components/AuthToken.vue b/apps/settings/src/authtokens/components/AuthToken.vue index b12c398c6d44a..d9bb8cbe5332f 100644 --- a/apps/settings/src/authtokens/components/AuthToken.vue +++ b/apps/settings/src/authtokens/components/AuthToken.vue @@ -6,10 +6,10 @@ + + + + diff --git a/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue new file mode 100644 index 0000000000000..7c47ce664b03b --- /dev/null +++ b/apps/settings/src/authtokens/components/AuthTokenAccessPicker.vue @@ -0,0 +1,113 @@ + + + + + + + diff --git a/apps/settings/src/authtokens/components/AuthTokenSetup.vue b/apps/settings/src/authtokens/components/AuthTokenSetup.vue index d31775fe1c570..ac34ce49b23e5 100644 --- a/apps/settings/src/authtokens/components/AuthTokenSetup.vue +++ b/apps/settings/src/authtokens/components/AuthTokenSetup.vue @@ -6,77 +6,32 @@ diff --git a/apps/settings/src/authtokens/store/authtoken.spec.ts b/apps/settings/src/authtokens/store/authtoken.spec.ts index 9e4387f26397d..383648097e38c 100644 --- a/apps/settings/src/authtokens/store/authtoken.spec.ts +++ b/apps/settings/src/authtokens/store/authtoken.spec.ts @@ -50,3 +50,39 @@ describe('store:authtoken addToken', () => { expect(store.tokens).toHaveLength(0) }) }) + +describe('store:authtoken scopes', () => { + beforeEach(() => { + setActivePinia(createPinia()) + vi.restoreAllMocks() + }) + + it('sends the scopes when creating a token', async () => { + const post = vi.spyOn(axios, 'post').mockResolvedValue({ + data: { deviceToken, loginName: 'alice', token: 'secret' }, + }) + + await useAuthTokenStore().addToken('Laptop', ['dav:calendar:read']) + + expect(post.mock.calls[0][1]).toMatchObject({ name: 'Laptop', scopes: ['dav:calendar:read'] }) + }) + + it.each([['dav:calendar:read'], null])('sets the scopes %o and keeps what the server stored', async (scopes) => { + const stored = { filesystem: false, scopes: ['dav:calendar:read'] } + const put = vi.spyOn(axios, 'put').mockResolvedValue({ data: { scope: stored } }) + const token = { ...deviceToken, scope: { filesystem: false, scopes: ['dav:contacts:read'] } } + + expect(await useAuthTokenStore().setTokenScopes(token, scopes)).toBe(true) + expect(put.mock.calls[0][1]).toMatchObject({ scope: { scopes } }) + expect(token.scope).toEqual(stored) + }) + + it('keeps the old scopes when saving fails', async () => { + vi.spyOn(axios, 'put').mockRejectedValue(new Error('Request failed')) + const scope = { filesystem: false, scopes: ['dav:contacts:read'] } + const token = { ...deviceToken, scope } + + expect(await useAuthTokenStore().setTokenScopes(token, null)).toBe(false) + expect(token.scope).toBe(scope) + }) +}) diff --git a/apps/settings/src/authtokens/store/authtoken.ts b/apps/settings/src/authtokens/store/authtoken.ts index 131cb60afb7e1..ab0c044af25ed 100644 --- a/apps/settings/src/authtokens/store/authtoken.ts +++ b/apps/settings/src/authtokens/store/authtoken.ts @@ -21,6 +21,14 @@ export enum TokenType { ONETIME_TOKEN = 3, } +export interface ITokenScope { + filesystem: boolean + /** + * Scopes the app password is limited to, missing for all access + */ + scopes?: string[] +} + export interface IToken { id: number canDelete: boolean @@ -32,7 +40,7 @@ export interface IToken { lastActivity: number name: string type: TokenType - scope: Record + scope: ITokenScope } export interface IRevokeAllResponse { @@ -84,10 +92,10 @@ export const useAuthTokenStore = defineStore('auth-token', { /** * Update a token on server * - * @param token Token to update + * @param token Token to update, `scope` may carry only the changed keys */ - async updateToken(token: IToken) { - const { data } = await axios.put(`${BASE_URL}/${token.id}`, token, { confirmPassword: PwdConfirmationMode.Strict }) + async updateToken(token: Omit & { scope: Partial | { scopes: string[] | null } }) { + const { data } = await axios.put<{ scope: ITokenScope }>(`${BASE_URL}/${token.id}`, token, { confirmPassword: PwdConfirmationMode.Strict }) return data }, @@ -95,13 +103,14 @@ export const useAuthTokenStore = defineStore('auth-token', { * Add a new token * * @param name The token name + * @param scopes Scopes to limit the token to, null for all access */ - async addToken(name: string) { + async addToken(name: string, scopes: string[] | null = null) { logger.debug('Creating a new app token') - // Let the failure reach the caller: AuthTokenSetup is the one that + // Let the failure reach the caller: AuthTokenAccessDialog is the one that // reports it, the same way updateToken leaves reporting to its callers. - const { data } = await axios.post(BASE_URL, { name, oneTime: true }, { confirmPassword: PwdConfirmationMode.Strict }) + const { data } = await axios.post(BASE_URL, { name, oneTime: true, scopes }, { confirmPassword: PwdConfirmationMode.Strict }) this.tokens.push(data.deviceToken) logger.debug('App token created') @@ -208,7 +217,7 @@ export const useAuthTokenStore = defineStore('auth-token', { * @param scope scope to set * @param value value to set */ - async setTokenScope(token: IToken, scope: string, value: boolean) { + async setTokenScope(token: IToken, scope: 'filesystem', value: boolean) { logger.debug('Updating app token scope', { token, scope, value }) const oldVal = token.scope[scope] @@ -226,6 +235,28 @@ export const useAuthTokenStore = defineStore('auth-token', { } return false }, + + /** + * Change what the token can reach + * + * @param token Token to change + * @param scopes Scopes to limit the token to, null for all access + */ + async setTokenScopes(token: IToken, scopes: string[] | null) { + logger.debug('Updating app token scopes', { token, scopes }) + + try { + // `scopes: null` has to be sent explicitly, it is what clears the limit + const data = await this.updateToken({ ...token, scope: { scopes } }) + token.scope = data.scope + logger.debug('App token scopes updated') + return true + } catch (error) { + logger.error('Could not update app token scopes', { error }) + showError(t('settings', 'Could not save the access')) + } + return false + }, }, }) diff --git a/apps/settings/src/authtokens/utils/accessLevel.spec.ts b/apps/settings/src/authtokens/utils/accessLevel.spec.ts new file mode 100644 index 0000000000000..1f593b4459e0e --- /dev/null +++ b/apps/settings/src/authtokens/utils/accessLevel.spec.ts @@ -0,0 +1,77 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { Selection } from './accessLevel.ts' + +import { describe, expect, it } from 'vitest' +import { accessLabel, areaState, fromScopes, setGrant, toScopes } from './accessLevel.ts' + +const files = ['files:files:read', 'files:files:write', 'files:dav:read', 'files:dav:write', 'files_sharing:shares:write'] +const calendarContacts = ['dav:calendar:read', 'dav:calendar:write', 'dav:contacts:read', 'dav:contacts:write'] + +describe('accessLevel', () => { + it.each<[string, Selection]>([ + ['files', { preset: 'files', grants: ['files', 'filesShare'] }], + ['files with trash and versions', { preset: 'custom', grants: ['files', 'filesShare', 'filesTrash', 'filesVersions'] }], + ['calendar and contacts', { preset: 'calendarContacts', grants: ['calendar', 'calendarWrite', 'contacts', 'contactsWrite'] }], + ['files without sharing', { preset: 'custom', grants: ['files'] }], + ['read-only calendar', { preset: 'custom', grants: ['calendar'] }], + ])('round-trips %s', (_, selection) => { + const scopes = toScopes(selection, [])! + expect(fromScopes(scopes)).toEqual({ selection, kept: [] }) + }) + + it('reads a token without scopes as all access', () => { + expect(fromScopes(undefined).selection.preset).toBe('all') + expect(toScopes({ preset: 'all', grants: ['files'] }, [])).toBeNull() + }) + + it('matches presets regardless of order', () => { + expect(fromScopes([...calendarContacts].reverse()).selection.preset).toBe('calendarContacts') + }) + + it('keeps scopes the picker can not show and sends them back', () => { + const { selection, kept } = fromScopes([...files, 'app:deck', 'dav:calendar:write']) + + // Write without read is possible through the API, the picker never creates it + expect(selection).toEqual({ preset: 'custom', grants: ['files', 'filesShare'] }) + expect(kept).toEqual(['app:deck', 'dav:calendar:write']) + expect(toScopes({ preset: 'custom', grants: ['contacts'] }, kept)) + .toEqual(['dav:contacts:read', 'app:deck', 'dav:calendar:write']) + }) + + it.each([ + [['calendar', 'calendarWrite'], 'all'], + [['calendar'], 'partial'], + [['files'], 'none'], + ] as const)('shows %o as %s for the calendar', (grants, state) => { + expect(areaState([...grants], 'calendar')).toBe(state) + }) + + it('ticks an area with its sub-options and unticks them together', () => { + expect(setGrant([], 'calendar', true)).toEqual(['calendar', 'calendarWrite']) + expect(setGrant(['calendar', 'calendarWrite', 'files'], 'calendar', false)).toEqual(['files']) + }) + + it('ticks the area with a sub-option, and unticks only the sub-option', () => { + expect(setGrant([], 'contactsWrite', true)).toEqual(['contacts', 'contactsWrite']) + expect(setGrant(['contacts', 'contactsWrite'], 'contactsWrite', false)).toEqual(['contacts']) + }) + + it.each([ + [{ filesystem: true }, 'All access'], + [{ filesystem: false }, 'All access without files'], + [{ filesystem: true, scopes: files }, 'Files'], + [{ filesystem: true, scopes: ['files:files:read', 'files:files:write', 'files:dav:read', 'files:dav:write'] }, 'Files'], + [{ filesystem: true, scopes: ['files:files:read', 'files:files:write'] }, 'Other access'], + [{ filesystem: false, scopes: calendarContacts }, 'Calendar and Contacts'], + [{ filesystem: false, scopes: ['dav:calendar:read'] }, 'Calendar (read only)'], + [{ filesystem: false, scopes: ['app:deck'] }, 'Other access'], + [{ filesystem: true, scopes: [...files, 'app:deck'] }, 'Files and other access'], + [{ filesystem: false, scopes: [] }, 'No access'], + ])('labels %o as %s', (scope, label) => { + expect(accessLabel(scope)).toBe(label) + }) +}) diff --git a/apps/settings/src/authtokens/utils/accessLevel.ts b/apps/settings/src/authtokens/utils/accessLevel.ts new file mode 100644 index 0000000000000..5f64fb186615a --- /dev/null +++ b/apps/settings/src/authtokens/utils/accessLevel.ts @@ -0,0 +1,169 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + +import type { ITokenScope } from '../store/authtoken.ts' + +import { getLanguage, t } from '@nextcloud/l10n' + +export type Area = 'files' | 'calendar' | 'contacts' +export type Grant = Area | 'filesShare' | 'filesTrash' | 'filesVersions' | 'calendarWrite' | 'contactsWrite' +export type Preset = 'all' | 'files' | 'calendarContacts' | 'custom' +type AreaState = 'all' | 'partial' | 'none' + +export interface Selection { + preset: Preset + /** Ticked grants, only used when the preset is not `all` */ + grants: Grant[] +} + +/** Must match the scope names the server checks */ +const GRANT_SCOPES: Record = { + files: ['files:files:read', 'files:files:write', 'files:dav:read', 'files:dav:write'], + filesShare: ['files_sharing:shares:write'], + filesTrash: ['files_trashbin:dav:read', 'files_trashbin:dav:write'], + filesVersions: ['files_versions:dav:read', 'files_versions:dav:write'], + calendar: ['dav:calendar:read'], + calendarWrite: ['dav:calendar:write'], + contacts: ['dav:contacts:read'], + contactsWrite: ['dav:contacts:write'], +} + +export const AREA_OPTIONS: Record = { + files: ['filesShare', 'filesTrash', 'filesVersions'], + calendar: ['calendarWrite'], + contacts: ['contactsWrite'], +} + +export const PRESET_GRANTS: Record<'files' | 'calendarContacts', Grant[]> = { + files: ['files', 'filesShare'], + calendarContacts: ['calendar', 'calendarWrite', 'contacts', 'contactsWrite'], +} + +export const AREAS = Object.keys(AREA_OPTIONS) as Area[] + +export const GRANT_LABELS: Record = { + files: t('settings', 'Files'), + filesShare: t('settings', 'Can create shares and public links'), + filesTrash: t('settings', 'Can restore deleted files'), + filesVersions: t('settings', 'Can restore older versions'), + calendar: t('settings', 'Calendar'), + calendarWrite: t('settings', 'Can edit calendars'), + contacts: t('settings', 'Contacts'), + contactsWrite: t('settings', 'Can edit contacts'), +} +const GRANTS = Object.keys(GRANT_SCOPES) as Grant[] + +/** + * Read a stored scope into what the picker shows. + * A grant counts as ticked only when all its scopes are there, and a sub-option only with its area. + * Everything else is kept as is. + * + * @param scopes Stored scopes, undefined for all access + */ +export function fromScopes(scopes?: string[]): { selection: Selection, kept: string[] } { + if (scopes === undefined) { + return { selection: { preset: 'all', grants: [] }, kept: [] } + } + + const held = GRANTS.filter((grant) => GRANT_SCOPES[grant].every((scope) => scopes.includes(scope))) + const grants = held.filter((grant) => held.includes(areaOf(grant))) + const shown = grants.flatMap((grant) => GRANT_SCOPES[grant]) + const kept = scopes.filter((scope) => !shown.includes(scope)) + + const presets = Object.keys(PRESET_GRANTS) as (keyof typeof PRESET_GRANTS)[] + const preset = kept.length === 0 ? presets.find((name) => sameItems(PRESET_GRANTS[name], grants)) : undefined + return { selection: { preset: preset ?? 'custom', grants }, kept } +} + +/** + * Turn the picker's selection back into the scopes to send, null for all access + * + * @param selection What the picker shows + * @param kept Scopes the picker can't show, sent back unchanged + */ +export function toScopes(selection: Selection, kept: string[]): string[] | null { + if (selection.preset === 'all') { + return null + } + return [...new Set([...selection.grants.flatMap((grant) => GRANT_SCOPES[grant]), ...kept])] +} + +/** + * @param grants Ticked grants + * @param area Area to check + */ +export function areaState(grants: Grant[], area: Area): AreaState { + if (!grants.includes(area)) { + return 'none' + } + return AREA_OPTIONS[area].every((option) => grants.includes(option)) ? 'all' : 'partial' +} + +/** + * Tick or untick a grant. An area takes its sub-options with it, a sub-option brings its area. + * + * @param grants Ticked grants + * @param grant Grant to change + * @param on Whether to tick it + */ +export function setGrant(grants: Grant[], grant: Grant, on: boolean): Grant[] { + const changed = grant in AREA_OPTIONS ? [grant, ...AREA_OPTIONS[grant as Area]] : [grant] + if (!on) { + return grants.filter((held) => !changed.includes(held)) + } + return [...new Set([...grants, areaOf(grant), ...changed])] +} + +/** + * Short description of what an app password can reach, for its row + * + * @param scope Stored scope of the app password + */ +export function accessLabel(scope: ITokenScope): string { + if (scope.scopes === undefined) { + return scope.filesystem + ? t('settings', 'All access') + : t('settings', 'All access without files') + } + + const { selection, kept } = fromScopes(scope.scopes) + // Files without sharing keeps its plain name + const readOnly: Partial> = { + calendar: t('settings', 'Calendar (read only)'), + contacts: t('settings', 'Contacts (read only)'), + } + const parts = AREAS.flatMap((area) => { + const state = areaState(selection.grants, area) + if (state === 'none') { + return [] + } + return [(state === 'partial' && readOnly[area]) || GRANT_LABELS[area]] + }) + if (kept.length > 0) { + // TRANSLATORS Last item of a list such as "Files and other access" + parts.push(parts.length === 0 ? t('settings', 'Other access') : t('settings', 'other access')) + } + if (parts.length === 0) { + return t('settings', 'No access') + } + return new Intl.ListFormat(getLanguage(), { type: 'conjunction' }).format(parts) +} + +/** + * @param grant An area or sub-option + */ +function areaOf(grant: Grant): Area { + return AREAS.find((area) => area === grant || AREA_OPTIONS[area].includes(grant))! +} + +/** + * Same items in any order + * + * @param a First list + * @param b Second list + */ +export function sameItems(a: T[], b: T[]): boolean { + return a.length === b.length && a.every((item) => b.includes(item)) +} diff --git a/apps/settings/tests/Controller/AuthSettingsControllerTest.php b/apps/settings/tests/Controller/AuthSettingsControllerTest.php index b4b2201e7a78e..279e35cae1159 100644 --- a/apps/settings/tests/Controller/AuthSettingsControllerTest.php +++ b/apps/settings/tests/Controller/AuthSettingsControllerTest.php @@ -18,6 +18,7 @@ use OC\Authentication\Token\RemoteWipe; use OC\Authentication\Token\TokenScopes; use OCA\Files\TokenScopes as FilesTokenScopes; +use OCA\Files_Sharing\TokenScopes as SharingTokenScopes; use OCA\Settings\Activity\Provider; use OCA\Settings\Controller\AuthSettingsController; use OCP\Activity\IEvent; @@ -137,6 +138,38 @@ public function testCreate(): void { $this->assertEquals($expected, $response->getData()); } + public static function dataCreateWithScopes(): array { + $files = [SharingTokenScopes::SHARES_WRITE, FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE]; + $sorted = [FilesTokenScopes::FILES_READ, FilesTokenScopes::FILES_WRITE, SharingTokenScopes::SHARES_WRITE]; + return [ + 'files' => [$files, [TokenScopes::KEY => $sorted, IToken::SCOPE_FILESYSTEM => true]], + 'calendar' => [['dav:calendar:read'], [TokenScopes::KEY => ['dav:calendar:read'], IToken::SCOPE_FILESYSTEM => false]], + ]; + } + + #[\PHPUnit\Framework\Attributes\DataProvider(methodName: 'dataCreateWithScopes')] + public function testCreateWithScopes(array $scopes, array $expectedScope): void { + $this->mockSessionForCreate(); + $deviceToken = $this->createMock(IToken::class); + $deviceToken->method('jsonSerialize')->willReturn([]); + $this->tokenProvider->expects($this->once()) + ->method('generateToken') + ->with($this->anything(), $this->uid, 'User13', '123456', 'Nexus 4', IToken::PERMANENT_TOKEN, IToken::DO_NOT_REMEMBER, $expectedScope) + ->willReturn($deviceToken); + $this->mockActivityManager(); + + $response = $this->controller->create('Nexus 4', false, $scopes); + $this->assertSame(Http::STATUS_OK, $response->getStatus()); + } + + public function testCreateInvalidScopes(): void { + $this->mockSessionForCreate(); + $this->tokenProvider->expects($this->never())->method('generateToken'); + + $response = $this->controller->create('Nexus 4', false, [FilesTokenScopes::FILES_WRITE]); + $this->assertSame(Http::STATUS_BAD_REQUEST, $response->getStatus()); + } + public function testCreateDisabledBySystemConfig(): void { $name = 'Nexus 4'; @@ -460,7 +493,7 @@ public function testUpdateRename(string $name, string $newName): void { ->method('updateToken') ->with($this->equalTo($token)); - $this->assertSame([], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], $newName)->getData()); + $this->assertSame(['scope' => [IToken::SCOPE_FILESYSTEM => true]], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], $newName)->getData()); } public static function dataUpdateFilesystemScope(): array { @@ -498,7 +531,7 @@ public function testUpdateFilesystemScope(bool $filesystem, bool $newFilesystem) ->method('updateToken') ->with($this->equalTo($token)); - $this->assertSame([], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => $newFilesystem], 'App password')->getData()); + $this->assertSame(['scope' => [IToken::SCOPE_FILESYSTEM => $newFilesystem]], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => $newFilesystem], 'App password')->getData()); } public function testUpdateNoChange(): void { @@ -529,7 +562,7 @@ public function testUpdateNoChange(): void { ->method('updateToken') ->with($this->equalTo($token)); - $this->assertSame([], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], 'App password')->getData()); + $this->assertSame(['scope' => [IToken::SCOPE_FILESYSTEM => true]], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], 'App password')->getData()); } public static function dataUpdateScopedToken(): array { @@ -563,7 +596,7 @@ public function testUpdateScopedTokenKeepsScopes(array $stored, array $scope): v ->method('updateToken') ->with($token); - $this->assertSame([], $this->controller->update($tokenId, $scope, 'Renamed')->getData()); + $this->assertSame(['scope' => $stored], $this->controller->update($tokenId, $scope, 'Renamed')->getData()); } public static function dataUpdateScopes(): array { @@ -594,7 +627,7 @@ public function testUpdateScopesPublishOneActivity(array $stored, array $scope, ->method('updateToken') ->with($token); - $this->assertSame([], $this->controller->update($tokenId, $scope, 'App password')->getData()); + $this->assertSame(['scope' => $expected], $this->controller->update($tokenId, $scope, 'App password')->getData()); } public function testUpdateInvalidScopes(): void { @@ -643,7 +676,7 @@ public function testUpdateExpired(): void { ->method('updateToken') ->with($this->equalTo($token)); - $this->assertSame([], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], 'App password')->getData()); + $this->assertSame(['scope' => [IToken::SCOPE_FILESYSTEM => true]], $this->controller->update($tokenId, [IToken::SCOPE_FILESYSTEM => true], 'App password')->getData()); } public function testUpdateTokenWrongUser(): void { @@ -680,6 +713,18 @@ public function testUpdateTokenNonExisting(): void { $this->assertSame(\OCP\AppFramework\Http::STATUS_NOT_FOUND, $response->getStatus()); } + private function mockSessionForCreate(): void { + $sessionToken = $this->createMock(IToken::class); + $sessionToken->method('getLoginName')->willReturn('User13'); + $this->serverConfig->method('getSystemValueBool') + ->with('auth_can_create_app_token', true) + ->willReturn(true); + $this->session->method('getId')->willReturn('sessionid'); + $this->tokenProvider->method('getToken')->with('sessionid')->willReturn($sessionToken); + $this->tokenProvider->method('getPassword')->willReturn('123456'); + $this->secureRandom->method('generate')->willReturn('XXXXX'); + } + private function mockAuthToken(int $id, int $type = IToken::PERMANENT_TOKEN): PublicKeyToken&MockObject { $token = $this->createMock(PublicKeyToken::class); $token->method('getId')->willReturn($id); diff --git a/build/frontend/__mocks__/@nextcloud/axios.ts b/build/frontend/__mocks__/@nextcloud/axios.ts index 6145098511836..a3dcbe317d434 100644 --- a/build/frontend/__mocks__/@nextcloud/axios.ts +++ b/build/frontend/__mocks__/@nextcloud/axios.ts @@ -14,5 +14,6 @@ export default { get: async () => ({ status: 200, data: {} }), delete: async () => ({ status: 200, data: {} }), post: async () => ({ status: 200, data: {} }), + put: async () => ({ status: 200, data: {} }), head: async () => ({ status: 200, data: {} }), } diff --git a/tests/playwright/e2e/settings/devices-sessions.spec.ts b/tests/playwright/e2e/settings/devices-sessions.spec.ts index 4f3ec2806a91e..ef7f8e6a23197 100644 --- a/tests/playwright/e2e/settings/devices-sessions.spec.ts +++ b/tests/playwright/e2e/settings/devices-sessions.spec.ts @@ -67,4 +67,14 @@ test.describe('Settings: Devices & sessions', () => { await expect(devicesSessionsPage.tokenRows()).toHaveCount(2) await expect(devicesSessionsPage.tokenRow('Playwright device')).toBeVisible() }) + + test('creates an app password with limited access and changes it', async ({ devicesSessionsPage }) => { + await devicesSessionsPage.open() + + await devicesSessionsPage.createAppPassword('Thunderbird', 'Files only') + await expect(devicesSessionsPage.tokenRow('Thunderbird')).toContainText('Files') + + await devicesSessionsPage.editAccess('Thunderbird', 'Calendar and contacts') + await expect(devicesSessionsPage.tokenRow('Thunderbird')).toContainText('Calendar and Contacts') + }) }) diff --git a/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts b/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts index ab866b53edaca..43f599776b972 100644 --- a/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts +++ b/tests/playwright/support/sections/DevicesSessionsSettingsPage.ts @@ -65,4 +65,48 @@ export class DevicesSessionsSettingsPage { await dialog.getByRole('button', { name: 'Revoke all others' }).click() await awaitPasswordGuardedRequest(this.page, revoked) } + + private accessDialog(name: 'Create new app password' | 'Edit access'): Locator { + return this.page.getByRole('dialog', { name }) + } + + /** + * @param name - Name of the new app password + * @param access - Label of the access preset to pick + */ + async createAppPassword(name: string, access: string): Promise { + await this.page.getByRole('button', { name: 'Create new app password' }).click() + const dialog = this.accessDialog('Create new app password') + await dialog.getByRole('textbox', { name: 'App name' }).fill(name) + await dialog.getByRole('radio', { name: access }).check({ force: true }) + + const created = this.page.waitForResponse((r) => r.request().method() === 'POST' + && r.url().includes('/settings/personal/authtokens') + && r.ok()) + await dialog.getByRole('button', { name: 'Create' }).click() + await awaitPasswordGuardedRequest(this.page, created) + + const setup = this.page.getByRole('dialog', { name: 'New app password', exact: true }) + await expect(setup).toBeVisible() + await setup.getByRole('button', { name: 'Close' }).click() + await expect(setup).toBeHidden() + } + + /** + * @param row - Visible name of the app password + * @param access - Label of the access preset to pick + */ + async editAccess(row: string, access: string): Promise { + await this.tokenRow(row).getByRole('button', { name: 'Device settings' }).click() + await this.page.getByRole('menuitem', { name: 'Edit access' }).click() + const dialog = this.accessDialog('Edit access') + await dialog.getByRole('radio', { name: access }).check({ force: true }) + + const saved = this.page.waitForResponse((r) => r.request().method() === 'PUT' + && r.url().includes('/settings/personal/authtokens/') + && r.ok()) + await dialog.getByRole('button', { name: 'Save' }).click() + await awaitPasswordGuardedRequest(this.page, saved) + await expect(dialog).toBeHidden() + } }