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)
This commit is contained in:
1 parent
63ee6f94f1
commit
8cddf98093
15 files changed
+347
-20
No files matched your search
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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' }
|
||||
)
|
||||
|
||||
@@ -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<Response> {
|
||||
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)
|
||||
}
|
||||
@@ -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<string | null>(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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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<any[]>([])
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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> = {}): 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:')
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user