packages feed

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

{-# LANGUAGE OverloadedStrings #-}

{- | Layer 0 coverage for "Salmon.Builtin.Nodes.Debian.Package"'s @check@.

The check exists so that a graph naming packages it already has can run as an
ordinary user (@apt-get install@ wants root even with nothing to do). Its one
subtlety is virtual packages: 'Salmon.Builtin.Nodes.Debian.OS' asks for
@ssh-client@, which @apt-get@ resolves to @openssh-client@ while
@dpkg-query@ answers @not-installed@ for that name forever. Sample lines
below are real @dpkg-query -W@ output.
-}
module Test.DebianPackageSpec (tests) where

import GHC.IO.Exception (ExitCode (..))
import Test.Tasty (TestTree, testGroup)
import Test.Tasty.HUnit (assertBool, assertEqual, testCase)

import Salmon.Actions.UpDown (CheckResult (..))
import Salmon.Builtin.Nodes.Debian.Package (interpretAptPolicy, interpretDpkgCatalog)

tests :: TestTree
tests = testGroup "Salmon.Builtin.Nodes.Debian.Package" [catalogTests, policyTests]

{- | 'interpretAptPolicy': whether the apt index has a candidate for every
package, which is what decides if @apt-get update@ runs before an install.
The stanzas are real @LC_ALL=C apt-cache policy@ output (Ubuntu 24.04).
-}
policyTests :: TestTree
policyTests =
    testGroup
        "interpretAptPolicy"
        [ testCase "a candidate for every package needs no refresh" $
            assertEqual "" Success (interpretAptPolicy False ["podman", "rsync"] ExitSuccess policy)
        , testCase "Candidate: (none) is no candidate" $
            -- what a fresh cloud image says of a universe package it has
            -- only heard of through another package's dependencies
            assertEqual
                ""
                (Failure "no installation candidate in the apt index for: pgbouncer")
                (interpretAptPolicy False ["podman", "pgbouncer"] ExitSuccess policy)
        , testCase "a name apt prints no stanza for is no candidate" $
            assertEqual
                ""
                (Failure "no installation candidate in the apt index for: nosuchpkg")
                (interpretAptPolicy False ["rsync", "nosuchpkg"] ExitSuccess policy)
        , testCase "an empty index has no candidate for anything" $
            assertEqual
                ""
                (Failure "no installation candidate in the apt index for: podman, rsync")
                (interpretAptPolicy False ["podman", "rsync"] ExitSuccess "")
        , testCase "an installed version is not a candidate" $
            -- "Installed:" and the version table both carry version strings;
            -- only the Candidate line decides
            assertBool "" (isFailure (interpretAptPolicy False ["orphan"] ExitSuccess policy))
        , testCase "after a recent refresh there is nothing more a refresh can do" $
            -- a virtual name (ssh-client) or a typo: left to the install to report
            assertEqual "" Success (interpretAptPolicy True ["pgbouncer", "nosuchpkg"] ExitSuccess policy)
        , testCase "apt-cache failing outright is 'cannot tell'" $
            assertEqual "" Unknown (interpretAptPolicy False ["rsync"] (ExitFailure 100) "")
        ]
  where
    isFailure (Failure _) = True
    isFailure _ = False

    policy =
        "pgbouncer:\n\
        \  Installed: (none)\n\
        \  Candidate: (none)\n\
        \  Version table:\n\
        \podman:\n\
        \  Installed: 4.9.3+ds1-1ubuntu0.2\n\
        \  Candidate: 4.9.3+ds1-1ubuntu0.2\n\
        \  Version table:\n\
        \ *** 4.9.3+ds1-1ubuntu0.2 500\n\
        \        500 http://archive.ubuntu.com/ubuntu noble-updates/universe amd64 Packages\n\
        \        100 /var/lib/dpkg/status\n\
        \     4.9.3+ds1-1build2 500\n\
        \        500 http://archive.ubuntu.com/ubuntu noble/universe amd64 Packages\n\
        \orphan:\n\
        \  Installed: 1.0-1\n\
        \  Candidate: (none)\n\
        \  Version table:\n\
        \ *** 1.0-1 -1\n\
        \        100 /var/lib/dpkg/status\n\
        \rsync:\n\
        \  Installed: (none)\n\
        \  Candidate: 3.2.7-1ubuntu1.5\n\
        \  Version table:\n\
        \     3.2.7-1ubuntu1.5 500\n\
        \        500 http://archive.ubuntu.com/ubuntu noble-updates/main amd64 Packages\n"

catalogTests :: TestTree
catalogTests =
    testGroup
        "interpretDpkgCatalog"
        [ testCase "an installed package is satisfied" $
            assertEqual "" Success (interpretDpkgCatalog ["rsync"] ExitSuccess catalogue)
        , testCase "a virtual name counts as installed when its provider is" $
            assertEqual
                "apt-get install ssh-client resolves to openssh-client"
                Success
                (interpretDpkgCatalog ["ssh-client"] ExitSuccess catalogue)
        , testCase "a provides entry carrying a version still matches" $
            assertEqual "" Success (interpretDpkgCatalog ["awk"] ExitSuccess catalogue)
        , testCase "a package present but not installed is not satisfied" $
            assertBool "" (isFailure (interpretDpkgCatalog ["podman"] ExitSuccess catalogue))
        , testCase "a package dpkg has never heard of is not satisfied" $
            assertBool "" (isFailure (interpretDpkgCatalog ["nosuchpkg"] ExitSuccess catalogue))
        , testCase "every wanted package must be installed, not just one" $
            assertBool "" (isFailure (interpretDpkgCatalog ["rsync", "podman"] ExitSuccess catalogue))
        , testCase "the reason names what is missing" $
            case interpretDpkgCatalog ["podman", "rsync"] ExitSuccess catalogue of
                Failure msg -> assertEqual "" "not installed: podman" msg
                other -> assertBool ("expected Failure, got " <> show other) False
        , testCase "dpkg-query failing outright is 'cannot tell', not 'missing'" $
            -- It exits non-zero when something else holds the dpkg lock
            -- (unattended-upgrades, typically). Reading that as "missing"
            -- makes the node run apt-get, which fails on the same lock --
            -- seen for real in a full test-suite run.
            assertEqual "" Unknown (interpretDpkgCatalog ["rsync"] (ExitFailure 2) "")
        ]
  where
    isFailure (Failure _) = True
    isFailure _ = False

    -- real `dpkg-query -W -f='${db:Status-Status}|${binary:Package}|${Provides}\n'` lines
    catalogue =
        "installed|rsync|\n\
        \installed|openssh-client|ssh-client\n\
        \installed|original-awk|awk (= 2020-05-22)\n\
        \not-installed|podman|\n\
        \installed|libc6:i386|libc6-i386\n"