Import the client, server, skills, tests, and build scripts as the starting point for the rebuild, then trim the surfaces this project does not ship: Electron desktop packaging, the marketing website, and the ESP32 firmware tree. CI keeps the web build, e2e, lockfile, release, and Docker workflows. Rename the package and CLI entry points to hermes-ui, drop the prepare hook so npm ci no longer triggers a build, and drop the removed tsconfig and nodemon references. Rewrite AGENTS.md, ARCHITECTURE.md, DEVELOPMENT.md, and the READMEs so the documented package map and hard rules match this repository. reference_code/ is ignored: it is a read-only snapshot, not an input to the build.
644 lines
22 KiB
TypeScript
644 lines
22 KiB
TypeScript
import { createHash, generateKeyPairSync, sign } from 'crypto'
|
|
import { createServer, type Server } from 'http'
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type { LanDeviceInfo } from '../../packages/server/src/services/lan-discovery'
|
|
|
|
const keyPair = generateKeyPairSync('ed25519', {
|
|
publicKeyEncoding: { type: 'spki', format: 'pem' },
|
|
privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
|
|
})
|
|
const deviceId = `hwui_${createHash('sha256').update(keyPair.publicKey).digest('base64url').slice(0, 32)}`
|
|
|
|
const device: LanDeviceInfo = {
|
|
id: deviceId,
|
|
device_id: deviceId,
|
|
device_public_key: keyPair.publicKey,
|
|
computer_name: 'paired-device',
|
|
endpoint_kind: 'web',
|
|
ip: '192.168.1.20',
|
|
http_port: 8648,
|
|
url: 'http://192.168.1.20:8648',
|
|
os: {
|
|
type: 'Linux',
|
|
platform: 'linux',
|
|
release: '1',
|
|
arch: 'x64',
|
|
},
|
|
hermes_agent_version: 'v1',
|
|
hermes_web_ui_version: '1',
|
|
response_ms: 12,
|
|
last_seen_at: new Date().toISOString(),
|
|
}
|
|
|
|
describe('devices controller', () => {
|
|
let db: any = null
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules()
|
|
const { DatabaseSync } = await import('node:sqlite')
|
|
db = new DatabaseSync(':memory:')
|
|
vi.doMock('../../packages/server/src/db/index', () => ({
|
|
getDb: () => db,
|
|
getStoragePath: () => ':memory:',
|
|
}))
|
|
const { initAllHermesTables } = await import('../../packages/server/src/db/hermes/schemas')
|
|
initAllHermesTables()
|
|
})
|
|
|
|
afterEach(() => {
|
|
db?.close()
|
|
db = null
|
|
vi.unstubAllGlobals()
|
|
vi.doUnmock('../../packages/server/src/db/index')
|
|
vi.doUnmock('../../packages/server/src/services/login-limiter')
|
|
vi.resetModules()
|
|
})
|
|
|
|
it('returns the inbound pairing status for a signed device status request', async () => {
|
|
const { requestInboundDeviceLink, updateInboundStatus } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
requestInboundDeviceLink(device)
|
|
updateInboundStatus(device.id, 'approved')
|
|
|
|
const timestamp = Date.now()
|
|
const nonce = 'status-nonce-1'
|
|
const signature = sign(null, Buffer.from(`${device.id}.${nonce}.${timestamp}`), keyPair.privateKey).toString('base64url')
|
|
const ctx: any = {
|
|
request: {
|
|
body: {
|
|
device_id: device.id,
|
|
device_public_key: device.device_public_key,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
},
|
|
},
|
|
}
|
|
|
|
const { requestDeviceLinkStatusController } = await import('../../packages/server/src/controllers/devices')
|
|
await requestDeviceLinkStatusController(ctx)
|
|
|
|
expect(ctx.status).toBeUndefined()
|
|
expect(ctx.body).toEqual({ status: 'approved' })
|
|
})
|
|
|
|
it('keeps LAN pairing requests code-free and derives the device URL from the request source', async () => {
|
|
const timestamp = Date.now()
|
|
const nonce = 'lan-request-nonce-1'
|
|
const signature = sign(null, Buffer.from(`${device.id}.${nonce}.${timestamp}`), keyPair.privateKey).toString('base64url')
|
|
const ctx: any = {
|
|
ip: '192.168.1.99',
|
|
request: {
|
|
ip: '192.168.1.99',
|
|
body: {
|
|
...device,
|
|
http_port: 8648,
|
|
url: 'javascript:alert(1)',
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
},
|
|
},
|
|
}
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestDeviceLinkController } = await import('../../packages/server/src/controllers/devices')
|
|
await requestDeviceLinkController(ctx)
|
|
|
|
expect(ctx.status).toBeUndefined()
|
|
expect(ctx.body).toEqual({ status: 'pending' })
|
|
expect(getDeviceRelation(device.id)?.url).toBe('http://192.168.1.99:8648')
|
|
})
|
|
|
|
it('requires the startup pairing code before public requests enter the approval list', async () => {
|
|
const timestamp = Date.now()
|
|
const nonce = 'public-request-nonce-1'
|
|
const signature = sign(null, Buffer.from(`${device.id}.${nonce}.${timestamp}`), keyPair.privateKey).toString('base64url')
|
|
const ctx: any = {
|
|
ip: '8.8.8.8',
|
|
request: {
|
|
ip: '8.8.8.8',
|
|
body: {
|
|
...device,
|
|
http_port: 8648,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
},
|
|
},
|
|
}
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestDeviceLinkController } = await import('../../packages/server/src/controllers/devices')
|
|
await requestDeviceLinkController(ctx)
|
|
|
|
expect(ctx.status).toBe(403)
|
|
expect(ctx.body).toEqual({ error: 'Invalid pairing code' })
|
|
expect(getDeviceRelation(device.id)).toBeNull()
|
|
})
|
|
|
|
it('does not trust forged forwarded private IPs for public pairing requests', async () => {
|
|
const recordPairingFailure = vi.fn()
|
|
vi.doMock('../../packages/server/src/services/login-limiter', () => ({
|
|
checkPairing: (ip: string) => {
|
|
expect(ip).toBe('8.8.8.8')
|
|
return { allowed: true }
|
|
},
|
|
recordPairingFailure,
|
|
}))
|
|
|
|
const timestamp = Date.now()
|
|
const nonce = 'forged-forwarded-private-request-nonce-1'
|
|
const signature = sign(null, Buffer.from(`${device.id}.${nonce}.${timestamp}`), keyPair.privateKey).toString('base64url')
|
|
const ctx: any = {
|
|
ip: '8.8.8.8',
|
|
get: (name: string) => name.toLowerCase() === 'x-forwarded-for' ? '192.168.1.44' : '',
|
|
request: {
|
|
ip: '8.8.8.8',
|
|
body: {
|
|
...device,
|
|
http_port: 8648,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
},
|
|
},
|
|
}
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestDeviceLinkController } = await import('../../packages/server/src/controllers/devices')
|
|
await requestDeviceLinkController(ctx)
|
|
|
|
expect(ctx.status).toBe(403)
|
|
expect(ctx.body).toEqual({ error: 'Invalid pairing code' })
|
|
expect(recordPairingFailure).toHaveBeenCalledWith('8.8.8.8')
|
|
expect(getDeviceRelation(device.id)).toBeNull()
|
|
})
|
|
|
|
it('requires the startup pairing code for public clients forwarded through loopback proxies', async () => {
|
|
const timestamp = Date.now()
|
|
const nonce = 'forwarded-public-request-nonce-1'
|
|
const signature = sign(null, Buffer.from(`${device.id}.${nonce}.${timestamp}`), keyPair.privateKey).toString('base64url')
|
|
const ctx: any = {
|
|
ip: '127.0.0.1',
|
|
get: (name: string) => name.toLowerCase() === 'x-forwarded-for' ? '203.0.113.10' : '',
|
|
request: {
|
|
ip: '127.0.0.1',
|
|
body: {
|
|
...device,
|
|
http_port: 8648,
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
},
|
|
},
|
|
}
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestDeviceLinkController } = await import('../../packages/server/src/controllers/devices')
|
|
await requestDeviceLinkController(ctx)
|
|
|
|
expect(ctx.status).toBe(403)
|
|
expect(ctx.body).toEqual({ error: 'Invalid pairing code' })
|
|
expect(getDeviceRelation(device.id)).toBeNull()
|
|
})
|
|
|
|
it('stores public pairing requests as pending when the startup pairing code is valid', async () => {
|
|
vi.doMock('../../packages/server/src/services/device-pairing-code', () => ({
|
|
getDevicePairingCode: () => 'pair-secret',
|
|
verifyDevicePairingCode: (value: unknown) => value === 'pair-secret',
|
|
}))
|
|
const timestamp = Date.now()
|
|
const nonce = 'public-request-nonce-2'
|
|
const signature = sign(null, Buffer.from(`${device.id}.${nonce}.${timestamp}`), keyPair.privateKey).toString('base64url')
|
|
const ctx: any = {
|
|
ip: '8.8.4.4',
|
|
request: {
|
|
ip: '8.8.4.4',
|
|
body: {
|
|
...device,
|
|
http_port: 8648,
|
|
pairing_code: 'pair-secret',
|
|
timestamp,
|
|
nonce,
|
|
signature,
|
|
},
|
|
},
|
|
}
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestDeviceLinkController } = await import('../../packages/server/src/controllers/devices')
|
|
await requestDeviceLinkController(ctx)
|
|
|
|
expect(ctx.status).toBeUndefined()
|
|
expect(ctx.body).toEqual({ status: 'pending' })
|
|
expect(getDeviceRelation(device.id)?.inbound_status).toBe('pending')
|
|
expect(getDeviceRelation(device.id)?.url).toBe('http://8.8.4.4:8648')
|
|
})
|
|
|
|
it('prefers public request hosts when building copyable pairing links', async () => {
|
|
vi.doMock('../../packages/server/src/services/device-pairing-code', () => ({
|
|
getDevicePairingCode: () => 'pair-secret',
|
|
verifyDevicePairingCode: () => false,
|
|
}))
|
|
|
|
const { getDevicePairingLink } = await import('../../packages/server/src/controllers/devices')
|
|
const ctx: any = {
|
|
protocol: 'http',
|
|
host: 'studio.example.com',
|
|
get: (name: string) => name.toLowerCase() === 'x-forwarded-proto' ? 'https' : '',
|
|
}
|
|
|
|
await getDevicePairingLink(ctx)
|
|
|
|
expect(ctx.body).toEqual({
|
|
code: 'pair-secret',
|
|
link: 'https://studio.example.com/#/hermes/devices?pairing_code=pair-secret',
|
|
})
|
|
})
|
|
|
|
it('falls back from localhost to LAN addresses when building copyable pairing links', async () => {
|
|
vi.doMock('os', async () => {
|
|
const actual = await vi.importActual<typeof import('os')>('os')
|
|
return {
|
|
...actual,
|
|
networkInterfaces: () => ({
|
|
lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }],
|
|
en0: [{ family: 'IPv4', internal: false, address: '192.168.1.88' }],
|
|
}),
|
|
}
|
|
})
|
|
vi.doMock('../../packages/server/src/services/device-pairing-code', () => ({
|
|
getDevicePairingCode: () => 'pair-secret',
|
|
verifyDevicePairingCode: () => false,
|
|
}))
|
|
|
|
const { getDevicePairingLink } = await import('../../packages/server/src/controllers/devices')
|
|
const ctx: any = {
|
|
protocol: 'http',
|
|
host: 'localhost:8648',
|
|
get: () => '',
|
|
}
|
|
|
|
await getDevicePairingLink(ctx)
|
|
|
|
expect(ctx.body).toEqual({
|
|
code: 'pair-secret',
|
|
link: 'http://192.168.1.88:8648/#/hermes/devices?pairing_code=pair-secret',
|
|
})
|
|
})
|
|
|
|
it('rejects peer socket connections until outbound pairing is approved locally', async () => {
|
|
vi.doMock('../../packages/server/src/services/lan-discovery', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/lan-discovery')>(
|
|
'../../packages/server/src/services/lan-discovery',
|
|
)
|
|
return {
|
|
...actual,
|
|
getLanDiscoveryCache: () => ({
|
|
scanning: false,
|
|
last_scanned_at: new Date().toISOString(),
|
|
devices: [device],
|
|
}),
|
|
}
|
|
})
|
|
|
|
const { connectPeerDevice } = await import('../../packages/server/src/controllers/devices')
|
|
const ctx: any = {
|
|
params: { id: device.id },
|
|
request: { body: {} },
|
|
}
|
|
|
|
await connectPeerDevice(ctx)
|
|
|
|
expect(ctx.status).toBe(403)
|
|
expect(ctx.body).toEqual({ error: 'Device pairing has not been approved' })
|
|
})
|
|
|
|
it('records outbound status when requesting pairing from a device with inbound history', async () => {
|
|
vi.doMock('../../packages/server/src/services/lan-discovery', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/lan-discovery')>(
|
|
'../../packages/server/src/services/lan-discovery',
|
|
)
|
|
return {
|
|
...actual,
|
|
getLanDiscoveryCache: () => ({
|
|
scanning: false,
|
|
last_scanned_at: new Date().toISOString(),
|
|
devices: [device],
|
|
}),
|
|
}
|
|
})
|
|
vi.doMock('../../packages/server/src/services/system-info', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/system-info')>(
|
|
'../../packages/server/src/services/system-info',
|
|
)
|
|
return {
|
|
...actual,
|
|
getPublicSystemInfo: async () => ({
|
|
device_id: 'hwui_local',
|
|
device_public_key: keyPair.publicKey,
|
|
computer_name: 'local',
|
|
os: { type: 'TestOS', platform: 'linux', release: '1', arch: 'x64' },
|
|
hermes_agent_version: 'v1',
|
|
hermes_web_ui_version: '1',
|
|
}),
|
|
createDeviceSignature: async () => 'signature',
|
|
}
|
|
})
|
|
|
|
const fetchMock = vi.fn(async () => ({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ status: 'pending' }),
|
|
}))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const { requestInboundDeviceLink, updateInboundStatus, getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
requestInboundDeviceLink(device)
|
|
updateInboundStatus(device.id, 'approved')
|
|
|
|
const { requestDevicePairing } = await import('../../packages/server/src/controllers/devices')
|
|
const ctx: any = {
|
|
params: { id: device.id },
|
|
request: { body: {} },
|
|
}
|
|
|
|
await requestDevicePairing(ctx)
|
|
|
|
const relation = getDeviceRelation(device.id)
|
|
expect(ctx.status).toBeUndefined()
|
|
expect(relation?.inbound_status).toBe('approved')
|
|
expect(relation?.outbound_status).toBe('pending')
|
|
expect(relation?.outbound_requested_at).toBeGreaterThan(0)
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'http://192.168.1.20:8648/api/devices/link-request',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
})
|
|
|
|
it('falls back to the native HTTP client when fetch cannot reach a LAN peer', async () => {
|
|
let receivedRequest: any = null
|
|
let server: Server | null = null
|
|
|
|
await new Promise<void>((resolve) => {
|
|
server = createServer((req, res) => {
|
|
const chunks: Buffer[] = []
|
|
req.on('data', chunk => chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)))
|
|
req.on('end', () => {
|
|
if (req.url === '/api/devices/link-request') {
|
|
receivedRequest = JSON.parse(Buffer.concat(chunks).toString('utf-8'))
|
|
}
|
|
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
res.end(JSON.stringify({ status: 'pending' }))
|
|
})
|
|
})
|
|
server.listen(0, '127.0.0.1', resolve)
|
|
})
|
|
|
|
const address = server!.address()
|
|
const port = typeof address === 'object' && address ? address.port : 0
|
|
const fallbackDevice: LanDeviceInfo = {
|
|
...device,
|
|
ip: '127.0.0.1',
|
|
http_port: port,
|
|
url: `http://127.0.0.1:${port}`,
|
|
}
|
|
|
|
try {
|
|
vi.doMock('../../packages/server/src/services/lan-discovery', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/lan-discovery')>(
|
|
'../../packages/server/src/services/lan-discovery',
|
|
)
|
|
return {
|
|
...actual,
|
|
getLanDiscoveryCache: () => ({
|
|
scanning: false,
|
|
last_scanned_at: new Date().toISOString(),
|
|
devices: [fallbackDevice],
|
|
}),
|
|
}
|
|
})
|
|
vi.doMock('../../packages/server/src/services/system-info', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/system-info')>(
|
|
'../../packages/server/src/services/system-info',
|
|
)
|
|
return {
|
|
...actual,
|
|
getPublicSystemInfo: async () => ({
|
|
device_id: 'hwui_local',
|
|
device_public_key: keyPair.publicKey,
|
|
computer_name: 'local',
|
|
os: { type: 'TestOS', platform: 'darwin', release: '1', arch: 'arm64' },
|
|
hermes_agent_version: 'v1',
|
|
hermes_web_ui_version: '1',
|
|
}),
|
|
createDeviceSignature: async () => 'signature',
|
|
}
|
|
})
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw Object.assign(new Error(`connect EHOSTUNREACH ${fallbackDevice.ip}:${fallbackDevice.http_port}`), {
|
|
code: 'EHOSTUNREACH',
|
|
syscall: 'connect',
|
|
address: fallbackDevice.ip,
|
|
port: fallbackDevice.http_port,
|
|
})
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestDevicePairing } = await import('../../packages/server/src/controllers/devices')
|
|
const ctx: any = {
|
|
params: { id: fallbackDevice.id },
|
|
request: { body: {} },
|
|
}
|
|
|
|
await requestDevicePairing(ctx)
|
|
|
|
const relation = getDeviceRelation(fallbackDevice.id)
|
|
expect(ctx.status).toBeUndefined()
|
|
expect(relation?.outbound_status).toBe('pending')
|
|
expect(receivedRequest).toEqual(expect.objectContaining({
|
|
device_id: 'hwui_local',
|
|
signature: 'signature',
|
|
}))
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
`http://127.0.0.1:${port}/api/devices/link-request`,
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
} finally {
|
|
await new Promise<void>((resolve, reject) => {
|
|
server!.close(err => err ? reject(err) : resolve())
|
|
})
|
|
}
|
|
})
|
|
|
|
it('requests pairing from a manually entered remote URL', async () => {
|
|
vi.doMock('../../packages/server/src/services/lan-discovery', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/lan-discovery')>(
|
|
'../../packages/server/src/services/lan-discovery',
|
|
)
|
|
return {
|
|
...actual,
|
|
getLanDiscoveryCache: () => ({
|
|
scanning: false,
|
|
last_scanned_at: new Date().toISOString(),
|
|
devices: [],
|
|
}),
|
|
}
|
|
})
|
|
vi.doMock('../../packages/server/src/services/system-info', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/system-info')>(
|
|
'../../packages/server/src/services/system-info',
|
|
)
|
|
return {
|
|
...actual,
|
|
getPublicSystemInfo: async () => ({
|
|
device_id: 'hwui_local',
|
|
device_public_key: keyPair.publicKey,
|
|
computer_name: 'local',
|
|
os: { type: 'TestOS', platform: 'linux', release: '1', arch: 'x64' },
|
|
hermes_agent_version: 'v1',
|
|
hermes_web_ui_version: '1',
|
|
}),
|
|
createDeviceSignature: async () => 'signature',
|
|
}
|
|
})
|
|
|
|
const fetchMock = vi.fn(async (url: string, options?: RequestInit) => {
|
|
if (url === 'https://remote.example.com/api/devices/link-info') {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
device_id: device.id,
|
|
device_public_key: device.device_public_key,
|
|
computer_name: 'remote-device',
|
|
endpoint_kind: 'web',
|
|
http_port: 443,
|
|
os: { type: 'Linux', platform: 'linux', release: '1', arch: 'x64' },
|
|
hermes_agent_version: 'v1',
|
|
hermes_web_ui_version: '1',
|
|
}),
|
|
}
|
|
}
|
|
if (url === 'https://remote.example.com/api/devices/link-request' && options?.method === 'POST') {
|
|
expect(JSON.parse(String(options.body))).toEqual(expect.objectContaining({
|
|
pairing_code: 'pair-secret',
|
|
}))
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ status: 'pending' }),
|
|
}
|
|
}
|
|
if (url === 'https://remote.example.com/api/devices/link-status' && options?.method === 'POST') {
|
|
return {
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ status: 'pending' }),
|
|
}
|
|
}
|
|
throw new Error(`unexpected fetch: ${url}`)
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const { getDeviceRelation } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { requestManualDevicePairing } = await import('../../packages/server/src/controllers/devices')
|
|
const ctx: any = {
|
|
request: {
|
|
body: {
|
|
url: 'https://remote.example.com/#/hermes/devices?pairing_code=pair-secret',
|
|
},
|
|
},
|
|
}
|
|
|
|
await requestManualDevicePairing(ctx)
|
|
|
|
const relation = getDeviceRelation(device.id)
|
|
expect(ctx.status).toBeUndefined()
|
|
expect(relation?.outbound_status).toBe('pending')
|
|
expect(relation?.url).toBe('https://remote.example.com')
|
|
expect(ctx.body.devices).toEqual([
|
|
expect.objectContaining({
|
|
id: device.id,
|
|
computer_name: 'remote-device',
|
|
online: true,
|
|
outbound_status: 'pending',
|
|
url: 'https://remote.example.com',
|
|
}),
|
|
])
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://remote.example.com/api/devices/link-info',
|
|
expect.objectContaining({ method: 'GET' }),
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://remote.example.com/api/devices/link-request',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
})
|
|
|
|
it('keeps a manually paired remote device in the list when it is offline', async () => {
|
|
vi.doMock('../../packages/server/src/services/lan-discovery', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/lan-discovery')>(
|
|
'../../packages/server/src/services/lan-discovery',
|
|
)
|
|
return {
|
|
...actual,
|
|
getLanDiscoveryCache: () => ({
|
|
scanning: false,
|
|
last_scanned_at: new Date().toISOString(),
|
|
devices: [],
|
|
}),
|
|
}
|
|
})
|
|
vi.doMock('../../packages/server/src/services/system-info', async () => {
|
|
const actual = await vi.importActual<typeof import('../../packages/server/src/services/system-info')>(
|
|
'../../packages/server/src/services/system-info',
|
|
)
|
|
return {
|
|
...actual,
|
|
getPublicSystemInfo: async () => ({
|
|
device_id: 'hwui_local',
|
|
device_public_key: keyPair.publicKey,
|
|
computer_name: 'local',
|
|
os: { type: 'TestOS', platform: 'linux', release: '1', arch: 'x64' },
|
|
hermes_agent_version: 'v1',
|
|
hermes_web_ui_version: '1',
|
|
}),
|
|
createDeviceSignature: async () => 'signature',
|
|
}
|
|
})
|
|
|
|
const fetchMock = vi.fn(async () => {
|
|
throw new Error('offline')
|
|
})
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
const { updateOutboundStatus } = await import('../../packages/server/src/db/hermes/devices-store')
|
|
const { listDevices } = await import('../../packages/server/src/controllers/devices')
|
|
updateOutboundStatus(device.id, 'approved', {
|
|
...device,
|
|
ip: 'remote.example.com',
|
|
http_port: 443,
|
|
url: 'https://remote.example.com',
|
|
})
|
|
|
|
const ctx: any = {}
|
|
await listDevices(ctx)
|
|
|
|
expect(ctx.body.devices).toEqual([
|
|
expect.objectContaining({
|
|
id: device.id,
|
|
online: false,
|
|
outbound_status: 'approved',
|
|
url: 'https://remote.example.com',
|
|
}),
|
|
])
|
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
'https://remote.example.com/api/devices/link-status',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
})
|
|
})
|