packages feed

slackblocks-3.1.0: test/Adversarial.hs

module Main (main) where

import Control.Exception (evaluate)
import Control.Monad (forM_, unless)
import Data.Aeson qualified as A
import Data.ByteString.Lazy qualified as L
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict qualified as Map
import Data.Text qualified as T
import Slackblocks

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 :: (FromSlackJSON a) => String -> ErrorCategory -> L.ByteString -> (a -> ()) -> IO ()
reject label category bytes witness = case decode bytes of
  Right value -> witness value `seq` fail (label <> " accepted")
  Left errors -> do
    diagnosticLength <- evaluate (length (show errors))
    assert (label <> ": diagnostics") (diagnosticLength > 0)
    assert (label <> ": category " <> show errors) (any ((== category) . errorCategory) (NE.toList errors))

main :: IO ()
main = do
  reject "untagged object cannot silently discard type" InvalidUsage "{\"channel\":\"C123\",\"type\":\"future\"}" (const () :: MessagePayload -> ())
  reject "empty section fields with text" MissingRequired "{\"type\":\"section\",\"text\":\"ok\",\"fields\":[]}" (const () :: SectionBlock -> ())
  reject "empty card" MissingRequired "{\"type\":\"card\"}" (const () :: CardBlock -> ())
  reject "two card icon sources" MutuallyExclusive "{\"type\":\"card\",\"body\":\"body\",\"slack_icon\":{\"type\":\"icon\",\"name\":\"check\"},\"icon\":{\"type\":\"image\",\"image_url\":\"x\",\"alt_text\":\"x\"}}" (const () :: CardBlock -> ())
  reject "container missing title" MissingRequired "{\"type\":\"container\",\"child_blocks\":[{\"type\":\"divider\"}]}" (const () :: ContainerBlock -> ())
  reject "collapsed noncollapsible container" InvalidUsage "{\"type\":\"container\",\"title\":\"Title\",\"child_blocks\":[{\"type\":\"divider\"}],\"default_collapsed\":true}" (const () :: ContainerBlock -> ())
  reject "collapsible header divider" InvalidUsage "{\"type\":\"container\",\"title\":\"Title\",\"child_blocks\":[{\"type\":\"divider\"}],\"is_collapsible\":true,\"has_header_divider\":true}" (const () :: ContainerBlock -> ())
  reject "empty Slack file" MutuallyExclusive "{}" (const () :: SlackFile -> ())
  reject "empty conversation filter" MissingRequired "{}" (const () :: ConversationFilter -> ())
  reject "duplicate axis categories" InvalidUsage "{\"categories\":[\"a\",\"a\"]}" (const () :: AxisConfig -> ())
  reject "bad attachment color" TypeMismatch "{\"color\":\"#GG0000\",\"blocks\":[{\"type\":\"divider\"}]}" (const () :: Attachment -> ())
  reject "attachment surface" TypeMismatch "{\"blocks\":[{\"type\":\"alert\",\"text\":\"Alert\"}]}" (const () :: Attachment -> ())
  reject "unequal table row widths" InvalidUsage "{\"type\":\"data_table\",\"caption\":\"Table\",\"rows\":[[{\"type\":\"raw_text\",\"text\":\"A\"}],[{\"type\":\"raw_text\",\"text\":\"B\"},{\"type\":\"raw_text\",\"text\":\"C\"}]]}" (const () :: DataTableBlock -> ())
  reject "rich text table header" TypeMismatch "{\"type\":\"data_table\",\"caption\":\"Table\",\"rows\":[[{\"type\":\"rich_text\",\"elements\":[{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"header\"}]}]}],[{\"type\":\"raw_text\",\"text\":\"Body\"}]]}" (const () :: DataTableBlock -> ())
  reject "table column settings extension" InvalidUsage "{\"type\":\"data_table\",\"caption\":\"Table\",\"column_settings\":[],\"rows\":[[{\"type\":\"raw_text\",\"text\":\"A\"}],[{\"type\":\"raw_text\",\"text\":\"Body\"}]]}" (const () :: DataTableBlock -> ())
  reject "duplicate chart series names" InvalidUsage "{\"type\":\"line\",\"axis_config\":{\"categories\":[\"a\"]},\"series\":[{\"name\":\"s\",\"data\":[{\"label\":\"a\",\"value\":1}]},{\"name\":\"s\",\"data\":[{\"label\":\"a\",\"value\":2}]}]}" (const () :: LineChart -> ())
  forM_ ["24:00", "23:60", "23:5x", "x3:59", "23-59"] $ \value ->
    reject "invalid time" TypeMismatch (A.encode (A.object ["type" A..= A.String "timepicker", "initial_time" A..= (value :: T.Text)])) (const () :: TimePickerElement -> ())
  forM_ [("-1", "1"), ("-1", "-1.0"), ("-2", "-1"), ("1e+2", "100"), ("0", "-0")] $ \(lo, hi) -> do
    number <- accept $ buildNumberInputElement ((numberInputElement True) {numberInputElementMinValue = Just lo, numberInputElementMaxValue = Just hi})
    assert "exact decimal bounds preserved" (numberInputElementMinValue (inspectNumberInputElement number) == Just lo)
  reject "repeated exponent" TypeMismatch "{\"type\":\"number_input\",\"is_decimal_allowed\":true,\"min_value\":\"1e2e3\"}" (const () :: NumberInputElement -> ())
  -- A nested rich-text table exercises content accounting through lists, links
  -- and non-text items. Metadata that resembles content stays opaque.
  let table = "{\"type\":\"data_table\",\"caption\":\"Table\",\"rows\":[[{\"type\":\"raw_text\",\"text\":\"Header\"}],[{\"type\":\"rich_text\",\"elements\":[{\"type\":\"rich_text_list\",\"style\":\"bullet\",\"elements\":[{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"Body\"},{\"type\":\"link\",\"url\":\"https://example.com\",\"text\":\"Link\"},{\"type\":\"emoji\",\"name\":\"wave\"}]}]}]}]]}"
  checked <- accept (decode table :: Result DataTableBlock)
  title <- accept (buildPlainText (plainText "Nested"))
  inner <- accept (buildContainerBlock (accordionSection title [dataTableBlockAsBlock checked]))
  outer <- accept (buildContainerBlock (accordionSection title [containerBlockAsBlock inner]))
  message <- accept (buildMessagePayload ((messagePayload "C123") {messagePayloadBlocks = Just [containerBlockAsBlock outer]}))
  assert "nested message roundtrip" (decode (A.encode message) == Right message)
  richTitle <- accept (decode "{\"type\":\"rich_text\",\"elements\":[{\"type\":\"rich_text_section\",\"elements\":[{\"type\":\"text\",\"text\":\"Title\"}]}]}" :: Result RichTextBlock)
  divider <- accept (buildDividerBlock dividerBlock)
  _ <- accept (buildContainerBlock ((containerBlock [dividerBlockAsBlock divider]) {containerBlockRichTextTitle = Just richTitle}))
  forM_ ["{\"exclude_external_shared_channels\":false}", "{\"exclude_bot_users\":false}"] $ \bytes -> do
    value <- accept (decode bytes :: Result ConversationFilter)
    assert "explicit false is content" (decode (A.encode value) == Right value)
  -- Verify Aeson's standard instance adapters in both directions, separately
  -- from the stricter duplicate-aware byte decoder.
  extended <- accept (buildPlainText ((plainText "text") {plainTextExtensions = Map.singleton "future" (A.Bool False)}))
  assert "extension roundtrip through Aeson" (A.eitherDecode (A.encode extended) == Right extended)
  assert "Aeson checked failure" (case A.eitherDecode "{\"type\":\"plain_text\",\"text\":\"\"}" :: Either String PlainText of Left errorText -> not (null errorText); Right _ -> False)
  putStrLn "Adversarial relationships, nested accounting, exact bounds and Aeson adapters passed."