Skip to content

Commit ad5b9b3

Browse files
gmelodieCopilotgithub-actions[bot]
authored
chore(nat): mock tests (#2576)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
1 parent 54b6682 commit ad5b9b3

1 file changed

Lines changed: 335 additions & 2 deletions

File tree

tests/libp2p/services/test_natservice.nim

Lines changed: 335 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33

44
{.used.}
55

6-
import std/net
6+
import std/[net, sequtils]
77
import chronos, results
8-
import ../../../libp2p/[builders, switch, multiaddress, multicodec]
8+
import ../../../libp2p/[builders, switch, multiaddress, multicodec, peerinfo, wire]
99
import ../../../libp2p/services/natservice
1010
import ../../../libp2p/services/nat/portmapper
1111
import ../../tools/[unittest, crypto, multiaddress]
@@ -377,3 +377,336 @@ suite "NATService":
377377
let res = await switch.peerInfo.addressMappers[0](@[ma("/ip6/fc00::1/tcp/4242")])
378378
check res == @[ma("/ip6/fc00::1/tcp/4242")] # fall-through, no map()
379379
check mock.countCalls(mckMap) == 0
380+
381+
type RecordingPortMapper = ref object of PortMapper
382+
externalIp: IpAddress
383+
discoverResult: Result[IpAddress, string]
384+
mapResult: Result[Port, string]
385+
mapPortOverride: Opt[Port]
386+
## When set, `map` returns this port instead of echoing the requested
387+
## `externalPort`. Models an IGD that re-maps to a different external port
388+
## (e.g. when the requested one is busy).
389+
unmapResult: Result[void, string]
390+
discoverCalls: int
391+
mapCalls: seq[tuple[internal, external: Port, proto: MapProto, lease: uint32]]
392+
unmapCalls: seq[tuple[external: Port, proto: MapProto]]
393+
closed: bool
394+
mapEvent: AsyncEvent
395+
396+
method discover*(
397+
self: RecordingPortMapper, timeout: Duration
398+
): Future[Result[IpAddress, string]] {.async: (raises: [CancelledError]), gcsafe.} =
399+
inc self.discoverCalls
400+
self.discoverResult
401+
402+
method map*(
403+
self: RecordingPortMapper,
404+
internalPort: Port,
405+
externalPort: Port,
406+
proto: MapProto,
407+
lease: uint32,
408+
): Future[Result[Port, string]] {.async: (raises: [CancelledError]), gcsafe.} =
409+
self.mapCalls.add((internalPort, externalPort, proto, lease))
410+
if self.mapEvent != nil:
411+
self.mapEvent.fire()
412+
if self.mapResult.isErr:
413+
return self.mapResult
414+
self.mapPortOverride.withValue(p):
415+
return Result[Port, string].ok(p)
416+
Result[Port, string].ok(externalPort)
417+
418+
method unmap*(
419+
self: RecordingPortMapper, externalPort: Port, proto: MapProto
420+
): Future[Result[void, string]] {.async: (raises: [CancelledError]), gcsafe.} =
421+
self.unmapCalls.add((externalPort, proto))
422+
self.unmapResult
423+
424+
method close*(self: RecordingPortMapper) {.async: (raises: []), gcsafe.} =
425+
self.closed = true
426+
427+
proc newRecordingOk(externalIp: IpAddress): RecordingPortMapper =
428+
RecordingPortMapper(
429+
externalIp: externalIp,
430+
discoverResult: Result[IpAddress, string].ok(externalIp),
431+
mapResult: Result[Port, string].ok(Port(0)), # actual port set per call
432+
unmapResult: Result[void, string].ok(),
433+
)
434+
435+
proc recordingFactory(m: RecordingPortMapper): PortMapperFactory =
436+
return proc(mode: NATMode): Opt[PortMapper] {.gcsafe, raises: [].} =
437+
Opt.some(PortMapper(m))
438+
439+
proc recordingFactoryFail(): PortMapperFactory =
440+
let mapper = RecordingPortMapper(
441+
discoverResult: Result[IpAddress, string].err("mock no IGD"),
442+
mapResult: Result[Port, string].err("not discovered"),
443+
unmapResult: Result[void, string].ok(),
444+
)
445+
recordingFactory(mapper)
446+
447+
proc findNatService(switch: Switch): NATService =
448+
for s in switch.services:
449+
if s of NATService:
450+
return NATService(s)
451+
raiseAssert "NATService not found in switch.services"
452+
453+
proc loopbackAddr(): MultiAddress =
454+
MultiAddress.init("/ip4/127.0.0.1/tcp/0").get()
455+
456+
proc privateAddr(port: int = 9000): MultiAddress =
457+
MultiAddress.init("/ip4/192.168.1.5/tcp/" & $port).get()
458+
459+
suite "NATService (setupMappings)":
460+
teardown:
461+
checkTrackers()
462+
463+
asyncTest "Upnp refresh loop reissues map calls":
464+
let
465+
externalIp = parseIpAddress("203.0.113.55")
466+
mapper = newRecordingOk(externalIp)
467+
mapper.mapEvent = newAsyncEvent()
468+
469+
let
470+
factory = recordingFactory(mapper)
471+
cfg = NATConfig(
472+
mode: Upnp,
473+
refreshInterval: 50.milliseconds,
474+
discoveryTimeout: DefaultDiscoveryTimeout,
475+
leaseDuration: DefaultLeaseDuration,
476+
)
477+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
478+
479+
await switch.start()
480+
defer:
481+
await switch.stop()
482+
483+
# Override the bound listenAddrs with a private one so the addressMapper
484+
# actually runs the mapper. The refresh loop calls peerInfo.update(), which
485+
# re-invokes the addressMapper.
486+
switch.peerInfo.listenAddrs = @[privateAddr(9000)]
487+
await switch.peerInfo.update()
488+
let
489+
firstCalls = mapper.mapCalls.len
490+
discoverBefore = mapper.discoverCalls
491+
check firstCalls >= 1
492+
check discoverBefore >= 1
493+
494+
mapper.mapEvent.clear()
495+
await mapper.mapEvent.wait()
496+
check mapper.mapCalls.len > firstCalls
497+
# The refresh loop invalidates externalIp before triggering
498+
# peerInfo.update, so the next setupMappings has to rediscover —
499+
# not just re-map.
500+
check mapper.discoverCalls > discoverBefore
501+
502+
asyncTest "NatPmp announces external IP after successful mapping":
503+
let
504+
externalIp = parseIpAddress("203.0.113.99")
505+
mapper = newRecordingOk(externalIp)
506+
factory = recordingFactory(mapper)
507+
cfg = NATConfig(
508+
mode: NatPmp,
509+
refreshInterval: 30.minutes,
510+
discoveryTimeout: DefaultDiscoveryTimeout,
511+
leaseDuration: DefaultLeaseDuration,
512+
)
513+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
514+
svc = findNatService(switch)
515+
516+
await switch.start()
517+
defer:
518+
await switch.stop()
519+
520+
let announced = await svc.setupMappings(@[privateAddr(9000)])
521+
check announced.len == 1
522+
check svc.externalIp.isSome
523+
check svc.externalIp.get() == externalIp
524+
525+
asyncTest "non-private listen addresses are skipped":
526+
let
527+
externalIp = parseIpAddress("203.0.113.1")
528+
mapper = newRecordingOk(externalIp)
529+
factory = recordingFactory(mapper)
530+
cfg = NATConfig(
531+
mode: Upnp,
532+
refreshInterval: 30.minutes,
533+
discoveryTimeout: DefaultDiscoveryTimeout,
534+
leaseDuration: DefaultLeaseDuration,
535+
)
536+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
537+
svc = findNatService(switch)
538+
539+
await switch.start()
540+
defer:
541+
await switch.stop()
542+
543+
# Public + loopback addresses only → discover never runs, no mappings made.
544+
let announced = await svc.setupMappings(
545+
@[
546+
MultiAddress.init("/ip4/8.8.8.8/tcp/9000").tryGet(),
547+
MultiAddress.init("/ip4/127.0.0.1/tcp/9001").tryGet(),
548+
]
549+
)
550+
551+
check mapper.discoverCalls == 0
552+
check mapper.mapCalls.len == 0
553+
check announced.len == 0
554+
555+
asyncTest "user-set announcedAddrs are not overwritten":
556+
let
557+
externalIp = parseIpAddress("203.0.113.111")
558+
userAddr = MultiAddress.init("/ip4/198.51.100.7/tcp/4242").tryGet()
559+
mapper = newRecordingOk(externalIp)
560+
factory = recordingFactory(mapper)
561+
cfg = NATConfig(
562+
mode: Upnp,
563+
refreshInterval: 30.minutes,
564+
discoveryTimeout: DefaultDiscoveryTimeout,
565+
leaseDuration: DefaultLeaseDuration,
566+
)
567+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
568+
569+
switch.peerInfo.announcedAddrs = @[userAddr]
570+
571+
await switch.start()
572+
defer:
573+
await switch.stop()
574+
575+
check switch.peerInfo.announcedAddrs == @[userAddr]
576+
# expandAddrs uses announcedAddrs directly when set, bypassing the
577+
# addressMapper chain — so the mapper should never have been consulted.
578+
check mapper.discoverCalls == 0
579+
580+
asyncTest "multiple private listen addresses are each mapped once":
581+
let
582+
externalIp = parseIpAddress("203.0.113.10")
583+
mapper = newRecordingOk(externalIp)
584+
factory = recordingFactory(mapper)
585+
cfg = NATConfig(
586+
mode: Upnp,
587+
refreshInterval: 30.minutes,
588+
discoveryTimeout: DefaultDiscoveryTimeout,
589+
leaseDuration: DefaultLeaseDuration,
590+
)
591+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
592+
svc = findNatService(switch)
593+
594+
await switch.start()
595+
defer:
596+
await switch.stop()
597+
598+
let
599+
tcpAddr = privateAddr(9000)
600+
udpAddr = MultiAddress.init("/ip4/192.168.1.5/udp/9000").tryGet()
601+
otherTcp = privateAddr(9001)
602+
announced = await svc.setupMappings(@[tcpAddr, udpAddr, otherTcp])
603+
604+
# Discovery happens once; one map call per listen address.
605+
check mapper.discoverCalls == 1
606+
check mapper.mapCalls.len == 3
607+
check announced.len == 3
608+
# TCP+UDP on the same port are two distinct (port, proto) mappings.
609+
let protos = mapper.mapCalls.mapIt(it.proto)
610+
check mpTcp in protos
611+
check mpUdp in protos
612+
613+
asyncTest "setupMappings unmaps stale ports when a listen addr is removed":
614+
let
615+
externalIp = parseIpAddress("203.0.113.20")
616+
mapper = newRecordingOk(externalIp)
617+
factory = recordingFactory(mapper)
618+
cfg = NATConfig(
619+
mode: Upnp,
620+
refreshInterval: 30.minutes,
621+
discoveryTimeout: DefaultDiscoveryTimeout,
622+
leaseDuration: DefaultLeaseDuration,
623+
)
624+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
625+
svc = findNatService(switch)
626+
627+
await switch.start()
628+
defer:
629+
await switch.stop()
630+
631+
# First cycle: two private addresses mapped.
632+
discard await svc.setupMappings(@[privateAddr(9000), privateAddr(9001)])
633+
check mapper.mapCalls.len == 2
634+
check mapper.unmapCalls.len == 0
635+
636+
# Second cycle: one of them is gone. unmapStale must clean it up.
637+
discard await svc.setupMappings(@[privateAddr(9000)])
638+
check mapper.unmapCalls.len == 1
639+
check mapper.unmapCalls[^1].external == Port(9001)
640+
check mapper.unmapCalls[^1].proto == mpTcp
641+
642+
asyncTest "IGD returning a different external port surfaces in announced":
643+
let
644+
externalIp = parseIpAddress("203.0.113.30")
645+
mapper = newRecordingOk(externalIp)
646+
factory = recordingFactory(mapper)
647+
cfg = NATConfig(
648+
mode: Upnp,
649+
refreshInterval: 30.minutes,
650+
discoveryTimeout: DefaultDiscoveryTimeout,
651+
leaseDuration: DefaultLeaseDuration,
652+
)
653+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
654+
svc = findNatService(switch)
655+
656+
# Simulate the IGD remapping the request to a different external port
657+
# (e.g. because the requested one is already busy on the gateway).
658+
mapper.mapPortOverride = Opt.some(Port(54321))
659+
660+
await switch.start()
661+
defer:
662+
await switch.stop()
663+
664+
let announced = await svc.setupMappings(@[privateAddr(9000)])
665+
check announced.len == 1
666+
let annTa = initTAddress(announced[0]).tryGet()
667+
check annTa.address_v4 == externalIp.address_v4
668+
check annTa.port == Port(54321)
669+
670+
asyncTest "NatPmp discovery failure leaves announced empty":
671+
let
672+
cfg = NATConfig(
673+
mode: NatPmp,
674+
refreshInterval: 30.minutes,
675+
discoveryTimeout: DefaultDiscoveryTimeout,
676+
leaseDuration: DefaultLeaseDuration,
677+
)
678+
switch = makeSwitch(cfg, @[loopbackAddr()], recordingFactoryFail())
679+
svc = findNatService(switch)
680+
681+
await switch.start()
682+
defer:
683+
await switch.stop()
684+
685+
let announced = await svc.setupMappings(@[privateAddr(9000)])
686+
check announced.len == 0
687+
check svc.externalIp.isNone
688+
689+
asyncTest "NatPmp stop unmaps all created mappings":
690+
let
691+
externalIp = parseIpAddress("203.0.113.40")
692+
mapper = newRecordingOk(externalIp)
693+
factory = recordingFactory(mapper)
694+
cfg = NATConfig(
695+
mode: NatPmp,
696+
refreshInterval: 30.minutes,
697+
discoveryTimeout: DefaultDiscoveryTimeout,
698+
leaseDuration: DefaultLeaseDuration,
699+
)
700+
switch = makeSwitch(cfg, @[loopbackAddr()], factory)
701+
svc = findNatService(switch)
702+
703+
await switch.start()
704+
705+
discard await svc.setupMappings(@[privateAddr(9000)])
706+
let mapsBefore = mapper.mapCalls.len
707+
708+
await switch.stop()
709+
710+
check mapsBefore >= 1
711+
check mapper.unmapCalls.len == mapsBefore
712+
check mapper.closed

0 commit comments

Comments
 (0)