diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 488f33d..7f384cf 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -38,3 +38,9 @@ volumes: networks: databases_default: external: true + default: + enable_ipv6: true + ipam: + config: + - subnet: "172.28.0.0/16" + - subnet: "fd00:adam::/64" diff --git a/docker-compose.yml b/docker-compose.yml index 97a5b0c..32a165e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,3 +55,11 @@ volumes: mongo_data: app_tr069_data: app_tr069_logs: + +networks: + default: + enable_ipv6: true + ipam: + config: + - subnet: "172.28.0.0/16" + - subnet: "fd00:adam::/64" diff --git a/inertia/components/SequenceViewer.vue b/inertia/components/SequenceViewer.vue index 7cd59ee..921914e 100644 --- a/inertia/components/SequenceViewer.vue +++ b/inertia/components/SequenceViewer.vue @@ -2,6 +2,7 @@ import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue' import { useI18n } from 'vue-i18n' import { formatTimestamp, truncateMethod, needsTruncation, formatTraceStartTime } from '../helpers/sequence_viewer_helpers' +import { fetchWithCsrf } from '../helpers/fetch_with_csrf' const { t } = useI18n() @@ -270,7 +271,7 @@ async function confirmClear() { isClearing.value = true clearError.value = '' try { - const response = await fetch( + const response = await fetchWithCsrf( `/internal/tr069-trace/${encodeURIComponent(props.deviceId)}/exchanges`, { method: 'DELETE' } ) diff --git a/inertia/helpers/fetch_with_csrf.ts b/inertia/helpers/fetch_with_csrf.ts new file mode 100644 index 0000000..d084461 --- /dev/null +++ b/inertia/helpers/fetch_with_csrf.ts @@ -0,0 +1,38 @@ +/** + * Reads the XSRF-TOKEN cookie set by AdonisJS Shield and returns its decoded value. + * Returns an empty string if the cookie is not present. + */ +function getCsrfToken(): string { + const match = document.cookie + .split('; ') + .find((row) => row.startsWith('XSRF-TOKEN=')) + if (!match) return '' + return decodeURIComponent(match.split('=').slice(1).join('=')) +} + +/** + * Wrapper around the native fetch() that automatically adds the + * X-XSRF-TOKEN header for state-mutating requests (POST, PUT, PATCH, DELETE) + * to satisfy AdonisJS Shield CSRF protection on /internal/* routes. + * + * Usage: drop-in replacement for fetch() — same signature. + */ +export function fetchWithCsrf(input: RequestInfo | URL, init: RequestInit = {}): Promise { + const method = (init.method ?? 'GET').toUpperCase() + const mutating = ['POST', 'PUT', 'PATCH', 'DELETE'].includes(method) + + if (mutating) { + const token = getCsrfToken() + if (token) { + init = { + ...init, + headers: { + ...init.headers, + 'X-XSRF-TOKEN': token, + }, + } + } + } + + return fetch(input, init) +} diff --git a/inertia/pages/DeviceDetail.vue b/inertia/pages/DeviceDetail.vue index c54f50d..4dc9a32 100644 --- a/inertia/pages/DeviceDetail.vue +++ b/inertia/pages/DeviceDetail.vue @@ -3,6 +3,7 @@ import { ref } from 'vue' import { router, Link } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '../components/TitleBar.vue' +import { fetchWithCsrf } from '../helpers/fetch_with_csrf' const { t } = useI18n() @@ -31,10 +32,10 @@ async function toggleTrace() { tracingAction.value = true try { if (isTraced.value) { - await fetch(`/internal/tr069-trace/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' }) + await fetchWithCsrf(`/internal/tr069-trace/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' }) isTraced.value = false } else { - const response = await fetch('/internal/tr069-trace', { + const response = await fetchWithCsrf('/internal/tr069-trace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceId: device.identifier }), @@ -48,7 +49,7 @@ async function toggleTrace() { checkTraceStatus() async function deleteDevice() { - await fetch(`/internal/devices/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' }) + await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}`, { method: 'DELETE' }) router.visit('/') } const showGetModelPopup = ref(false) @@ -152,7 +153,7 @@ const resolvingProvider = ref(null) async function resolveProvider(providerName: string) { resolvingProvider.value = providerName try { - await fetch(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' }) + await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve/${providerName}`, { method: 'POST' }) router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true }) } catch { /* ignore */ } finally { resolvingProvider.value = null } @@ -161,7 +162,7 @@ async function resolveProvider(providerName: string) { async function resolveAllProviders() { resolvingProvider.value = '__all__' try { - await fetch(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' }) + await fetchWithCsrf(`/internal/devices/${encodeURIComponent(device.identifier)}/references/resolve`, { method: 'POST' }) router.visit(`/devices/${encodeURIComponent(device.identifier)}`, { preserveScroll: true }) } catch { /* ignore */ } finally { resolvingProvider.value = null } diff --git a/inertia/pages/Home.vue b/inertia/pages/Home.vue index 3b25eb8..1576597 100644 --- a/inertia/pages/Home.vue +++ b/inertia/pages/Home.vue @@ -3,6 +3,7 @@ import { ref, computed, watch, onMounted } from 'vue' import { router } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '../components/TitleBar.vue' +import { fetchWithCsrf } from '../helpers/fetch_with_csrf' const { t } = useI18n() @@ -78,7 +79,7 @@ async function deleteSelected() { try { await Promise.all( selectedIdentifiers.value.map((id) => - fetch(`/internal/devices/${encodeURIComponent(id)}`, { method: 'DELETE' }) + fetchWithCsrf(`/internal/devices/${encodeURIComponent(id)}`, { method: 'DELETE' }) ) ) router.reload() diff --git a/inertia/pages/Tr069Trace.vue b/inertia/pages/Tr069Trace.vue index 2dc124c..d1e3b06 100644 --- a/inertia/pages/Tr069Trace.vue +++ b/inertia/pages/Tr069Trace.vue @@ -5,6 +5,7 @@ import TitleBar from '~/components/TitleBar.vue' import SequenceViewer from '~/components/SequenceViewer.vue' import InspectorPanel from '~/components/InspectorPanel.vue' import type { CwmpExchange } from '~/composables/useExchangeStream' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' type TraceEntry = { deviceId: string @@ -94,7 +95,7 @@ async function removeDevice(deviceId: string) { removingDeviceId.value = deviceId actionError.value = '' try { - const response = await fetch(`/internal/tr069-trace/${encodeURIComponent(deviceId)}`, { + const response = await fetchWithCsrf(`/internal/tr069-trace/${encodeURIComponent(deviceId)}`, { method: 'DELETE', }) if (!response.ok) { @@ -114,7 +115,7 @@ async function enableTrace(deviceId: string) { actionError.value = '' pendingDeviceId.value = deviceId try { - const response = await fetch('/internal/tr069-trace', { + const response = await fetchWithCsrf('/internal/tr069-trace', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceId }), @@ -168,7 +169,7 @@ async function saveJsonConfig() { } try { - const response = await fetch('/internal/tr069-trace/config', { + const response = await fetchWithCsrf('/internal/tr069-trace/config', { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ raw: jsonContent.value }), diff --git a/inertia/pages/configurations/ConfigurationCreate.vue b/inertia/pages/configurations/ConfigurationCreate.vue index 64fc894..ae9bb84 100644 --- a/inertia/pages/configurations/ConfigurationCreate.vue +++ b/inertia/pages/configurations/ConfigurationCreate.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' import TagsInput from '~/components/configuration/TagsInput.vue' import CriteriaEditor from '~/components/configuration/CriteriaEditor.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() @@ -42,7 +43,7 @@ function removeParameter(index: number) { async function submitForm() { errorMsg.value = '' try { - const response = await fetch('/internal/configurations', { + const response = await fetchWithCsrf('/internal/configurations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form.value), diff --git a/inertia/pages/configurations/ConfigurationDetail.vue b/inertia/pages/configurations/ConfigurationDetail.vue index ff2191d..a9433af 100644 --- a/inertia/pages/configurations/ConfigurationDetail.vue +++ b/inertia/pages/configurations/ConfigurationDetail.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' import PolicyBadge from '~/components/configuration/PolicyBadge.vue' import ComplianceBadge from '~/components/configuration/ComplianceBadge.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() const props = defineProps<{ id: string }>() @@ -68,7 +69,7 @@ async function runDryRun(deviceIdentifier?: string) { showDryRunModal.value = true try { - const res = await fetch(`/internal/configurations/${props.id}/dry-run`, { + const res = await fetchWithCsrf(`/internal/configurations/${props.id}/dry-run`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ deviceIdentifier: targetDevice }), diff --git a/inertia/pages/configurations/ConfigurationEdit.vue b/inertia/pages/configurations/ConfigurationEdit.vue index 43a2ae8..f2de7e4 100644 --- a/inertia/pages/configurations/ConfigurationEdit.vue +++ b/inertia/pages/configurations/ConfigurationEdit.vue @@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' import TagsInput from '~/components/configuration/TagsInput.vue' import CriteriaEditor from '~/components/configuration/CriteriaEditor.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() @@ -51,7 +52,7 @@ function removeParameter(i: number) { form.value.desiredParameters.splice(i, 1) async function submitForm() { errorMsg.value = '' try { - const res = await fetch(`/internal/configurations/${props.id}`, { + const res = await fetchWithCsrf(`/internal/configurations/${props.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form.value), diff --git a/inertia/pages/configurations/ConfigurationsList.vue b/inertia/pages/configurations/ConfigurationsList.vue index b860209..c6e0fad 100644 --- a/inertia/pages/configurations/ConfigurationsList.vue +++ b/inertia/pages/configurations/ConfigurationsList.vue @@ -4,6 +4,7 @@ import { router } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' import PolicyBadge from '~/components/configuration/PolicyBadge.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() @@ -81,7 +82,7 @@ function removeFilter(key: keyof typeof filters.value) { } async function toggleEnabled(id: string, enabled: boolean) { - await fetch(`/internal/configurations/${id}/toggle`, { + await fetchWithCsrf(`/internal/configurations/${id}/toggle`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ enabled }), @@ -90,8 +91,8 @@ async function toggleEnabled(id: string, enabled: boolean) { } async function deleteConfig(id: string, name: string) { - if (!confirm(`Delete configuration "${name}"?`)) return - await fetch(`/internal/configurations/${id}`, { method: 'DELETE' }) + if (!confirm(`Delete configuration "${name}\"?`)) return + await fetchWithCsrf(`/internal/configurations/${id}`, { method: 'DELETE' }) await loadConfigurations() } diff --git a/inertia/pages/firmware_catalog/FirmwareCatalog.vue b/inertia/pages/firmware_catalog/FirmwareCatalog.vue index d8a5886..9955bcf 100644 --- a/inertia/pages/firmware_catalog/FirmwareCatalog.vue +++ b/inertia/pages/firmware_catalog/FirmwareCatalog.vue @@ -3,6 +3,7 @@ import { ref, onMounted, watch } from 'vue' import { router } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() const entries = ref([]) @@ -33,13 +34,13 @@ async function loadRoutes() { async function deleteEntry(identifier: string) { if (!confirm(t('modal.confirmDeleteMessage'))) return - await fetch(`/internal/firmware-catalog/entries/${identifier}`, { method: 'DELETE' }) + await fetchWithCsrf(`/internal/firmware-catalog/entries/${identifier}`, { method: 'DELETE' }) await loadEntries() } async function deleteRoute(identifier: string) { if (!confirm(t('modal.confirmDeleteMessage'))) return - await fetch(`/internal/firmware-catalog/routes/${identifier}`, { method: 'DELETE' }) + await fetchWithCsrf(`/internal/firmware-catalog/routes/${identifier}`, { method: 'DELETE' }) await loadRoutes() } diff --git a/inertia/pages/firmware_catalog/FirmwareCatalogCreate.vue b/inertia/pages/firmware_catalog/FirmwareCatalogCreate.vue index cb5bd41..8a380b6 100644 --- a/inertia/pages/firmware_catalog/FirmwareCatalogCreate.vue +++ b/inertia/pages/firmware_catalog/FirmwareCatalogCreate.vue @@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue' import { router } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() const form = ref({ @@ -26,7 +27,7 @@ onMounted(async () => { async function submitForm() { try { - const res = await fetch('/internal/firmware-catalog/entries', { + const res = await fetchWithCsrf('/internal/firmware-catalog/entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form.value), diff --git a/inertia/pages/firmware_catalog/FirmwareCatalogEdit.vue b/inertia/pages/firmware_catalog/FirmwareCatalogEdit.vue index ef58a25..5f08609 100644 --- a/inertia/pages/firmware_catalog/FirmwareCatalogEdit.vue +++ b/inertia/pages/firmware_catalog/FirmwareCatalogEdit.vue @@ -3,6 +3,7 @@ import { ref, onMounted } from 'vue' import { router } from '@inertiajs/vue3' import { useI18n } from 'vue-i18n' import TitleBar from '~/components/TitleBar.vue' +import { fetchWithCsrf } from '~/helpers/fetch_with_csrf' const { t } = useI18n() const props = defineProps<{ id: string }>() @@ -36,7 +37,7 @@ onMounted(async () => { async function submitForm() { try { - const res = await fetch(`/internal/firmware-catalog/entries/${props.id}`, { + const res = await fetchWithCsrf(`/internal/firmware-catalog/entries/${props.id}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(form.value), diff --git a/tests/unit/services/device_service.spec.ts b/tests/unit/services/device_service.spec.ts new file mode 100644 index 0000000..84df3c0 --- /dev/null +++ b/tests/unit/services/device_service.spec.ts @@ -0,0 +1,264 @@ +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:') + }) + }) +})