Found in an end-to-end test on a Windows 11 VM against a self-signed server.
- Rollback: a 1.20 agent re-enrolls within a second of an un-enroll, so an
older agent pushed afterwards arrived on an enrolled PC and was refused.
Revoke now takes {"blockreenroll": true, "reason": ...}; a blocked PC is
never issued a credential and keeps working on the shared key until
POST .../assets/<assetid>/allow-reenroll. The Enrollment page offers the
block when un-enrolling and shows blocked PCs with an allow button.
- First upgrade of old agents: agents before 1.20 check the server
certificate when downloading the upgrade, so a store wave failed on PCs that
do not trust a self-signed server while the Migration tab said ready. A
failed download now marks the PC blocked with "server certificate not
trusted by this PC". New Install-ShopDBServerCertificate.ps1 (GE-Enforce,
SYSTEM) adds a non-CA server certificate to the trusted roots after checking
its thumbprint; the server certificate page offers a .cer download and the
SHA-1 thumbprint.
- The generated installer writes ServerPins (with an optional next pin for a
planned rotation), can leave the shared key out so the PC waits for
approval, never touches an existing credential, and skips the trusted-root
import for a CA certificate.
- The agent re-reads server pins about every 60 seconds and recycles pooled
connections within 2 minutes, so a pin change needs no restart.
- An alert can only be acknowledged by a PC it targets.
- A wave no longer counts a PC as failed after a later wave upgraded it.
- The scripts report a pin mismatch only when the pin really is missing, and
otherwise log the real error.
- Runbook: trust step before the first wave, rollback order, pin re-read,
verifying the pin against the live URL; agent contract updated.
239 lines
9.8 KiB
JavaScript
239 lines
9.8 KiB
JavaScript
/**
|
|
* 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: '<div />' }
|
|
}))
|
|
|
|
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'] }
|
|
],
|
|
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 },
|
|
counts: { enrolled: 40, notenrolled: 58, 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('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 })
|
|
})
|
|
|
|
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)
|
|
})
|
|
})
|