Files
adam-acs/tests/unit/services/device_service.spec.ts
T
kevin.lhereec 8cddf98093
Build & Deploy / build (push) Successful in 30s
Build & Deploy / docker (push) Successful in 1m6s
Build & Deploy / deploy (push) Failing after 33s
fix: CSRF token sur les requetes mutantes /internal + IPv6 Docker + tests Connection Request
- Ajout de fetchWithCsrf() helper qui lit le cookie XSRF-TOKEN et injecte
  X-XSRF-TOKEN sur toutes les requetes POST/PUT/PATCH/DELETE vers /internal/*
- Remplacement de fetch() par fetchWithCsrf() dans tous les composants Vue
  concernes (Tr069Trace, DeviceDetail, SequenceViewer, Home, configurations,
  firmware_catalog)
- Activation IPv6 sur les reseaux Docker (enable_ipv6: true, subnet fd00:adam::/64)
  dans docker-compose.yml et docker-compose.prod.yml
- Ajout tests unitaires DeviceService.sendWakeOnWlan : IPv4, IPv6, ENETUNREACH,
  credentials fallback chain (11 tests)
2026-07-17 10:16:29 +02:00

265 lines
10 KiB
TypeScript

import { describe, it, expect, beforeEach, vi, afterEach } from 'vitest'
import DeviceService from '../../../app/services/device_service.js'
import type { DeviceRecord } from '../../../app/domain/device/device_record.js'
// ── Mocks ──────────────────────────────────────────────────────────────────────
vi.mock('@adonisjs/core/services/logger', () => ({
default: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
}))
vi.mock('../../../app/models/device.js', () => ({
default: {
findByIdentifier: vi.fn(),
paginateDevices: vi.fn(),
deleteOne: vi.fn(),
},
}))
vi.mock('../../../app/models/command.js', () => ({
default: {
createCommand: vi.fn(),
getCollection: vi.fn(() => ({
findOne: vi.fn().mockResolvedValue(null),
find: vi.fn(() => ({ toArray: vi.fn().mockResolvedValue([]) })),
updateOne: vi.fn(),
})),
},
}))
// ── Helpers ────────────────────────────────────────────────────────────────────
function makeDevice(overrides: Partial<DeviceRecord> = {}): DeviceRecord {
return {
identifier: 'TestMfr-AABBCC-TestBox-SN001',
deviceId: { manufacturer: 'TestMfr', oui: 'AABBCC', productClass: 'TestBox', serialNumber: 'SN001' },
properties: [],
parameters: [],
...overrides,
} as unknown as DeviceRecord
}
function makeProperty(name: string, value: string) {
return { name, value }
}
// ── Tests ──────────────────────────────────────────────────────────────────────
describe('DeviceService — sendWakeOnWlan / Connection Request', () => {
let service: DeviceService
let fetchMock: ReturnType<typeof vi.fn>
beforeEach(() => {
service = new DeviceService()
fetchMock = vi.fn()
vi.stubGlobal('fetch', fetchMock)
})
afterEach(() => {
vi.unstubAllGlobals()
vi.clearAllMocks()
})
// ── IPv4 ───────────────────────────────────────────────────────────────────
describe('IPv4 Connection Request', () => {
it('retourne true quand le CPE répond 200 sur une URL IPv4', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://192.168.1.100:7547/cr'),
makeProperty('Device.ManagementServer.Username', 'admin'),
makeProperty('Device.ManagementServer.Password', 'secret'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
const result = await (service as any).sendWakeOnWlan(
new URL('http://192.168.1.100:7547/cr'),
device
)
expect(result).toBe(true)
expect(fetchMock).toHaveBeenCalledOnce()
const [calledUrl] = fetchMock.mock.calls[0]
expect(calledUrl).toBe('http://192.168.1.100:7547/cr')
})
it('retourne false et log error quand le CPE répond 401', async () => {
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/')],
})
fetchMock.mockResolvedValue({ ok: false, status: 401, statusText: 'Unauthorized' })
const result = await (service as any).sendWakeOnWlan(
new URL('http://10.0.0.1:7547/'),
device
)
expect(result).toBe(false)
})
it('retourne false quand fetch lève une erreur réseau IPv4', async () => {
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://10.0.0.1:7547/')],
})
const networkError = Object.assign(new Error('fetch failed'), { cause: { code: 'ECONNREFUSED' } })
fetchMock.mockRejectedValue(networkError)
const result = await (service as any).sendWakeOnWlan(
new URL('http://10.0.0.1:7547/'),
device
)
expect(result).toBe(false)
})
})
// ── IPv6 ───────────────────────────────────────────────────────────────────
describe('IPv6 Connection Request', () => {
it('retourne true quand le CPE répond 200 sur une URL IPv6', async () => {
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/kOmisavuzLYxpksz'
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url),
makeProperty('Device.ManagementServer.Username', 'user'),
makeProperty('Device.ManagementServer.Password', 'pass'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(result).toBe(true)
expect(fetchMock).toHaveBeenCalledOnce()
const [calledUrl, init] = fetchMock.mock.calls[0]
// L'URL doit conserver les crochets IPv6
expect(calledUrl).toBe(ipv6Url)
// Le header Authorization doit être présent
expect(init.headers?.Authorization).toMatch(/^Basic /)
})
it('envoie la bonne URL IPv6 complète avec path et port', async () => {
const ipv6Url = 'http://[::1]:8080/wake'
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
})
fetchMock.mockResolvedValue({ ok: true, status: 204 })
await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(fetchMock.mock.calls[0][0]).toBe('http://[::1]:8080/wake')
})
it('retourne false et ENETUNREACH est loggé quand l\'IPv6 n\'est pas joignable', async () => {
const ipv6Url = 'http://[2a01:cb15:890f:4a00:56ec:b0ff:fe9c:e490]:50895/cr'
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
})
const networkError = Object.assign(new Error('fetch failed'), {
cause: { code: 'ENETUNREACH' },
})
fetchMock.mockRejectedValue(networkError)
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(result).toBe(false)
})
it('retourne false quand le CPE IPv6 répond 403', async () => {
const ipv6Url = 'http://[fe80::1]:7547/cr'
const device = makeDevice({
properties: [makeProperty('Device.ManagementServer.ConnectionRequestURL', ipv6Url)],
})
fetchMock.mockResolvedValue({ ok: false, status: 403, statusText: 'Forbidden' })
const result = await (service as any).sendWakeOnWlan(new URL(ipv6Url), device)
expect(result).toBe(false)
})
})
// ── Credentials fallback chain ─────────────────────────────────────────────
describe('Construction des credentials (Basic Auth)', () => {
it('utilise ManagementServer.Username/Password en priorité', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
makeProperty('Device.ManagementServer.Username', 'mgmtUser'),
makeProperty('Device.ManagementServer.Password', 'mgmtPass'),
makeProperty('Device.ManagementServer.ConnectionRequestUsername', 'crUser'),
makeProperty('Device.ManagementServer.ConnectionRequestPassword', 'crPass'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
expect(decoded).toBe('mgmtUser:mgmtPass')
})
it('utilise ConnectionRequestUsername/Password si ManagementServer absent', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
makeProperty('Device.ManagementServer.ConnectionRequestUsername', 'crUser'),
makeProperty('Device.ManagementServer.ConnectionRequestPassword', 'crPass'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
expect(decoded).toBe('crUser:crPass')
})
it('utilise OUI-ProductClass-SerialNumber comme username par défaut', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
// format: OUI-ProductClass-SerialNumber:
expect(decoded).toBe('AABBCC-TestBox-SN001:')
})
it('fonctionne avec un password vide', async () => {
const device = makeDevice({
properties: [
makeProperty('Device.ManagementServer.ConnectionRequestURL', 'http://1.2.3.4:7547/'),
makeProperty('Device.ManagementServer.Username', 'admin'),
],
})
fetchMock.mockResolvedValue({ ok: true, status: 200 })
await (service as any).sendWakeOnWlan(new URL('http://1.2.3.4:7547/'), device)
const authHeader: string = fetchMock.mock.calls[0][1].headers.Authorization
const decoded = Buffer.from(authHeader.replace('Basic ', ''), 'base64').toString('utf-8')
expect(decoded).toBe('admin:')
})
})
})