opsdash-app/opsdash/test/DeckCardsPanel.test.ts
blade34242 ff2210cc97
Some checks failed
Nextcloud Server Tests / version-consistency (push) Successful in 42s
Nextcloud Server Tests / matrix-config (push) Successful in 32s
Nextcloud Server Tests / Nextcloud stable30 / PHP 8.2 (stable30, 8.2) (push) Failing after 6m14s
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.2 (stable31, 8.2) (push) Failing after 6m25s
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.2 (stable32, 8.2) (push) Has been cancelled
Nextcloud Server Tests / Nextcloud stable32 / PHP 8.3 (stable32, 8.3) (push) Has been cancelled
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.2 (stable33, 8.2) (push) Has been cancelled
Nextcloud Server Tests / Nextcloud stable33 / PHP 8.3 (stable33, 8.3) (push) Has been cancelled
Nextcloud Server Tests / Nextcloud stable31 / PHP 8.3 (stable31, 8.3) (push) Has been cancelled
security: validate Deck API colors before binding to :style
Board and label colors from the Deck API are now filtered through a
safeColor() helper that allows only #hex or plain CSS color names.
Arbitrary strings (CSS expressions, url() references) fall back to
the component's CSS variable default.
2026-05-06 10:36:13 +07:00

242 lines
8.1 KiB
TypeScript

