Files
shopdb-flask/plugins/endpoints/frontend/views/agentBootstrapInstall.spec.js
cproudlock 2557d1967d
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
Make the one-line install survive Defender, and approval leave a PC that works
All three found testing the bootstrap installer on Windows.

- The published command used the irm-pipe-iex form, which Microsoft Defender
  detects as Trojan:Win32/Commando.A!ml and kills before it runs, every time,
  on a default install. The command now downloads the script to a file, runs
  it, and removes it in a finally block, which Defender does not object to.
  The UI also offers the script as a plain download for a tech who would
  rather save it.
- Approving a request only linked the asset, so a PC whose pending reason was
  "unknown hostname" - which is every PC installed this way - kept getting 403
  on every call afterwards, because its computer record had no hostname to
  match the credential against. Approval now stamps the hostname when the
  record has none, refuses with a 409 naming both when the record has a
  different one, and overwrites only when the admin confirms. The Credentials
  tab offers that confirmation.
- A hostname that resolves to no record now says so instead of reading as an
  impostor, while still refusing the request.
- While waiting for approval the agent retried on the 15-minute telemetry
  cadence, so a PC approved immediately enrolled a quarter of an hour later
  and the installer always ended on "still pending". It now retries about
  every minute for the first ten minutes, then every five to thirty minutes,
  then every fifteen, with jitter. A rate limit or server error still uses the
  slower backoff.
2026-09-15 20:46:04 -04:00

211 lines
7.8 KiB
JavaScript

