packages feed

hprox-0.7.0: test/Network/HProx/PureSpec.hs

-- SPDX-License-Identifier: Apache-2.0
--
-- Copyright (C) 2026 Bin Jin. All Rights Reserved.

module Network.HProx.PureSpec
  ( spec
  ) where

import Data.List.NonEmpty qualified as NE

import Network.HProx.Log
import Network.HProx.Util

import Test.Hspec

spec :: Spec
spec = do
  describe "parseHostPort" $ do
    it "parses a valid host and port" $
      parseHostPort "example.com:443" `shouldBe` Just ("example.com", 443)

    it "uses the last colon as the port separator" $
      parseHostPort "example.com:proxy:8080" `shouldBe` Just ("example.com:proxy", 8080)

    it "accepts an empty host portion" $
      parseHostPort ":123" `shouldBe` Just ("", 123)

    it "rejects missing, invalid, trailing, and out-of-range ports" $
      map parseHostPort ["example.com", "example.com:http", "example.com:80x", "example.com:0", "example.com:65536", "example.com:"]
        `shouldBe` replicate 6 Nothing

  describe "parseHostPortWithDefault" $ do
    it "returns an explicit port when parsing succeeds" $
      parseHostPortWithDefault 80 "example.com:443" `shouldBe` ("example.com", 443)

    it "returns the original input and default port when parsing fails" $
      parseHostPortWithDefault 80 "example.com:not-a-port" `shouldBe` ("example.com:not-a-port", 80)

  describe "passwordReader" $ do
    it "parses plaintext password lines" $
      passwordReader "user:pass" `shouldBe` Just ("user", PlainText "pass")

    it "parses salted password lines by base64-decoding salt and hash" $
      passwordReader "user:c2FsdA==:aGFzaA==" `shouldBe` Just ("user", Salted "salt" "hash")

    it "rejects invalid salted and over-split password lines" $
      map passwordReader ["user:not-base64:also-not-base64", "user:pass:extra:field"]
        `shouldBe` [Nothing, Nothing]

  describe "passwordWriter" $
    it "writes salted password lines with base64 salt and hash" $
      passwordWriter "user" (PasswordSalted "salt" "hash") `shouldBe` "user:c2FsdA==:aGFzaA=="

  describe "hashPasswordWithSalt" $ do
    it "returns a typed failure when Argon2 rejects hashing parameters" $
      case hashPasswordWithSalt "" (PlainText "pass") of
        Left (PasswordHashError msg) -> msg `shouldContain` "unable to hash password with salt"
        Right _                      -> expectationFailure "expected typed hash failure"

    it "keeps already salted passwords without rehashing" $
      hashPasswordWithSalt "" (Salted "salt" "hash") `shouldBe` Right (PasswordSalted "salt" "hash")

  describe "logLevelReader" $ do
    it "parses all current log levels" $
      map logLevelReader ["trace", "debug", "info", "warn", "error", "none"]
        `shouldBe` map Just [TRACE, DEBUG, INFO, WARN, ERROR, NONE]

    it "rejects unknown log levels and different casing" $
      map logLevelReader ["", "warning", "INFO"] `shouldBe` replicate 3 Nothing

  describe "parseLogOutput" $
    it "preserves current log destination parsing policy" $
      map parseLogOutput ["none", "stdout", "stderr", "hprox.log"]
        `shouldBe` [LogOutputNone, LogOutputStdout, LogOutputStderr, LogOutputFile "hprox.log"]

  describe "splitBy" $ do
    it "preserves empty segments between, before, and after separators" $
      NE.toList (splitBy ':' ":a::b:") `shouldBe` ["", "a", "", "b", ""]

    it "returns one empty segment for empty input" $
      NE.toList (splitBy ':' "") `shouldBe` [""]