import { mount } from '@vue/test-utils'
import { describe, it, expect, vi } from 'vitest'
import DeckCardsPanel from '../src/components/panels/DeckCardsPanel.vue'
const cards = [
{
id: 'c1',
title: 'Prep Opsdash Deck sync',
status: 'active',
match: 'due' as const,
due: '2025-03-12T10:00:00Z',
done: null,
boardId: 'b1',
boardTitle: 'Opsdash Deck QA',
boardColor: '#2563EB',
stackTitle: 'Inbox',
labels: [{ id: 'l1', title: 'Ops', color: '#F97316' }],
assignees: [{ id: 'u1', uid: 'qa', displayName: 'QA User' }],
},
{
id: 'c2',
title: 'Archive completed Ops tasks',
status: 'done',
match: 'completed' as const,
due: null,
done: '2025-03-05T08:00:00Z',
boardId: 'b1',
boardTitle: 'Opsdash Deck QA',
boardColor: '#2563EB',
stackTitle: 'Done',
labels: [],
assignees: [],
},
]
const mountPanel = (overrides: Record<string, unknown> = {}) => {
return mount(DeckCardsPanel, {
props: {
cards,
loading: false,
rangeLabel: 'Week',
deckUrl: '/apps/deck/',
lastFetchedAt: '2025-03-10T12:00:00Z',
filter: 'all',
canFilterMine: true,
showHeader: true,
...overrides,
},
global: {
stubs: {
NcEmptyContent: {
template: '<div class="stub-empty"><slot /></div>',
},
NcLoadingIcon: {
template: '<div class="stub-loading"></div>',
},
},
},
})
}
describe('DeckCardsPanel', () => {
it('renders cards with status/board/assignee info and last fetched label', () => {
const wrapper = mountPanel()
expect(wrapper.text()).toContain('Deck cards')
expect(wrapper.text()).toContain('Updated')
expect(wrapper.text()).toContain('Prep Opsdash Deck sync')
expect(wrapper.text()).toContain('Active')
expect(wrapper.text()).toContain('Due')
expect(wrapper.text()).toContain('Opsdash Deck QA')
expect(wrapper.text()).toContain('Inbox')
expect(wrapper.text()).toContain('QA User')
const doneStatus = wrapper.findAll('.deck-card__status').at(1)
expect(doneStatus?.text()).toBe('Done')
})
it('emits filter updates and disables "My cards" when not allowed', async () => {
const wrapper = mountPanel({ canFilterMine: false, filtersEnabled: true })
const filterGroup = wrapper.find('[aria-label="Deck card filters"]')
expect(filterGroup.exists()).toBe(true)
const buttons = filterGroup.findAll('button.deck-filter-btn')
expect(buttons.length).toBeGreaterThan(2)
const mineButtons = buttons.filter((btn) => btn.text().includes('Mine'))
mineButtons.forEach((btn) => expect(btn.attributes('disabled')).toBeDefined())
await buttons[0].trigger('click')
await mineButtons[0].trigger('click')
const emissions = wrapper.emitted('update:filter') ?? []
expect(emissions).toEqual([['all']]) // mine click ignored due to disabled
})
it('shows empty state and refresh emits when clicked', async () => {
const wrapper = mountPanel({ cards: [], loading: false })
expect(wrapper.find('.deck-card-list').exists()).toBe(false)
expect(wrapper.find('.stub-empty').exists()).toBe(true)
await wrapper.find('button.deck-panel__refresh').trigger('click')
expect(wrapper.emitted().refresh).toBeTruthy()
})
it('renders filter counts when provided', () => {
const wrapper = mountPanel({
filtersEnabled: true,
filterOptions: [
{ value: 'open_all', label: 'Open · All', mine: false, count: 2 },
{ value: 'done_all', label: 'Done · All', mine: false, count: 1 },
],
})
const counts = wrapper.findAll('.deck-filter-count')
expect(counts.length).toBe(2)
expect(counts[0].text()).toBe('2')
expect(counts[1].text()).toBe('1')
})
it('renders context label and board color marker for duplicate tag filters', () => {
const wrapper = mountPanel({
filtersEnabled: true,
filterOptions: [
{
value: 'open_all',
label: 'Open · All',
mine: false,
count: 2,
},
{
value: 'tag_11' as any,
label: 'Ops',
mine: false,
count: 4,
contextLabel: 'Opsdash Product Delivery',
contextColor: '#2563EB',
},
],
})
expect(wrapper.text()).toContain('Opsdash Product Delivery')
const dot = wrapper.find('.deck-filter-board-dot')
expect(dot.exists()).toBe(true)
expect(dot.attributes('style')).toContain('background-color: rgb(37, 99, 235)')
})
it('renders error message when provided', () => {
const wrapper = mountPanel({ error: 'Deck unavailable', cards: [] })
expect(wrapper.find('.deck-panel__error').text()).toContain('Deck unavailable')
})
it('shows loading state instead of cards or error', () => {
const wrapper = mountPanel({ loading: true, cards: [], error: 'Should not show' })
expect(wrapper.find('.deck-panel__loading').exists()).toBe(true)
expect(wrapper.find('.deck-card-list').exists()).toBe(false)
expect(wrapper.find('.deck-panel__error').exists()).toBe(false)
})
it('hides filters when disabled', () => {
const wrapper = mountPanel({ filtersEnabled: false })
expect(wrapper.find('[aria-label="Deck card filters"]').exists()).toBe(false)
})
it('falls back to range label when last fetched is missing/invalid', () => {
const wrapper = mountPanel({ lastFetchedAt: 'not-a-date' })
expect(wrapper.text()).toContain('Showing week selection')
})
it('hides the header when showHeader is false', () => {
const wrapper = mountPanel({ showHeader: false })
expect(wrapper.find('.deck-panel__header').exists()).toBe(false)
})
it('emits reorder event when filters are dragged in edit mode', async () => {
const wrapper = mountPanel({
filtersEnabled: true,
editable: true,
orderableValues: ['open_all', 'done_all', 'archived_all'],
filterOptions: [
{ value: 'open_all', label: 'Open · All', mine: false },
{ value: 'done_all', label: 'Done · All', mine: false },
{ value: 'archived_all', label: 'Archived · All', mine: false },
],
})
const buttons = wrapper.findAll('button.deck-filter-btn')
const dataTransfer = { effectAllowed: '', setData: vi.fn() }
await buttons[0].trigger('dragstart', { dataTransfer })
await buttons[2].trigger('drop')
const emissions = wrapper.emitted('reorder:filters') ?? []
expect(emissions.length).toBe(1)
expect(emissions[0]).toEqual([['done_all', 'archived_all', 'open_all']])
})
it('sanitizes unsafe board and label colors, keeps valid hex', () => {
const maliciousCards = [
{
id: 'c3',
title: 'Test card',
status: 'active',
match: 'due' as const,
due: null,
done: null,
boardId: 'b2',
boardTitle: 'Board',
boardColor: 'red; } body { display:none',
stackTitle: 'Stack',
labels: [{ id: 'l2', title: 'Bad', color: 'url(https://evil.com)' }],
assignees: [],
},
{
id: 'c4',
title: 'Safe card',
status: 'active',
match: 'due' as const,
due: null,
done: null,
boardId: 'b3',
boardTitle: 'Board',
boardColor: '#2563EB',
stackTitle: 'Stack',
labels: [{ id: 'l3', title: 'Ok', color: '#F97316' }],
assignees: [],
},
]
const wrapper = mountPanel({ cards: maliciousCards })
const boardSpans = wrapper.findAll('.deck-card__board')
const labelSpans = wrapper.findAll('.deck-card__label')
// Unsafe board color falls back to CSS var; the malicious string must not appear.
const badBoardStyle = boardSpans[0]?.attributes('style') ?? ''
expect(badBoardStyle).not.toContain('display')
expect(badBoardStyle).not.toContain('evil.com')
expect(badBoardStyle).not.toContain('red; }')
// Safe hex board color passes through (jsdom renders hex as rgb()).
const goodBoardStyle = boardSpans[1]?.attributes('style') ?? ''
expect(goodBoardStyle).toContain('37, 99, 235')
// Safe hex label color passes through (c4's label is labelSpans[1]).
const goodLabelStyle = labelSpans[1]?.attributes('style') ?? ''
expect(goodLabelStyle).toContain('249, 115, 22')
})
})