Skip to content
Draft
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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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> = {}): IToken {
return {
id: 1,
Expand All @@ -42,37 +47,42 @@ function makeToken(overrides: Partial<IToken> = {}): IToken {
}
}

function mountAuthToken(token: IToken) {
function mountAuthToken(token: IToken, { stubs = {}, attachTo }: { stubs?: Record<string, object | boolean>, 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: '<div><slot /></div>' },
props: { token, open },
global: {
mocks: {
t: (_: string, text: string) => text,
},
stubs: {
NcDialog: { template: '<div><slot /></div>' },
},
},
})
}
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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)
Expand All @@ -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: '<div><button>Device settings</button><slot /></div>' },
NcTextField: { template: '<input>', methods: { select() {} } },
},
})
}

function actionsButton(wrapper: ReturnType<typeof mountRenamable>) {
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<void>()
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: '<ul><slot /></ul>' },
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: '<div><slot /></div>' },
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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" />
<NcButton :aria-label="t('settings', 'Save new name')" variant="tertiary" type="submit">
<template #icon>
Expand All @@ -33,37 +33,38 @@
<td>
<NcDateTime
class="auth-token__last-activity"
:ignore-seconds="true"
:ignoreSeconds="true"
:timestamp="tokenLastActivity" />
</td>
<td class="auth-token__actions">
<NcActions
v-if="!token.current"
ref="actions"
v-model:open="actionOpen"
:title="t('settings', 'Device settings')"
:aria-label="t('settings', 'Device settings')"
:open.sync="actionOpen">
:aria-label="t('settings', 'Device settings')">
<!-- TODO: add text/longtext with some description -->
<NcActionCheckbox
v-if="canChangeScope"
:model-value="token.scope.filesystem"
:modelValue="token.scope.filesystem"
@update:modelValue="updateFileSystemScope">
<!-- TODO: add text/longtext with some description -->
{{ t('settings', 'Allow filesystem access') }}
</NcActionCheckbox>
<!-- TODO: add text/longtext with some description -->
<NcActionButton
v-if="token.canRename"
icon="icon-rename"
@click.stop.prevent="startRename">
<!-- TODO: add text/longtext with some description -->
{{ t('settings', 'Rename') }}
</NcActionButton>

<!-- revoke & wipe -->
<template v-if="token.canDelete">
<template v-if="token.type !== TokenType.WIPING_TOKEN">
<!-- TODO: add text/longtext with some description -->
<NcActionButton
icon="icon-delete"
@click.stop.prevent="revoke">
<!-- TODO: add text/longtext with some description -->
{{ t('settings', 'Revoke') }}
</NcActionButton>
<NcActionButton
Expand All @@ -84,8 +85,8 @@
</td>
<AuthTokenDeleteDialog
v-if="deleteDialogOpen"
v-model:open="deleteDialogOpen"
:token="token"
:open.sync="deleteDialogOpen"
@confirm="confirmDelete" />
</tr>
</template>
Expand Down Expand Up @@ -285,6 +286,13 @@ export default defineComponent({

cancelRename() {
this.renaming = false
this.focusActions()
},

async focusActions() {
await this.$nextTick()
const actions = this.$refs.actions as { $el: HTMLElement } | undefined
actions?.$el.querySelector('button')?.focus()
},

revoke() {
Expand All @@ -296,9 +304,11 @@ export default defineComponent({
this.authTokenStore.deleteToken(this.token)
},

rename() {
async rename() {
this.renaming = false
this.authTokenStore.renameToken(this.token, this.newName)
// The password confirmation holds focus until it closes, so refocus after it
await this.authTokenStore.renameToken(this.token, this.newName)
this.focusActions()
},

async wipe() {
Expand Down
Loading
Loading