/** * Enrollment settings page (per-PC credentials). * * Pins the wire shapes the page sends, because the server contract is exact: * a wave is {"next": n} or {"assetids": [...]}, an approval carries the chosen * assetid, and turning the shared key off must tell the admin how many PCs are * not enrolled yet before anything is sent. */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' vi.mock('@/api', () => ({ default: { get: vi.fn(), post: vi.fn(), put: vi.fn() }, computersApi: { list: vi.fn() } })) vi.mock('@/stores/auth', () => ({ useAuthStore: () => ({ isAdmin: true, hasPermission: () => true }) })) vi.mock('@/composables/toast', () => ({ useToast: () => ({ success: vi.fn(), error: vi.fn(), info: vi.fn() }) })) vi.mock('@/components/PaginationBar.vue', () => ({ default: { name: 'PaginationBar', template: '
' } })) import api, { computersApi } from '@/api' import EnrollmentSettings from './EnrollmentSettings.vue' function migrationPayload() { return { data: { data: { target: '1.20.0', counts: { enrolled: 40, pending: 2, upgrading: 3, ready: 30, blocked: 5, notreporting: 20 }, computers: [ { assetid: 11, hostname: 'PC-READY', agentversion: '1.19.2', lastreportedat: null, state: 'ready', blockers: [] }, { assetid: 12, hostname: 'PC-BLOCKED', agentversion: '1.18.0', lastreportedat: null, state: 'blocked', blockers: ['offline', 'no serial on record'] }, { assetid: 13, hostname: 'PC-TRUST', agentversion: '1.19.2', lastreportedat: null, state: 'blocked', blockers: ['server certificate not trusted by this PC'] }, { assetid: 14, hostname: 'PC-TRUST-OFFLINE', agentversion: '1.19.2', lastreportedat: null, state: 'blocked', blockers: ['offline', 'server certificate not trusted by this PC'] } ], waves: [ { waveid: 3, createdat: null, createdby: 'admin', size: 25, queued: 25, enrolled: 10, failed: 1, done: false } ] }, meta: { pagination: { totalpages: 1 } } } } } function enrollmentPayload() { return { data: { data: { settings: { sharedkeyallowed: true, autoenroll: true, maxpendingenrollments: 500 }, counts: { enrolled: 40, notenrolled: 58, pendingcomputers: 1, pending: 2 }, computers: [ { assetid: 21, hostname: 'PC-ENROLLED', serialnumber: 'S1', state: 'enrolled', credentialprefix: 'sdbe_abcdefg', issuedat: null, lastusedat: null, enrolledvia: 'auto', reenrollblocked: false, reenrollblockedreason: null }, { assetid: 22, hostname: 'PC-ROLLEDBACK', serialnumber: 'S2', state: 'revoked', credentialprefix: 'sdbe_hijklmn', issuedat: null, lastusedat: null, enrolledvia: 'auto', reenrollblocked: true, reenrollblockedreason: 'rollback' } ], requests: [ { requestid: 45, hostname: 'PC-NEW', serialnumber: 'S9', deviceid: null, reason: 'serial mismatch', status: 'pending', matchedassetid: 77, requestedat: null, lastseenat: null, sourceip: null } ] }, meta: { pagination: { totalpages: 1 } } } } } async function render() { api.get.mockImplementation(path => Promise.resolve( path === '/endpoints/enrollment/migration' ? migrationPayload() : enrollmentPayload())) api.post.mockResolvedValue({ data: { data: { waveid: 4, queued: [11], skipped: [] } } }) api.put.mockResolvedValue({ data: { data: {} } }) const wrapper = mount(EnrollmentSettings) await flushPromises() return wrapper } async function openCredentials(wrapper) { const tab = wrapper.findAll('.enroll-tab').find(button => button.text() === 'Credentials') await tab.trigger('click') await flushPromises() } describe('EnrollmentSettings', () => { beforeEach(() => { api.get.mockReset() api.post.mockReset() api.put.mockReset() computersApi.list.mockReset() }) afterEach(() => { vi.unstubAllGlobals() }) it('renders the migration counts and enrolled progress', async () => { const wrapper = await render() expect(wrapper.find('[data-count="enrolled"]').text()).toBe('40') expect(wrapper.find('[data-count="ready"]').text()).toBe('30') expect(wrapper.find('[data-count="blocked"]').text()).toBe('5') expect(wrapper.find('[data-count="notreporting"]').text()).toBe('20') expect(wrapper.find('.progress-text').text()).toContain('40 of 100 enrolled') }) it('shows every blocker for a blocked PC', async () => { const wrapper = await render() const row = wrapper.findAll('.migration-row').find(r => r.text().includes('PC-BLOCKED')) expect(row.find('.blockers').text()).toBe('offline, no serial on record') }) it('only offers a checkbox on ready rows', async () => { const wrapper = await render() const rows = wrapper.findAll('.migration-row') expect(rows[0].find('input[type="checkbox"]').exists()).toBe(true) expect(rows[1].find('input[type="checkbox"]').exists()).toBe(false) }) it('offers a trust-only blocked PC for retry, not one with another blocker too', async () => { const wrapper = await render() const rows = wrapper.findAll('.migration-row') const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE')) const mixed = rows.find(r => r.text().includes('PC-TRUST-OFFLINE')) expect(trust.find('input[type="checkbox"]').exists()).toBe(true) expect(trust.find('.retry-hint').text()).toBe('Retry after fixing trust') expect(mixed.find('input[type="checkbox"]').exists()).toBe(false) expect(mixed.find('.retry-hint').exists()).toBe(false) }) it('includes a trust-blocked PC in Upgrade selected', async () => { vi.stubGlobal('confirm', vi.fn(() => true)) const wrapper = await render() const rows = wrapper.findAll('.migration-row') await rows[0].find('input[type="checkbox"]').setValue(true) const trust = rows.find(r => r.text().includes('PC-TRUST') && !r.text().includes('OFFLINE')) await trust.find('input[type="checkbox"]').setValue(true) await wrapper.find('.upgrade-selected').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11, 13] }) }) it('posts {"next":25} for Upgrade next 25 after a confirm naming the count', async () => { const confirmSpy = vi.fn(() => true) vi.stubGlobal('confirm', confirmSpy) const wrapper = await render() await wrapper.find('.upgrade-next').trigger('click') await flushPromises() expect(confirmSpy).toHaveBeenCalledTimes(1) expect(confirmSpy.mock.calls[0][0]).toMatch(/Upgrade 25 PC\(s\) to endpoint agent 1\.20\.0/) expect(confirmSpy.mock.calls[0][0]).toMatch(/enrolls automatically/) expect(confirmSpy.mock.calls[0][0]).toMatch( /older than 1\.20 must trust the server certificate before they can download the upgrade/) expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { next: 25 }) }) it('sends nothing when the wave confirm is declined', async () => { vi.stubGlobal('confirm', vi.fn(() => false)) const wrapper = await render() await wrapper.find('.upgrade-next').trigger('click') await flushPromises() expect(api.post).not.toHaveBeenCalled() }) it('posts the selected assetids for Upgrade selected', async () => { vi.stubGlobal('confirm', vi.fn(() => true)) const wrapper = await render() await wrapper.findAll('.migration-row')[0].find('input[type="checkbox"]').setValue(true) await wrapper.find('.upgrade-selected').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/migration/wave', { assetids: [11] }) }) it('approves a request with the matched asset by default', async () => { vi.stubGlobal('confirm', vi.fn(() => true)) const wrapper = await render() await openCredentials(wrapper) await wrapper.find('.approve-open').trigger('click') expect(wrapper.find('.asset-picker').element.value).toBe('77') await wrapper.find('.approve-confirm').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 }) }) // Approving a one-off PC onto a computer recorded under another hostname is // refused by the server, because the PC authenticates by hostname afterwards. // The page has to show that message and let the admin answer it, not swallow // it as "could not approve". function hostnameConflict() { return { response: { status: 409, data: { data: { error: { code: 'CONFLICT', message: 'That computer is recorded as PC-OLD, but the request comes from PC-NEW.', details: { hostnameconflict: true, computerhostname: 'PC-OLD', requesthostname: 'PC-NEW' } } } } } } } it('offers to set the hostname when approve comes back 409, then re-posts', async () => { const confirmSpy = vi.fn(() => true) vi.stubGlobal('confirm', confirmSpy) const wrapper = await render() await openCredentials(wrapper) api.post.mockReset() api.post.mockRejectedValueOnce(hostnameConflict()) api.post.mockResolvedValue({ data: { data: { hostnameresult: 'overwritten' } } }) await wrapper.find('.approve-open').trigger('click') await wrapper.find('.approve-confirm').trigger('click') await flushPromises() expect(api.post.mock.calls).toEqual([ ['/endpoints/enrollment/requests/45/approve', { assetid: 77 }], ['/endpoints/enrollment/requests/45/approve', { assetid: 77, sethostname: true }] ]) const asked = confirmSpy.mock.calls.at(-1)[0] expect(asked).toContain('recorded as PC-OLD') expect(asked).toContain('Set the hostname on this PC?') }) it('sends nothing more when the hostname question is declined', async () => { vi.stubGlobal('confirm', vi.fn().mockReturnValueOnce(true).mockReturnValueOnce(false)) const wrapper = await render() await openCredentials(wrapper) api.post.mockReset() api.post.mockRejectedValue(hostnameConflict()) await wrapper.find('.approve-open').trigger('click') await wrapper.find('.approve-confirm').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledTimes(1) expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/approve', { assetid: 77 }) }) it('rejects a request after confirm', async () => { vi.stubGlobal('confirm', vi.fn(() => true)) const wrapper = await render() await openCredentials(wrapper) await wrapper.find('.reject').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/requests/45/reject') }) it('un-enrolls through the revoke route', async () => { const wrapper = await render() await openCredentials(wrapper) await wrapper.find('.revoke').trigger('click') expect(api.post).not.toHaveBeenCalled() expect(wrapper.find('.revoke-row').text()).toContain('Keep this PC from enrolling again') expect(wrapper.find('.revoke-block').element.checked).toBe(false) await wrapper.find('.revoke-confirm').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke') }) it('sends blockreenroll with the reason when the block box is ticked', async () => { const wrapper = await render() await openCredentials(wrapper) await wrapper.find('.revoke').trigger('click') await wrapper.find('.revoke-block').setValue(true) await wrapper.find('.revoke-reason').setValue(' rollback ') await wrapper.find('.revoke-confirm').trigger('click') await flushPromises() expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke', { blockreenroll: true, reason: 'rollback' }) }) it('shows the block chip and allows re-enrollment after confirm', async () => { const confirmSpy = vi.fn(() => true) vi.stubGlobal('confirm', confirmSpy) const wrapper = await render() await openCredentials(wrapper) const rows = wrapper.findAll('.credential-row') const blocked = rows.find(r => r.text().includes('PC-ROLLEDBACK')) const enrolled = rows.find(r => r.text().includes('PC-ENROLLED')) expect(blocked.find('.block-chip').text()).toBe('Re-enroll blocked') expect(enrolled.find('.block-chip').exists()).toBe(false) expect(enrolled.find('.allow-reenroll').exists()).toBe(false) await blocked.find('.allow-reenroll').trigger('click') await flushPromises() expect(confirmSpy).toHaveBeenCalledTimes(1) expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/22/allow-reenroll') }) it('shows the not-enrolled count before turning the shared key off', async () => { const promptSpy = vi.fn(() => 'OFF') vi.stubGlobal('prompt', promptSpy) const wrapper = await render() await openCredentials(wrapper) await wrapper.find('.toggle-sharedkey').setValue(false) await flushPromises() expect(promptSpy).toHaveBeenCalledTimes(1) expect(promptSpy.mock.calls[0][0]).toMatch(/58 PC\(s\) are not enrolled yet/) expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings', { sharedkeyallowed: false, autoenroll: true }) }) it('leaves the shared key on when the confirmation is not typed', async () => { vi.stubGlobal('prompt', vi.fn(() => 'yes')) const wrapper = await render() await openCredentials(wrapper) const toggle = wrapper.find('.toggle-sharedkey') await toggle.setValue(false) await flushPromises() expect(api.put).not.toHaveBeenCalled() expect(toggle.element.checked).toBe(true) }) it('saves a new pending request limit', async () => { const wrapper = await render() await openCredentials(wrapper) const field = wrapper.find('.input-maxpending') expect(field.element.value).toBe('500') await field.setValue(25) await field.trigger('change') await flushPromises() expect(api.put).toHaveBeenCalledWith('/endpoints/enrollment/settings', { maxpendingenrollments: 25 }) }) // The negative case is proved server side (tests/test_plugins/ // test_endpoints_enroll_limits.py: PUT with -1 is a 400). Asserting it here // would only be asserting what vue-test-utils does to a number input. })