diff --git a/apps/settings/src/components/AuthToken.spec.ts b/apps/settings/src/authtokens/components/AuthToken.spec.ts similarity index 60% rename from apps/settings/src/components/AuthToken.spec.ts rename to apps/settings/src/authtokens/components/AuthToken.spec.ts index 75b483247f66f..509712d72c4e5 100644 --- a/apps/settings/src/components/AuthToken.spec.ts +++ b/apps/settings/src/authtokens/components/AuthToken.spec.ts @@ -6,8 +6,10 @@ import type { IToken } from '../store/authtoken.ts' import { createTestingPinia } from '@pinia/testing' -import { mount } from '@vue/test-utils' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { getByRole } from '@testing-library/vue' +import { enableAutoUnmount, mount } from '@vue/test-utils' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { nextTick } from 'vue' // AuthToken.vue reads window.OC.theme.productName at module evaluation time. // vi.hoisted runs before imports, so this guarantees the property is set on @@ -26,9 +28,12 @@ vi.mock('@nextcloud/dialogs', () => ({ import NcNoteCard from '@nextcloud/vue/components/NcNoteCard' import AuthToken from './AuthToken.vue' import AuthTokenDeleteDialog from './AuthTokenDeleteDialog.vue' +import AuthTokenSetupDialog from './AuthTokenSetupDialog.vue' import { TokenType, useAuthTokenStore } from '../store/authtoken.ts' import { detect } from '../utils/userAgentDetect.ts' +enableAutoUnmount(afterEach) + function makeToken(overrides: Partial = {}): IToken { return { id: 1, @@ -42,37 +47,42 @@ function makeToken(overrides: Partial = {}): IToken { } } -function mountAuthToken(token: IToken) { +function mountAuthToken(token: IToken, { stubs = {}, attachTo }: { stubs?: Record, attachTo?: HTMLElement } = {}) { return mount(AuthToken, { - // Vue Test Utils v1 (legacy pipeline) uses propsData; v2 also accepts it - propsData: { token }, - mocks: { - t: (_: string, text: string) => text, - }, - stubs: { - NcActions: true, - NcActionButton: true, - NcActionCheckbox: true, - NcButton: true, - NcDateTime: true, - NcIconSvgWrapper: true, - NcTextField: true, + props: { token }, + attachTo, + global: { + mocks: { + t: (_: string, text: string) => text, + }, + stubs: { + NcActions: true, + NcActionButton: true, + NcActionCheckbox: true, + NcButton: true, + NcDateTime: true, + NcIconSvgWrapper: true, + NcTextField: true, + ...stubs, + }, + plugins: [createTestingPinia({ + createSpy: vi.fn, + initialState: { 'auth-token': { tokens: [token] } }, + })], }, - pinia: createTestingPinia({ - createSpy: vi.fn, - initialState: { 'auth-token': { tokens: [token] } }, - }), }) } function mountDeleteDialog(token: IToken, open = true) { return mount(AuthTokenDeleteDialog, { - propsData: { token, open }, - mocks: { - t: (_: string, text: string) => text, - }, - stubs: { - NcDialog: { template: '
' }, + props: { token, open }, + global: { + mocks: { + t: (_: string, text: string) => text, + }, + stubs: { + NcDialog: { template: '
' }, + }, }, }) } @@ -88,7 +98,7 @@ describe('AuthToken revoke flow', () => { const store = useAuthTokenStore() ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) expect(dialog.exists()).toBe(true) @@ -102,12 +112,12 @@ describe('AuthToken revoke flow', () => { const store = useAuthTokenStore() ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) dialog.vm.$emit('confirm') dialog.vm.$emit('update:open', false) - await wrapper.vm.$nextTick() + await nextTick() expect(store.deleteToken).toHaveBeenCalledTimes(1) expect(store.deleteToken).toHaveBeenCalledWith(token) @@ -119,11 +129,11 @@ describe('AuthToken revoke flow', () => { const store = useAuthTokenStore() ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) dialog.vm.$emit('update:open', false) - await wrapper.vm.$nextTick() + await nextTick() // Dialog is v-if'd off the tree once closed expect(wrapper.findComponent(AuthTokenDeleteDialog).exists()).toBe(false) @@ -135,7 +145,7 @@ describe('AuthToken revoke flow', () => { const wrapper = mountAuthToken(token) ;(wrapper.vm as unknown as { revoke: () => void }).revoke() - await wrapper.vm.$nextTick() + await nextTick() const dialog = wrapper.findComponent(AuthTokenDeleteDialog) expect(dialog.exists()).toBe(true) @@ -144,6 +154,110 @@ describe('AuthToken revoke flow', () => { }) }) +describe('AuthToken rename focus', () => { + function mountRenamable(token: IToken) { + return mountAuthToken(token, { + attachTo: document.body, + stubs: { + NcActions: { template: '
' }, + NcTextField: { template: '', methods: { select() {} } }, + }, + }) + } + + function actionsButton(wrapper: ReturnType) { + return getByRole(wrapper.element, 'button', { name: 'Device settings' }) + } + + it('returns focus to the actions button after cancelling with Escape', async () => { + const wrapper = mountRenamable(makeToken()) + + ;(wrapper.vm as unknown as { startRename: () => void }).startRename() + await nextTick() + await wrapper.find('input').trigger('keyup', { key: 'Escape' }) + await nextTick() + + expect(wrapper.find('form').exists()).toBe(false) + expect(actionsButton(wrapper)).toHaveFocus() + }) + + it('returns focus to the actions button after saving the new name', async () => { + const token = makeToken() + const wrapper = mountRenamable(token) + const store = useAuthTokenStore() + + ;(wrapper.vm as unknown as { startRename: () => void }).startRename() + await nextTick() + await wrapper.find('form').trigger('submit') + await nextTick() + + expect(store.renameToken).toHaveBeenCalledWith(token, token.name) + expect(actionsButton(wrapper)).toHaveFocus() + }) + + it('returns focus to the actions button once the password confirmation closes', async () => { + const token = makeToken() + const wrapper = mountRenamable(token) + const store = useAuthTokenStore() + const dialogField = document.createElement('input') + document.body.appendChild(dialogField) + const dialog = Promise.withResolvers() + vi.mocked(store.renameToken).mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve)) + dialogField.focus() + await dialog.promise + dialogField.remove() + return true + }) + + ;(wrapper.vm as unknown as { startRename: () => void }).startRename() + await nextTick() + await wrapper.find('form').trigger('submit') + await vi.waitFor(() => expect(dialogField).toHaveFocus(), { interval: 1 }) + dialog.resolve() + + await vi.waitFor(() => expect(actionsButton(wrapper)).toHaveFocus(), { interval: 1 }) + }) +}) + +describe('AuthToken action labels', () => { + it('labels each action with its own text', () => { + const wrapper = mountAuthToken(makeToken(), { + stubs: { + NcActions: { template: '
' }, + NcActionButton: false, + NcButton: false, + }, + }) + + const labels = wrapper.findAll('button').map((button) => button.text()) + expect(labels).toEqual(expect.arrayContaining(['Rename', 'Revoke', 'Wipe device'])) + }) +}) + +describe('AuthTokenSetupDialog QR code', () => { + // The credentials are shown as text, so the QR code is redundant for screen readers + it('hides the QR code from assistive technology', async () => { + const wrapper = mount(AuthTokenSetupDialog, { + props: { token: { token: 'app-password', loginName: 'admin', deviceToken: makeToken() } }, + global: { + mocks: { + t: (_: string, text: string) => text, + }, + stubs: { + NcDialog: { template: '
' }, + NcIconSvgWrapper: true, + }, + }, + }) + + getByRole(wrapper.element, 'button', { name: 'Show QR code for mobile apps' }).click() + await nextTick() + + expect(wrapper.find('canvas').element).toHaveAttribute('aria-hidden', 'true') + }) +}) + describe('AuthToken wipe flow', () => { beforeEach(() => { vi.clearAllMocks() diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/authtokens/components/AuthToken.vue similarity index 90% rename from apps/settings/src/components/AuthToken.vue rename to apps/settings/src/authtokens/components/AuthToken.vue index 716ed27edb0a4..d16862370bfc3 100644 --- a/apps/settings/src/components/AuthToken.vue +++ b/apps/settings/src/authtokens/components/AuthToken.vue @@ -16,9 +16,9 @@ ref="input" v-model="newName" :label="t('settings', 'Device name')" - :show-trailing-button="true" - :trailing-button-label="t('settings', 'Cancel renaming')" - @trailing-button-click="cancelRename" + :showTrailingButton="true" + :trailingButtonLabel="t('settings', 'Cancel renaming')" + @trailingButtonClick="cancelRename" @keyup.esc="cancelRename" />