packages feed

haskell-opentimestamps-0.5.5.0: test/Spec.hs

{-# LANGUAGE BlockArguments #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_GHC -Wno-unrecognised-pragmas #-}

{-# HLINT ignore "Use lambda-case" #-}

module Spec (main) where

import qualified PruneSpec
import qualified UpgradeSpec
import qualified VerifySpec

import Arbitrary ()
import Data.Binary.Get (runGet, runGetOrFail)
import Data.Binary.Put (runPut)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base16 as B16
import qualified Data.ByteString.Char8 as BSC
import qualified Data.ByteString.Lazy as BSL
import Data.List (isPrefixOf)
import qualified Data.Map.Strict as Map
import qualified Data.Set as Set
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified OpenTimestamps as OTS
import OpenTimestamps.Attestation (Attestation (..))
import OpenTimestamps.DetachedTimestampFile
  ( DetachedTimestampFile
  )
import qualified OpenTimestamps.DetachedTimestampFile as DTSF
  ( deserialize
  , digestLen
  , digestType
  , serialize
  , timestamp
  )
import OpenTimestamps.Op as Op
  ( Op (Append, Hexlify, Keccak256, Prepend, Reverse, Ripemd160, Sha1, Sha256)
  , execute
  , getOp
  , opToTag
  , putOp
  )
import OpenTimestamps.Timestamp as TS
  ( Timestamp (..)
  , deserialize
  , getAttestations
  , getMerkleRoot
  , getPendingAttestationsWithMsgs
  , isTimestampComplete
  , merge
  , serialize
  )
import qualified OpenTimestamps.Upgrade as Upgrade
import System.FilePath ((</>))
import System.Random (newStdGen, randomRs)
import Test.Hspec
  ( Spec
  , describe
  , expectationFailure
  , hspec
  , it
  , shouldBe
  , shouldSatisfy
  )
import Test.QuickCheck
  ( quickCheck
  )

main :: IO ()
main = hspec spec

{-
calendarUrls :: [String]
calendarUrls =
  [ "https://a.pool.opentimestamps.org"
  , "https://b.pool.opentimestamps.org"
  , "https://a.pool.eternitywall.com"
  , "https://ots.btc.catallaxy.com"
  ]
-}

spec :: Spec
spec = do
  let examplesDir = "examples"
  describe "stamp" $ do
    it "creates a timestamp with the correct structure" $ do
      let originalDigest = "\x00\x01\x02\x03\x04\x05\x06\x07\x08\x09\x0a\x0b\x0c\x0d\x0e\x0f"
      let nonce = "\x10\x11\x12\x13\x14\x15\x16\x17\x18\x19\x1a\x1b\x1c\x1d\x1e\x1f"
      let appendedDigest = originalDigest <> nonce
      let submissionDigest = "\x20\x21\x22\x23\x24\x25\x26\x27\x28\x29\x2a\x2b\x2c\x2d\x2e\x2f"

      let serverTimestamp = TS.Timestamp {TS.timestampMsg = submissionDigest, TS.attestations = mempty, TS.ops = mempty}

      let expectedTimestamp =
            TS.Timestamp
              { TS.timestampMsg = originalDigest
              , TS.attestations = mempty
              , TS.ops =
                  Map.singleton (Op.Append nonce) $
                    TS.Timestamp
                      { TS.timestampMsg = appendedDigest
                      , TS.attestations = mempty
                      , TS.ops = Map.singleton Op.Sha256 serverTimestamp
                      }
              }

      TS.timestampMsg expectedTimestamp `shouldBe` originalDigest

      case Map.toList (TS.ops expectedTimestamp) of
        [(Op.Append nonce', intermediateTimestamp)] -> do
          nonce' `shouldBe` nonce
          TS.timestampMsg intermediateTimestamp `shouldBe` appendedDigest
          case Map.toList (TS.ops intermediateTimestamp) of
            [(Op.Sha256, finalTimestamp)] -> do
              TS.timestampMsg finalTimestamp `shouldBe` submissionDigest
            _ -> expectationFailure "Expected a Sha256 operation"
        _ -> expectationFailure "Expected an Append operation"

    it "stamps random content and verifies the resulting OTS file using internal info" $ do
      -- Generate random content
      gen <- newStdGen
      let randomBytes = BSC.pack $ take 32 $ randomRs ('a', 'z') gen
      let randomContent = BSL.fromStrict randomBytes

      -- Stamp the random content
      let calendarUrl = "https://a.pool.opentimestamps.org"
      stampResult <- OTS.stamp [calendarUrl] (BSL.toStrict randomContent)

      -- Verify no errors and a timestamp file is returned
      OTS.srErrors stampResult `shouldBe` []
      OTS.srTimestampFile stampResult
        `shouldSatisfy` (\x -> case x of Just _ -> True; Nothing -> False)

      -- Extract and serialize the DetachedTimestampFile
      case OTS.srTimestampFile stampResult of
        Just otsFile -> do
          let serializedOts = DTSF.serialize otsFile
          let infoResult = OTS.info serializedOts
          infoResult `shouldSatisfy` either (const False) (const True)
        Nothing -> expectationFailure "No timestamp file returned by stamp operation"
  describe "OpenTimestamps.Op" $ do
    it "can be round-tripped" $
      quickCheck propRoundtripOp
    it "hash operations are idempotent" $
      quickCheck propHashIdempotent
    it "operations compose consistently" $
      quickCheck propOpComposition
    it "length is preserved for non-appending operations" $
      quickCheck propLengthPreservation

  describe "OpenTimestamps.Timestamp" $ do
    it "serialization is round-trip" $
      quickCheck propTimestampRoundTrip
    it "serialization is deterministic" $
      quickCheck propSerializationDeterministic
    it "message is preserved through serialization" $
      quickCheck propMessagePreservation
    it "merge is idempotent" $
      quickCheck propMergeIdempotent
    it "merge is commutative" $
      quickCheck propMergeCommutative
    it "merge accumulates attestations" $
      quickCheck propMergeAttestationAccumulation
    it "merkle root is consistent with operations" $
      quickCheck propMerkleRootConsistency
    it "merkle root is deterministic" $
      quickCheck propMerkleRootDeterministic
    it "completeness depends only on Bitcoin attestations" $
      quickCheck propCompletenessBitcoinDetection
    it "completeness propagates from sub-timestamps" $
      quickCheck propCompletenessNested

  describe "OpenTimestamps.DetachedTimestampFile" $ do
    it "serialization is round-trip" $
      quickCheck propDetachedFileRoundTrip
    it "digest type consistency" $
      quickCheck propDigestTypeConsistency

  describe "OpenTimestamps.Attestation" $ do
    it "collection is complete" $
      quickCheck propAttestationCollectionComplete
    it "collection has no duplicates" $
      quickCheck propAttestationCollectionUnique
    it "pending attestations are correctly filtered" $
      quickCheck propPendingAttestationFiltering
    it "all attestation types are handled" $
      quickCheck propAttestationTypeDistribution

  describe "OpenTimestamps" $ do
    let otsFiles =
          [ "bad-stamp.txt.ots"
          , "bitcoin.pdf.ots"
          , "different-blockchains.txt.ots"
          , "empty.ots"
          , "hello-world.txt.ots"
          , "incomplete.txt.ots"
          , "invalid/bad-major-version.ots"
          , "invalid/exceeds-max-msg-length.ots"
          , "invalid/invalid-file-digest-type.ots"
          , "known-and-unknown-notary.txt.ots"
          , "merkle1.txt.ots"
          , "merkle2.txt.ots"
          , "merkle3.txt.ots"
          , "sha1/a-or-b.ots"
          , "two-calendars.txt.ots"
          , "unknown-notary.txt.ots"
          ]
    mapM_
      ( \otsFile ->
          it ("info parses " ++ otsFile) $ do
            otsFileContent <- BSL.readFile (examplesDir </> otsFile)
            let result = OTS.info otsFileContent
            if "invalid/" `isPrefixOf` otsFile
              then result `shouldSatisfy` either (const True) (const False)
              else result `shouldSatisfy` either (const False) (const True)
      )
      otsFiles

  describe "upgrade" $ do
    it "upgrades an incomplete example timestamp from a calendar" $ do
      let otsFile = examplesDir </> "incomplete.txt.ots"
      otsFileContent <- BSL.readFile otsFile
      -- let calendarUrl = "https://alice.btc.calendar.opentimestamps.org"
      -- result <- OTS.upgrade calendarUrls otsFileContent
      -- result <- OTS.upgrade [calendarUrl] otsFileContent
      result <- OTS.upgrade [] otsFileContent
      case result of
        Left err -> expectationFailure $ "Upgrade failed: " ++ err
        Right upgradedOtsContent -> do
          let deserializedResult = OTS.info upgradedOtsContent
          case deserializedResult of
            Left err -> expectationFailure $ "Failed to deserialize upgraded OTS: " ++ err
            Right dtfs -> do
              TS.isTimestampComplete (DTSF.timestamp dtfs) `shouldBe` True
              putStrLn $ "\nLength of example upgradedOtsContent: " ++ show (BSL.length upgradedOtsContent)
              putStrLn $
                "First 100 bytes of example upgradedOtsContent: " ++ show (BSL.take 100 upgradedOtsContent)

  describe "fetchTimestampFromCalendar" $ do
    it "fetches a complete timestamp to upgrade from a calendar" $ do
      let calendarUrl = "https://alice.btc.calendar.opentimestamps.org"
      let msgHex = "57cfa5c46716df9bd9e83595bce439c58108d8fcc1678f30d4c6731c3f1fa6c79ed712c66fb1ac8d4e4eb0e7"
      let msg = case B16.decode $ TE.encodeUtf8 msgHex of
            Left err -> error $ "Failed to decode hex: " ++ err
            Right bs -> bs
      result <- Upgrade.fetchTimestampFromCalendar (T.pack calendarUrl) msg
      case result of
        Left err -> expectationFailure $ "fetchTimestampFromCalendar failed: " ++ err
        Right ts ->
          TS.isTimestampComplete ts `shouldBe` True

  PruneSpec.spec
  UpgradeSpec.spec
  VerifySpec.spec

propRoundtripOp :: Op -> Bool
propRoundtripOp op =
  let serialized = runPut (putOp op)
      tag = opToTag op
      deserialized = runGet (getOp tag) (BSL.drop 1 serialized)
   in deserialized == op

-- | Hash operations should be idempotent (applying twice gives same result)
propHashIdempotent :: Op -> BS.ByteString -> Bool
propHashIdempotent op input =
  case op of
    Sha1 -> execute op (execute op input) == execute op input
    Sha256 -> execute op (execute op input) == execute op input
    Ripemd160 -> execute op (execute op input) == execute op input
    Keccak256 -> execute op (execute op input) == execute op input
    _ -> True -- Skip non-hash operations

-- | Operations should compose consistently
propOpComposition :: Op -> Op -> BS.ByteString -> Bool
propOpComposition op1 op2 input =
  let result1 = execute op2 (execute op1 input)
      result2 = execute op1 (execute op2 input)
   in -- For commutative operations, results should be the same
      -- For non-commutative, we just check they're both valid
      case (op1, op2) of
        (Reverse, Reverse) -> result1 == result2 -- Double reverse should be identity
        (Append _, Prepend _) -> True -- Different append/prepend order gives different results
        (Prepend _, Append _) -> True -- Different append/prepend order gives different results
        _ -> BS.length result1 == BS.length result2 -- Same length for same type operations

-- | Length preservation for operations that shouldn't change length
propLengthPreservation :: Op -> BS.ByteString -> Bool
propLengthPreservation op input =
  case op of
    Sha1 -> BS.length (execute op input) == 20 -- SHA1 produces 20 bytes
    Sha256 -> BS.length (execute op input) == 32 -- SHA256 produces 32 bytes
    Ripemd160 -> BS.length (execute op input) == 20 -- RIPEMD160 produces 20 bytes
    Keccak256 -> BS.length (execute op input) == 32 -- Keccak256 produces 32 bytes
    Hexlify -> BS.length (execute op input) == BS.length input * 2 -- Hexlify doubles length
    Reverse -> BS.length (execute op input) == BS.length input -- Reverse preserves length
    Append bs -> BS.length (execute op input) == BS.length input + BS.length bs -- Append adds length
    Prepend bs -> BS.length (execute op input) == BS.length input + BS.length bs -- Prepend adds length

-- | Check if a timestamp tree contains any empty timestamps
hasEmptyTimestamps :: Timestamp -> Bool
hasEmptyTimestamps ts =
  (Set.null (TS.attestations ts) && Map.null (TS.ops ts))
    || any hasEmptyTimestamps (Map.elems (TS.ops ts))

-- | Timestamp serialization round-trip property
propTimestampRoundTrip :: Timestamp -> Bool
propTimestampRoundTrip ts =
  -- Skip timestamps that contain empty sub-timestamps
  hasEmptyTimestamps ts
    || ( let serialized = TS.serialize ts
             deserialized = TS.deserialize (TS.timestampMsg ts)
          in case runGetOrFail deserialized serialized of
               Left _ -> False
               Right (_, _, result) -> result == ts
       )

-- | Serialization should be deterministic (same timestamp always serializes the same way)
propSerializationDeterministic :: Timestamp -> Bool
propSerializationDeterministic ts =
  -- Skip timestamps that contain empty sub-timestamps
  hasEmptyTimestamps ts
    || ( let ser1 = TS.serialize ts
             ser2 = TS.serialize ts
          in ser1 == ser2
       )

-- | The root message should be preserved through serialization cycles
propMessagePreservation :: Timestamp -> Bool
propMessagePreservation ts =
  -- Skip timestamps that contain empty sub-timestamps
  hasEmptyTimestamps ts
    || ( let serialized = TS.serialize ts
             deserialized = TS.deserialize (TS.timestampMsg ts)
          in case runGetOrFail deserialized serialized of
               Left _ -> False
               Right (_, _, result) ->
                 TS.timestampMsg result == TS.timestampMsg ts
       )

-- | Merge operation should be idempotent
propMergeIdempotent :: Timestamp -> Bool
propMergeIdempotent ts =
  let (merged, changed) = TS.merge ts ts
   in merged == ts && not changed

-- | Merge operation should be commutative
propMergeCommutative :: Timestamp -> Timestamp -> Bool
propMergeCommutative ts1 ts2 =
  let (merged1, _) = TS.merge ts1 ts2
      (merged2, _) = TS.merge ts2 ts1
   in merged1 == merged2

-- | Merge should accumulate all unique attestations
propMergeAttestationAccumulation :: Timestamp -> Timestamp -> Bool
propMergeAttestationAccumulation ts1 ts2 =
  let (merged, _) = TS.merge ts1 ts2
      originalAtts = Set.union (TS.attestations ts1) (TS.attestations ts2)
   in TS.attestations merged == originalAtts

-- | DetachedTimestampFile serialization round-trip property
propDetachedFileRoundTrip :: DetachedTimestampFile -> Bool
propDetachedFileRoundTrip dtf =
  -- Skip files with empty timestamps that can't be serialized
  hasEmptyTimestamps (DTSF.timestamp dtf)
    || let serialized = DTSF.serialize dtf
           deserialized = DTSF.deserialize serialized
        in case deserialized of
             Left _ -> False
             Right result -> result == dtf

-- | Digest type should be consistent with message length
propDigestTypeConsistency :: DetachedTimestampFile -> Bool
propDigestTypeConsistency dtf =
  let msg = TS.timestampMsg (DTSF.timestamp dtf)
      expectedLen = DTSF.digestLen (DTSF.digestType dtf)
   in BS.length msg == expectedLen

-- | Merkle root should be consistent with timestamp operations
propMerkleRootConsistency :: Timestamp -> Bool
propMerkleRootConsistency ts =
  -- Skip empty timestamps
  -- The merkle root should be the result of applying all operations to the root message
  -- For now, just check that it's deterministic for the same timestamp
  hasEmptyTimestamps ts || (getMerkleRoot ts == getMerkleRoot ts)

-- | Merkle root should be deterministic for identical timestamps
propMerkleRootDeterministic :: Timestamp -> Bool
propMerkleRootDeterministic ts =
  -- Skip empty timestamps
  hasEmptyTimestamps ts || (getMerkleRoot ts == getMerkleRoot ts)

-- | Completeness should depend only on Bitcoin attestations
propCompletenessBitcoinDetection :: Timestamp -> Bool
propCompletenessBitcoinDetection ts =
  -- Skip empty timestamps
  hasEmptyTimestamps ts
    || (isTimestampComplete ts == any isBitcoinAttestation (getAttestations ts))
  where
    isBitcoinAttestation (Bitcoin _) = True
    isBitcoinAttestation _ = False

-- | Completeness should propagate from sub-timestamps
propCompletenessNested :: Timestamp -> Bool
propCompletenessNested ts =
  -- Skip empty timestamps
  -- A timestamp is complete if any sub-timestamp is complete
  hasEmptyTimestamps ts
    || (isTimestampComplete ts == any isTimestampComplete (ts : allSubTimestamps ts))
  where
    allSubTimestamps t = concatMap (allSubTimestamps . snd) (Map.toList (TS.ops t))

-- | All attestations in the tree are collected
propAttestationCollectionComplete :: Timestamp -> Bool
propAttestationCollectionComplete ts =
  -- Skip empty timestamps
  hasEmptyTimestamps ts
    || let collected = getAttestations ts
           allAtts = collectAllAttestations ts
        in length collected == length allAtts
             && all (`elem` collected) allAtts

-- | No duplicate attestations in collection
propAttestationCollectionUnique :: Timestamp -> Bool
propAttestationCollectionUnique ts =
  let collected = getAttestations ts
      deduplicated = Set.toList $ Set.fromList collected
   in length collected == length deduplicated

-- | Pending attestations are correctly filtered and paired
propPendingAttestationFiltering :: Timestamp -> Bool
propPendingAttestationFiltering ts =
  let pendingPairs = getPendingAttestationsWithMsgs ts
      allAtts = getAttestations ts
      pendingAtts = filter isPending allAtts
   in length pendingPairs == length pendingAtts
        && all
          ( \(msg, att) ->
              isPending att
                && any (\ts' -> TS.timestampMsg ts' == msg) (allTimestamps ts)
          )
          pendingPairs

-- | All attestation types are handled
propAttestationTypeDistribution :: Timestamp -> Bool
propAttestationTypeDistribution ts =
  let allAtts = getAttestations ts
      hasBitcoin = any isBitcoin allAtts
      hasPending = any isPending allAtts
      hasUnknown = any isUnknown allAtts
   in -- At least one type should be present if there are attestations
      (null allAtts || (hasBitcoin || hasPending || hasUnknown))

-- Helper functions for attestation property tests

-- | Recursively collect all attestations from timestamp tree
collectAllAttestations :: Timestamp -> [Attestation]
collectAllAttestations ts =
  Set.toList (TS.attestations ts)
    ++ concatMap (collectAllAttestations . snd) (Map.toList $ TS.ops ts)

-- | Get all timestamps in the tree
allTimestamps :: Timestamp -> [Timestamp]
allTimestamps ts = ts : concatMap (allTimestamps . snd) (Map.toList $ TS.ops ts)

-- | Type predicates
isBitcoin, isPending, isUnknown :: Attestation -> Bool
isBitcoin (Bitcoin _) = True
isBitcoin _ = False
isPending (Pending _) = True
isPending _ = False
isUnknown (Unknown _ _) = True
isUnknown _ = False