packages feed

salmon-ops-recipes-0.2.0.0: test/Test/PortMappingSpec.hs

{-# LANGUAGE OverloadedStrings #-}

{- | Layer 0 coverage for "Salmon.Builtin.Nodes.PortMapping": the parsers over
captured @upnpc@ output, and the plan/verdict each listing leads to. No
gateway, no network.

Two fixtures are real captures (miniupnpc 2.2.6, an owner's LAN, UUID
redacted): "nothing answered" and "a WPS device answered but it is not an
IGD". The listings of a real IGD are written from @upnpc.c@'s format and have
not been captured from a live router.
-}
module Test.PortMappingSpec (tests) where

import Data.Text (Text)
import qualified Data.Text as Text
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)

import Salmon.Actions.UpDown (CheckResult (..))
import Salmon.Builtin.Nodes.PortMapping

notIgdOutput :: Text
notIgdOutput =
    Text.unlines
        [ "No valid UPNP Internet Gateway Device found."
        , "upnpc : miniupnpc library test client, version 2.2.6."
        , " (c) 2005-2024 Thomas Bernard."
        , "List of UPNP devices found on the network :"
        , " desc: http://192.168.1.1:1990/<uuid>/WFADevice.xml"
        , " st: upnp:rootdevice"
        , ""
        , "UPnP device found. Is it an IGD ? : http://192.168.1.1:1990/"
        ]

noDeviceOutput :: Text
noDeviceOutput =
    Text.unlines
        [ "No valid UPNP Internet Gateway Device found."
        , "upnpc : miniupnpc library test client, version 2.2.6."
        ]

igdListing :: Text -> [Text] -> Text
igdListing ext rows =
    Text.unlines $
        [ "upnpc : miniupnpc library test client, version 2.2.6."
        , "List of UPNP devices found on the network :"
        , "Found valid IGD : http://192.168.1.1:5000/ctl/IPConn"
        , "Local LAN ip address : 192.168.1.5"
        , "Connection Type : IP_Routed"
        , "Status : Connected, uptime=100s, LastConnectionError : ERROR_NONE"
        , "ExternalIPAddress = " <> ext
        , " i protocol exPort->inAddr:inPort description remoteHost leaseTime"
        ]
            <> rows

pm :: PortMap
pm = PortMap "wg0" "192.168.1.5" 51820 51820 UDP 3600

ours :: Text
ours = " 0 UDP 51820->192.168.1.5:51820 'salmon:wg0' '' 3600"

tests :: TestTree
tests =
    testGroup
        "Salmon.Builtin.Nodes.PortMapping"
        [ testCase "gateway: nothing answered" $ assertEqual "" NoDevice (parseGateway noDeviceOutput)
        , testCase "gateway: a device answered but is not an IGD (real capture)" $ assertEqual "" NotIgd (parseGateway notIgdOutput)
        , testCase "gateway: an IGD with its external address" $
            assertEqual "" (Igd (Just "203.0.113.7") (Just "192.168.1.5")) (parseGateway (igdListing "203.0.113.7" []))
        , testCase "mappings: a listing is parsed, including a description with spaces" $
            assertEqual
                ""
                [ Mapping UDP 51820 "192.168.1.5" 51820 "salmon:wg0" "" 3600
                , Mapping TCP 80 "192.168.1.9" 8080 "my web server" "" 0
                ]
                (parseMappings (igdListing "203.0.113.7" [ours, " 1 TCP    80->192.168.1.9:8080  'my web server' '' 0"]))
        , testCase "mappings: header and garbage lines are skipped" $
            assertEqual "" [] (parseMappings (igdListing "203.0.113.7" ["GetGenericPortMappingEntry() returned 713 (SpecifiedArrayIndexInvalid)"]))
        , testCase "address classes" $
            assertEqual
                ""
                [Public, Private, Private, Private, CGNAT, CGNAT, Public, Public, Unparseable]
                (map classifyAddress ["203.0.113.7", "10.1.2.3", "172.16.0.1", "192.168.1.1", "100.64.0.1", "100.127.255.1", "100.128.0.1", "172.32.0.1", "not an address"])
        , testCase "plan: present with our address is in place, check Success" $ do
            let out = igdListing "203.0.113.7" [ours]
            assertEqual "" InPlace (planFor pm out)
            assertBool "success" (isSuccess (checkOf (planFor pm out)))
        , testCase "plan: absent needs an add, check Failure" $ do
            let out = igdListing "203.0.113.7" []
            assertBool "needs add" (isNeedsAdd (planFor pm out))
            assertBool "failure" (isFailure (checkOf (planFor pm out)))
        , testCase "plan: a hand-configured map pointing at us is satisfied (not ours to delete)" $ do
            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.5:51820 'by hand' '' 0"]
            assertEqual "" InPlace (planFor pm out)
            assertEqual "" (NotOurs "refusing to delete mapping 'by hand' on external port 51820: it does not carry our marker salmon:wg0") (teardownFor pm out)
        , testCase "plan: somebody else's mapping is Taken, never taken over" $ do
            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.77:51820 'laptop' '' 0"]
            assertBool "taken" (isTaken (planFor pm out))
            assertBool "failure" (isFailure (checkOf (planFor pm out)))
        , testCase "plan: our own stale mapping (pointing elsewhere) is re-added" $ do
            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.77:51820 'salmon:wg0' '' 3600"]
            assertBool "needs add" (isNeedsAdd (planFor pm out))
        , testCase "plan: a mapping near expiry is re-added" $ do
            let out = igdListing "203.0.113.7" [" 0 UDP 51820->192.168.1.5:51820 'salmon:wg0' '' 600"]
            assertBool "needs add" (isNeedsAdd (planFor pm out))
        , testCase "plan: the same external port under the other protocol is not ours" $ do
            let out = igdListing "203.0.113.7" [" 0 TCP 51820->192.168.1.77:51820 'laptop' '' 0"]
            assertBool "needs add" (isNeedsAdd (planFor pm out))
        , testCase "plan: a private external address is double NAT, Failure with a reason" $ do
            let out = igdListing "192.168.0.2" [ours]
            case checkOf (planFor pm out) of
                Failure r -> assertBool "names the problem" ("cannot be reached" `Text.isInfixOf` r)
                other -> assertBool ("expected Failure, got " <> show other) False
        , testCase "plan: a CGNAT external address is double NAT too" $
            assertBool "double nat" (isDoubleNat (planFor pm (igdListing "100.64.3.2" [])))
        , testCase "plan: no device and not-an-IGD are Unknown, not Failure (real captures)" $ do
            assertBool "unknown" (isUnknown (checkOf (planFor pm noDeviceOutput)))
            assertBool "unknown" (isUnknown (checkOf (planFor pm notIgdOutput)))
        , testCase "teardown: absent is already gone, ours is deleted, no gateway cannot tell" $ do
            assertEqual "" AlreadyGone (teardownFor pm (igdListing "203.0.113.7" []))
            assertEqual "" DeleteIt (teardownFor pm (igdListing "203.0.113.7" [ours]))
            assertBool "cannot tell" (case teardownFor pm notIgdOutput of CannotTell _ -> True; _ -> False)
        ]
  where
    isSuccess Success = True
    isSuccess _ = False
    isFailure (Failure _) = True
    isFailure _ = False
    isUnknown Unknown = True
    isUnknown _ = False
    isNeedsAdd (NeedsAdd _) = True
    isNeedsAdd _ = False
    isTaken (Taken _) = True
    isTaken _ = False
    isDoubleNat (DoubleNat _) = True
    isDoubleNat _ = False