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 { 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 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:') }) }) })