Files
shopdb-flask/plugins/endpoints/frontend/views/enrollmentSettings.spec.js
cproudlock 8b144a17bb
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Add the enrollment page, move scripts to per-PC credentials, and document the rollout
- Settings > Endpoints > Enrollment. Migration tab: readiness counts and
  progress, each PC's state with the reasons it is blocked, "Upgrade
  selected" and "Upgrade next 25", and live wave progress. Credentials tab:
  enrolled PCs with revoke, pending requests with approve and reject, and the
  shared key and auto-enroll switches. Turning the shared key off asks for
  confirmation and shows how many PCs are not enrolled.
- The server certificate page shows the SPKI pin to put in the GE-Enforce
  pin entry.
- The agent upgrade script and the asset and printer reporters send the PC's
  credential when it has one (over https, only to the issuing server), fall
  back to the shared key, check server pins, and log refusals clearly.
- New Set-ShopDBServerPins.ps1 (GE-Enforce entry) and Get-ShopDBServerPin.ps1.
- AGENT-CONTRACT.md documents per-PC credentials; the rollout runbook covers
  phases, gates, rollback, pin and wave entries, and every place the shared
  key lives and how to clear it at cutover.
- Removed the 09-14 certificate design notes and dated session reports, and
  two VM notes that carried a live collector key and advised plain http.
- Corrected the GE-Enforce collector note, which claimed a plain registry
  token is protected by ACL.
2026-09-15 12:04:12 -04:00

201 lines
7.7 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' }
],
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(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 () => {
vi.stubGlobal('confirm', vi.fn(() => true))
const wrapper = await render()
await openCredentials(wrapper)
await wrapper.find('.revoke').trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith('/endpoints/enrollment/assets/21/revoke')
})
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)
})
})