Files
shopdb-flask/plugins/endpoints/frontend/views/enrollmentSettings.spec.js
cproudlock 066a19052b
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
Act on the wj architectural review: races, limits, pool and contract gaps
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from
wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3
(the warranty sync holding a pooled connection) are being handled separately
and are untouched here.

- A collector check-in that raced another for the same PC lost everything:
  the unique index on the application fingerprint raised, nothing caught it,
  and the whole check-in rolled back, discarding state, events and hardware,
  answering the agent with form copy meant for a person. The insert now runs
  in a savepoint, reconciles what the other transaction already wrote, one row
  per savepoint so a second collision skips a row rather than the batch, and
  the check-in commits with a warning. This is the shape the delete side of
  the same function already had.
- Enrollment is deliberately unauthenticated, so it now carries a per-address
  rate limit and a cap on pending requests, and the cap is a setting an admin
  can see and change. A flood can no longer bury the approval queue or grow
  the table without bound.
- The fleet alert poll no longer formats debug strings on every request.
- BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin
  stops reaching into a core table with raw SQL.
- A test walks every asset type descriptor and proves the endpoint it names
  really refuses a caller without the declared permission.
- The plugin settings routes now need a permission; they were telling anyone
  which modules a site runs and where its components live.
- The connection pool is sized explicitly with a short timeout, and the
  arithmetic against the server's connection ceiling is written next to the
  worker count instead of being left to defaults nobody chose.
- Fleet alerts carry both the correct lowercase keys and the old camelCase
  pair. Trays older than 1.7.0 match keys case-sensitively and would have gone
  quiet on every PC not yet upgraded; the old pair goes when they are.
- The dev server proxy points at localhost again, with environment variables
  for the remote-box workflow, and one developer's address is out of the
  server certificate page.
2026-09-16 08:57:04 -04:00

346 lines
14 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'] },
{ 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.
})