/**
* One-line install block on the endpoints agent key page.
*
* The token is shown exactly once, so the two things worth pinning are that the
* command rendered is the one the server returned, and that reloading the page
* does NOT put a token back on screen - the list read carries no secret, and a
* field that repopulated from it would be lying about what is recoverable.
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mount, flushPromises, RouterLinkStub } from '@vue/test-utils'
vi.mock('@/api', () => ({
default: { get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn() }
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
import api from '@/api'
import AgentKeySettings from './AgentKeySettings.vue'
const TOKENS_URL = '/endpoints/siteagent/bootstrap/tokens'
const SECRET = 'sdbb_0123456789abcdefghijklmnopqrstuvwxyzABCDEFG'
const SCRIPT_URL =
`https://shopdb.test/shopdb/api/endpoints/siteagent/bootstrap.ps1?t=${SECRET}`
// Download then run: Defender kills the `irm ... | iex` shape before it runs.
const TEMP = 'C:\\Windows\\Temp\\shopdb-bootstrap.ps1'
const COMMAND =
'powershell -ExecutionPolicy Bypass -c "try { Invoke-WebRequest '
+ `-UseBasicParsing '${SCRIPT_URL}' -OutFile '${TEMP}'; & '${TEMP}' }`
+ ` finally { Remove-Item '${TEMP}' -Force -ErrorAction SilentlyContinue }"`
const MINTED_TOKEN = {
tokenid: 7,
tokenprefix: 'sdbb_012345',
createdat: '2026-09-15T10:00:00Z',
expiresat: '2026-09-16T10:00:00Z',
maxuses: 25,
usecount: 0,
remaining: 25,
revokedat: null,
state: 'active'
}
function listResponse(tokens) {
return { data: { data: { tokens } } }
}
async function render(tokens = []) {
api.get.mockImplementation((path) => {
if (path === TOKENS_URL) return Promise.resolve(listResponse(tokens))
if (path === '/endpoints/agentkey') {
return Promise.resolve({ data: { data: { key: null, envkeyfallback: false } } })
}
return Promise.resolve({ data: { data: {} } })
})
const wrapper = mount(AgentKeySettings, {
global: { stubs: { RouterLink: RouterLinkStub } }
})
await flushPromises()
return wrapper
}
function generateButton(wrapper) {
return wrapper.findAll('button')
.find((button) => button.text() === 'Generate install command')
}
describe('AgentKeySettings one-line install', () => {
beforeEach(() => {
api.get.mockReset()
api.post.mockReset()
api.delete.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('says the PC waits for approval and that managed bays use GE-Enforce', async () => {
const wrapper = await render()
const block = wrapper.find('.bootstrap-block').text()
expect(block).toContain('One-line install')
expect(block).toMatch(/waits for approval in\s+Settings > Endpoints > Enrollment/)
expect(block).toContain('GE-Enforce')
expect(block).toContain('elevated')
})
it('generate posts the hours and maximum uses', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await wrapper.find('#bootstrap-hours').setValue(48)
await wrapper.find('#bootstrap-maxuses').setValue(5)
await generateButton(wrapper).trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 48, maxuses: 5 })
})
it('defaults to 24 hours and 25 uses', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('#bootstrap-hours').element.value).toBe('24')
expect(wrapper.find('#bootstrap-maxuses').element.value).toBe('25')
await generateButton(wrapper).trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(TOKENS_URL, { hours: 24, maxuses: 25 })
})
it('renders the returned command, the once-only warning and the expiry', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await generateButton(wrapper).trigger('click')
await flushPromises()
const field = wrapper.find('#bootstrap-command')
expect(field.exists()).toBe(true)
expect(field.element.value).toBe(COMMAND)
expect(field.element.value).toContain(SECRET)
expect(field.attributes('readonly')).toBeDefined()
const block = wrapper.find('.bootstrap-block').text()
expect(block).toContain('The token is shown once and cannot be recovered')
expect(block).toContain('25 of')
expect(block).toContain('uses remaining')
})
it('offers the same token as a Download .ps1 link', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
expect(wrapper.find('.bootstrap-download').exists()).toBe(false)
await generateButton(wrapper).trigger('click')
await flushPromises()
const link = wrapper.find('.bootstrap-download')
expect(link.attributes('href')).toBe(SCRIPT_URL)
expect(link.attributes('download')).toBe('shopdb-bootstrap.ps1')
expect(wrapper.find('#bootstrap-command').element.value).not.toContain(' | iex')
})
it('copies the command to the clipboard', async () => {
const writeText = vi.fn(() => Promise.resolve())
vi.stubGlobal('navigator', { clipboard: { writeText } })
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const wrapper = await render()
await generateButton(wrapper).trigger('click')
await flushPromises()
const copy = wrapper.findAll('button').find((button) => button.text() === 'Copy')
await copy.trigger('click')
await flushPromises()
expect(writeText).toHaveBeenCalledWith(COMMAND)
expect(wrapper.find('.bootstrap-block').text()).toContain('Copied')
})
it('lists live tokens and revoke posts to that token', async () => {
api.post.mockResolvedValue({ data: { data: { token: MINTED_TOKEN } } })
const wrapper = await render([MINTED_TOKEN])
expect(api.get).toHaveBeenCalledWith(TOKENS_URL)
const rows = wrapper.findAll('.bootstrap-block tbody tr')
expect(rows).toHaveLength(1)
expect(rows[0].text()).toContain('sdbb_012345')
expect(rows[0].text()).toContain('0 / 25')
const revoke = wrapper.findAll('button').find((button) => button.text() === 'Revoke')
await revoke.trigger('click')
await flushPromises()
expect(api.post).toHaveBeenCalledWith(`${TOKENS_URL}/7/revoke`)
})
it('offers no Revoke on a token that is already spent', async () => {
const wrapper = await render([
{ ...MINTED_TOKEN, state: 'revoked', usecount: 2 }
])
expect(wrapper.findAll('button').find((button) => button.text() === 'Revoke'))
.toBeUndefined()
})
it('does not show the secret again after a reload', async () => {
api.post.mockResolvedValue({
data: { data: { secret: SECRET, command: COMMAND, scripturl: SCRIPT_URL, token: MINTED_TOKEN } }
})
const first = await render()
await generateButton(first).trigger('click')
await flushPromises()
expect(first.find('#bootstrap-command').exists()).toBe(true)
// The reload sees the token in the list read, which carries no secret.
const reloaded = await render([MINTED_TOKEN])
expect(reloaded.find('#bootstrap-command').exists()).toBe(false)
expect(reloaded.text()).not.toContain(SECRET)
expect(reloaded.text()).not.toContain('The token is shown once')
// The row is still there, by prefix only.
expect(reloaded.find('.bootstrap-block tbody tr').text()).toContain('sdbb_012345')
})
})