slackblocks-3.1.0: test/Helpers.hs
{-# LANGUAGE LambdaCase #-}
module Main (main) where
import Control.Exception (evaluate)
import Control.Monad (forM_, unless)
import Data.Aeson (ToJSON (..), Value (..), encode, object, (.=))
import Data.Aeson qualified as A
import Data.Aeson.KeyMap qualified as KM
import Data.ByteString.Lazy qualified as L
import Data.ByteString.Lazy.Char8 qualified as C
import Data.Map.Strict qualified as Map
import Data.Maybe (isNothing)
import Data.Text qualified as T
import Slackblocks
import Test.QuickCheck hiding (Result, label)
import Test.QuickCheck.Random (mkQCGen)
assert :: String -> Bool -> IO ()
assert label condition = unless condition (fail label)
accept :: (Show e) => Either e a -> IO a
accept = either (fail . show) pure
reject :: String -> Result a -> IO ()
reject label = either report (const (fail (label <> " accepted")))
where
report errors = do
diagnosticLength <- evaluate (length (show errors))
assert (label <> ": empty diagnostics") (diagnosticLength > 0)
rejectExponent :: String -> Result a -> IO ()
rejectExponent label = either report (const (fail (label <> " accepted")))
where
report errors =
assert (label <> ": wrong diagnostic") $
all (\e -> errorCategory e == TypeMismatch && errorConstraint e == "json.number.exponent") errors
textValue :: Gen T.Text
textValue = T.pack <$> listOf1 (elements ['a', 'Z', '0', 'é', '中', '😀', '\x301', '\n', '"', '\\'])
propertyRun :: (Testable prop) => String -> prop -> IO ()
propertyRun name test = do
putStrLn name
result <- quickCheckWithResult stdArgs {maxSuccess = 500, replay = Just (mkQCGen 20261025, 0)} test
unless (isSuccess result) (fail ("Property failed: " <> name))
main :: IO ()
main = do
divider <- accept (buildDividerBlock dividerBlock)
let blocks = replicate 12 (dividerBlockAsBlock divider)
title <- accept (buildPlainText (plainText "Section"))
section <- accept (buildContainerBlock (accordionSection title [dividerBlockAsBlock divider]))
accept (accordion [section, section]) >>= assert "accordion preserves order" . (== replicate 2 (containerBlockAsBlock section))
reject "empty accordion" (accordion [])
regular <- accept $ buildContainerBlock ((containerBlock [dividerBlockAsBlock divider]) {containerBlockTitle = Just title})
reject "noncollapsible accordion" (accordion [regular])
first <- accept (paginate (paginator "list") blocks)
middle <- accept (paginate ((paginator "list") {paginatorPage = 2}) blocks)
lastPage <- accept (paginate ((paginator "list") {paginatorPage = 3, paginatorShowPageIndicator = False}) blocks)
assert "page sizes include controls" (length first == 7 && length middle == 7 && length lastPage == 3)
let actionValues page = case matchBlockActionsBlock (last page) of
Nothing -> []
Just actions ->
[ (buttonElementActionId fields, buttonElementValue fields)
| element <- actionsBlockElements (inspectActionsBlock actions),
Just button <- [matchElementButtonElement element],
let fields = inspectButtonElement button
]
assert "first navigation" (actionValues first == [(Just "list.next", Just "2")])
assert "middle navigation" (actionValues middle == [(Just "list.previous", Just "1"), (Just "list.next", Just "3")])
assert "last navigation" (actionValues lastPage == [(Just "list.previous", Just "2")])
reject "empty pagination" (paginate (paginator "list") [])
reject "empty prefix" (paginate (paginator "") blocks)
reject "page zero" (paginate ((paginator "list") {paginatorPage = 0}) blocks)
reject "size zero" (paginate ((paginator "list") {paginatorPageSize = 0}) blocks)
reject "past end" (paginate ((paginator "list") {paginatorPage = 4}) blocks)
reject "oversized navigation ID" (paginate (paginator (T.replicate 255 "x")) blocks)
accept (paginate ((paginator "list") {paginatorPageSize = 10 ^ (100 :: Int)}) blocks) >>= assert "huge size has no Int overflow" . (== blocks)
reject "expanded message count" $ do
page <- paginate ((paginator "list") {paginatorPageSize = 50}) (replicate 51 (dividerBlockAsBlock divider))
buildMessagePayload ((messagePayload "C123") {messagePayloadBlocks = Just page})
parameter <- accept (buildInputParameter (inputParameter "ticket" "42"))
flow <- accept (workflowFromURL "https://example.com/trigger" [parameter])
assert "workflow has typed parameters" (triggerCustomizableInputParameters (inspectTrigger (workflowTrigger (inspectWorkflow flow))) == Just [parameter])
flowEmpty <- accept (workflowFromURL "https://example.com/trigger" [])
assert "empty workflow parameters omitted" (isNothing (triggerCustomizableInputParameters (inspectTrigger (workflowTrigger (inspectWorkflow flowEmpty)))))
accept (hexColor "A0B1c2") >>= assert "hex keeps case" . (== either (error . show) id (hexColor "#A0B1c2"))
assert "named palette" (map (colorText . namedColor) [minBound .. maxBound] == ["good", "warning", "danger", "#ff0000", "#0000ff", "#ffff00", "#00ff00", "#ff8800", "#8800ff", "#000000"])
reject "bad hex" (hexColor "#gg0000")
reject "short hex" (hexColor "fff")
reject "negative RGB" (rgbColor (-1) 0 0)
reject "large RGB" (rgbColor 0 0 256)
assert "Builder blocks wrapped and escaped" $
blockKitBuilderURL (BuilderBlocks [dividerBlockAsBlock divider]) (Just "T/a#")
== "https://app.slack.com/block-kit-builder/T%2Fa%23#%7B%22blocks%22%3A%5B%7B%22type%22%3A%22divider%22%7D%5D%7D"
assert "Builder Unicode encoded as UTF8" ("%F0%9F%98%80" `T.isInfixOf` blockKitBuilderURL (BuilderJSON (Map.singleton "emoji" (String "😀"))) Nothing)
assert "Builder unreserved segment" ("T-a._~#" `T.isInfixOf` blockKitBuilderURL (BuilderJSON Map.empty) (Just "T-a._~"))
msg <- accept (buildMessagePayload (messagePayload "C123"))
web <- accept (buildWebhookMessage webhookMessage)
response <- accept (buildMessageResponse messageResponse)
modalTitle <- accept (buildPlainText (plainText "View"))
modal <- accept (buildModalView (modalView modalTitle []))
home <- accept (buildHomeTabView (homeTabView [dividerBlockAsBlock divider]))
assert "Builder message" (toJSON (BuilderMessage msg) == toJSON msg)
assert "Builder webhook" (toJSON (BuilderWebhook web) == toJSON web)
assert "Builder response" (toJSON (BuilderResponse response) == toJSON response)
assert "Builder modal" (toJSON (BuilderModal modal) == toJSON modal)
assert "Builder home" (toJSON (BuilderHome home) == toJSON home)
let duplicate = "{\"type\":\"plain_text\",\"text\":\"one\",\"te\\u0078t\":\"two\"}"
reject "escaped duplicate key" (decode duplicate :: Result PlainText)
reject "nested duplicate metadata" (decode "{\"metadata\":{\"key\":1,\"key\":2}}" :: Result WebhookMessage)
reject "trailing JSON" (decode "{\"type\":\"divider\"} false" :: Result DividerBlock)
reject "invalid UTF8" (decode (L.pack [34, 255, 34]) :: Result PlainText)
reject "invalid JSON object" (decode "{\"type\":" :: Result DividerBlock)
reject "invalid JSON array" (decode "[1," :: Result [Integer])
reject "wrong boolean" (fromValue (String "false") :: Result Bool)
reject "wrong text" (fromValue (Bool False) :: Result T.Text)
reject "wrong integer" (fromValue (String "1") :: Result Integer)
reject "fractional integer" (decode "1.5" :: Result Integer)
-- Aeson's token lexer narrows JSON exponents to Int. Check the original
-- bytes before that conversion can wrap, including opaque application data.
let tooHigh = show (toInteger (maxBound :: Int) + 1)
tooLow = show (toInteger (minBound :: Int) - 1)
overflow = "1e18446744073709551616"
forM_ ["1e" <> tooHigh, "1E+" <> tooHigh, "-1e" <> tooLow, "1.1e" <> show (minBound :: Int), overflow] $ \literal -> do
let raw = C.pack literal
rejectExponent "overflowing metadata exponent" (decode ("{\"metadata\":{\"nested\":[" <> raw <> "]}}") :: Result WebhookMessage)
rejectExponent "overflowing extension exponent" (decode ("{\"type\":\"divider\",\"extra\":" <> raw <> "}") :: Result DividerBlock)
rejectExponent "overflowing coerced bound" (decode ("{\"type\":\"number_input\",\"is_decimal_allowed\":true,\"min_value\":" <> raw <> "}") :: Result NumberInputElement)
rejectExponent "overflow split across lazy chunks" (decode (L.fromChunks (map (L.toStrict . L.singleton) (L.unpack raw))) :: Result Integer)
forM_ [show (maxBound :: Int), show (toInteger (minBound :: Int) + 1), "+00001000", "-1000"] $ \power -> do
let raw = "{\"n\":1e" <> C.pack power <> "}"
parsed <- accept (decode raw :: Result (Map.Map T.Text Value))
assert "representable exponent preserved" (decode (encode parsed) == Right parsed)
forM_ ["1e" <> show (minBound :: Int), "10e" <> show (maxBound :: Int), "-100e" <> show (toInteger (maxBound :: Int) - 1)] $ \literal -> do
let raw = C.pack literal
rejectExponent "encoder exponent overflow" (decode ("{\"n\":" <> raw <> "}") :: Result (Map.Map T.Text Value))
number <- accept (A.eitherDecode raw :: Either String Value)
let metadata = Map.singleton "nested" (toJSON [number])
rejectExponent "native metadata encoder overflow" (buildWebhookMessage (webhookMessage {webhookMessageMetadata = Just metadata}))
rejectExponent "native extension encoder overflow" (buildDividerBlock (dividerBlock {dividerBlockExtensions = metadata}))
rejectExponent "preparsed metadata encoder overflow" (fromValue (object ["metadata" .= metadata]) :: Result WebhookMessage)
rejectExponent "preparsed map encoder overflow" (fromValue (toJSON metadata) :: Result (Map.Map T.Text Value))
rejectExponent "Aeson model instance encoder overflow" (fromValue (object ["label" .= String "n", "value" .= number]) :: Result DataPoint)
assert "Aeson instance rejects encoder overflow" (case A.fromJSON (object ["metadata" .= metadata]) :: A.Result WebhookMessage of A.Error _ -> True; _ -> False)
case number of
Number value -> do
assert "decimal string conversion keeps exact representation" (decimalText value == T.pack literal)
_ <- accept (buildNumberInputElement ((numberInputElement True) {numberInputElementMinValue = Just (decimalText value)}))
pure ()
_ -> fail "numeric regression input was not a number"
forM_ ["12e" <> show (minBound :: Int), "10e" <> show (minBound :: Int), "0e" <> show (minBound :: Int), "10e" <> show (toInteger (maxBound :: Int) - 1), "1.2e" <> show (maxBound :: Int)] $ \literal -> do
let raw = "{\"n\":" <> C.pack literal <> "}"
parsed <- accept (decode raw :: Result (Map.Map T.Text Value))
assert "neighboring encodable exponent remains supported" (decode (encode parsed) == Right parsed)
forM_ ["1e1000000000", "-1e1000000000"] $ \literal -> do
let raw = "{\"type\":\"plain_text_input\",\"max_length\":" <> literal <> "}"
case decode raw :: Result PlainTextInputElement of
Left errors -> assert "huge bounded integer rejects before expansion" (all (\e -> errorCategory e == OutOfRange && errorPath e == [Field "max_length"]) errors)
Right _ -> fail "huge bounded integer accepted"
native <- accept (A.eitherDecode raw :: Either String Value)
assert "Aeson bounded integer rejects before expansion" (case A.fromJSON native :: A.Result PlainTextInputElement of A.Error _ -> True; _ -> False)
forM_ ["3e3", "30000e-1"] $ \literal -> do
value <- accept (decode ("{\"type\":\"plain_text_input\",\"max_length\":" <> literal <> "}") :: Result PlainTextInputElement)
assert "exact integer boundary accepted" (plainTextInputElementMaxLength (inspectPlainTextInputElement value) == Just 3000)
case decode "{\"type\":\"plain_text_input\",\"max_length\":3000.5}" :: Result PlainTextInputElement of
Left errors -> assert "fraction retains type category" (all ((== TypeMismatch) . errorCategory) errors)
Right _ -> fail "fractional integer accepted"
let quoted = "escaped \\\" quote and \\ backslash; " <> T.pack overflow <> " \\\n"
assert "number-looking string remains text" ((decode (encode quoted) :: Result T.Text) == Right quoted)
forM_ ["1e+", "1e-", "1e1.5", "\"unterminated", "\"trailing\\"] $ \raw ->
reject "malformed JSON still rejected" (decode raw :: Result T.Text)
assert "huge decimal-string bound remains supported" $
either
(const False)
(const True)
(decode "{\"type\":\"number_input\",\"is_decimal_allowed\":true,\"min_value\":\"1e18446744073709551616\"}" :: Result NumberInputElement)
reject "wrong list" (fromValue (Bool True) :: Result [Integer])
reject "wrong map" (fromValue (Bool False) :: Result (Map.Map T.Text Value))
reject "wrong model shape" (fromValue (Bool False) :: Result PlainText)
reject "wrong child role shape" (fromValue (Bool False) :: Result Block)
reject "wrong discriminator" (fromValue (object ["type" .= String "unknown"]) :: Result PlainText)
reject "unknown child role" (fromValue (object ["type" .= String "unknown"]) :: Result Block)
reject "required null" (fromValue (object ["type" .= String "plain_text", "text" .= Null]) :: Result PlainText)
optionalNull <- accept (fromValue (object ["type" .= String "plain_text", "text" .= String "text", "emoji" .= Null]) :: Result PlainText)
assert "optional null omitted" (isNothing (plainTextEmoji (inspectPlainText optionalNull)))
reject "bad plan task collection" (fromValue (object ["type" .= String "plan", "title" .= String "Plan", "tasks" .= Bool True]) :: Result PlanBlock)
reject "bad plan task" (fromValue (object ["type" .= String "plan", "title" .= String "Plan", "tasks" .= [Bool True]]) :: Result PlanBlock)
reject "bad plan task discriminator" (fromValue (object ["type" .= String "plan", "title" .= String "Plan", "tasks" .= [object ["type" .= String "wrong"]]]) :: Result PlanBlock)
reject "malformed surrogate" (decode "\"\\uD800\"" :: Result T.Text)
assert "syntax boolean false" ((decode "false\n" :: Result Bool) == Right False)
assert "syntax decimal exact" ((decode "{\"x\":0.125,\"y\":1.25e2}" :: Result (Map.Map T.Text Value)) == Right (Map.fromList [("x", Number 0.125), ("y", Number 125)]))
let semantic = object ["bool" .= True, "null" .= Null, "array" .= [Number 1, String "é"], "exact" .= (9007199254740993 :: Integer)]
assert "all syntax token variants" ((decode (encode semantic) :: Result (Map.Map T.Text Value)) == Right (Map.fromList [("bool", Bool True), ("null", Null), ("array", toJSON [Number 1, String "é"]), ("exact", Number 9007199254740993)]))
propertyRun "Unicode checked construction / exact JSON ingress" $ forAll textValue $ \value ->
let result = buildPlainText (plainText value)
in case result of Left _ -> T.length value > 3000; Right checked -> decode (encode checked) == Right checked
propertyRun "RGB produces valid six-digit colors" $ forAll (vectorOf 3 (chooseInteger (0, 255))) $ \case
[r, g, b] -> case rgbColor r g b of
Right color -> hexColor (colorText color) == Right color
_ -> False
_ -> False
propertyRun "Pagination preserves the selected content" $ forAll (chooseInt (1, 100)) $ \count ->
forAll (chooseInteger (1, 20)) $ \size ->
let items = take count (cycle blocks); pages = (fromIntegral count - 1) `div` size + 1
in forAll (chooseInteger (1, pages)) $ \page -> case paginate ((paginator "prop") {paginatorPage = page, paginatorPageSize = size}) items of
Left _ -> False
Right output -> take (min (fromInteger size) (count - fromInteger ((page - 1) * size))) output == take (fromInteger size) (drop (fromInteger ((page - 1) * size)) items)
propertyRun "Record edits must cross validation" $ forAll textValue $ \value ->
case buildPlainText (plainText value) of
Left _ -> False
Right checked -> either (const True) (const False) (buildPlainText ((inspectPlainText checked) {plainTextText = ""}))
propertyRun "Opaque metadata preserves exact integer values" $ \(integer :: Integer) ->
let payload = buildWebhookMessage (webhookMessage {webhookMessageMetadata = Just (Map.singleton "integer" (toJSON integer))})
in case payload of Left _ -> False; Right checked -> decode (encode checked) == Right checked
assert "no duplicate keys in helper output" $ case toJSON (BuilderJSON (Map.singleton "x" Null)) of Object fields -> KM.size fields == 1; _ -> False
putStrLn "Helpers, strict JSON boundaries and 2,500 property cases passed."