First prototype of audit-log
Has auditing for term-edits as an example
This commit is contained in:
parent
eae511ec02
commit
14efbd8656
12
models/audit
Normal file
12
models/audit
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
-- Table recording all significant changes of database-state for auditing purposes
|
||||||
|
TransactionLog
|
||||||
|
time UTCTime
|
||||||
|
instance InstanceId
|
||||||
|
initiator (CI Text) Maybe -- Case-insensitive user-identifier associated with performing this action
|
||||||
|
info Value -- JSON-encoded `Transaction`
|
||||||
|
request RequestInfo Maybe -- HTTP-Request info
|
||||||
|
-- Best guess of users affected by a change in database-state at time of transaction
|
||||||
|
TransactionLogAffected
|
||||||
|
transaction TransactionLogId
|
||||||
|
user (CI Text) -- Case-insensitive user-identifier
|
||||||
|
UniqueTransactionLogAffected transaction user
|
||||||
@ -117,6 +117,7 @@ dependencies:
|
|||||||
- lattices
|
- lattices
|
||||||
- hsass
|
- hsass
|
||||||
- semigroupoids
|
- semigroupoids
|
||||||
|
- http-types
|
||||||
|
|
||||||
other-extensions:
|
other-extensions:
|
||||||
- GeneralizedNewtypeDeriving
|
- GeneralizedNewtypeDeriving
|
||||||
|
|||||||
63
src/Audit.hs
Normal file
63
src/Audit.hs
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
module Audit
|
||||||
|
( module Audit.Types
|
||||||
|
, audit
|
||||||
|
, getRequestInfo
|
||||||
|
) where
|
||||||
|
|
||||||
|
|
||||||
|
import ClassyPrelude.Yesod
|
||||||
|
import Database.Persist.Sql
|
||||||
|
import Yesod.Auth
|
||||||
|
import Audit.Types
|
||||||
|
import Model
|
||||||
|
|
||||||
|
import Utils.Lens
|
||||||
|
import qualified Network.Wai as Wai
|
||||||
|
|
||||||
|
{-# ANN module ("HLint: ignore Use newtype instead of data" :: String) #-}
|
||||||
|
|
||||||
|
|
||||||
|
data RequestInfoException
|
||||||
|
= RICouldNotParseMethod Method
|
||||||
|
deriving (Show, Generic, Typeable)
|
||||||
|
|
||||||
|
instance Exception RequestInfoException
|
||||||
|
|
||||||
|
|
||||||
|
getRequestInfo :: (MonadHandler m, MonadThrow m) => m RequestInfo
|
||||||
|
getRequestInfo = do
|
||||||
|
wai <- waiRequest
|
||||||
|
riMethod <- either (throwM . RICouldNotParseMethod) return . parseMethod $ Wai.requestMethod wai
|
||||||
|
let riRemote = Wai.remoteHost wai
|
||||||
|
riPath = Wai.pathInfo wai
|
||||||
|
riQueryString = queryToQueryText $ Wai.queryString wai
|
||||||
|
return RequestInfo{..}
|
||||||
|
|
||||||
|
|
||||||
|
audit :: ( AuthId site ~ Key User
|
||||||
|
, AuthEntity site ~ User
|
||||||
|
, IsSqlBackend (YesodPersistBackend site)
|
||||||
|
, SqlBackendCanWrite (YesodPersistBackend site)
|
||||||
|
, HasInstanceID site InstanceId
|
||||||
|
, YesodAuthPersist site
|
||||||
|
)
|
||||||
|
=> [UserId] -- ^ Affected users
|
||||||
|
-> Transaction -- ^ Transaction to record
|
||||||
|
-> YesodDB site ()
|
||||||
|
-- ^ Log a transaction using information available from `HandlerT`:
|
||||||
|
--
|
||||||
|
-- - `transactionLogTime` is now
|
||||||
|
-- - `transactionLogInitiator` is currently logged in user (or none)
|
||||||
|
-- - `transactionLogRequest` is current HTTP-Request
|
||||||
|
audit affected (toJSON -> transactionLogInfo) = do
|
||||||
|
uid <- liftHandlerT maybeAuthId
|
||||||
|
|
||||||
|
transactionLogTime <- liftIO getCurrentTime
|
||||||
|
transactionLogInstance <- getsYesod $ view instanceID
|
||||||
|
transactionLogInitiator <- for uid $ fmap userIdent . getJust
|
||||||
|
transactionLogRequest <- Just <$> getRequestInfo
|
||||||
|
|
||||||
|
tlId <- insert TransactionLog{..}
|
||||||
|
|
||||||
|
affectedUsers <- forM affected getJust
|
||||||
|
insertMany_ [ TransactionLogAffected tlId aident | aident <- userIdent <$> affectedUsers ]
|
||||||
26
src/Audit/Types.hs
Normal file
26
src/Audit/Types.hs
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
module Audit.Types
|
||||||
|
( Transaction(..)
|
||||||
|
) where
|
||||||
|
|
||||||
|
import ClassyPrelude.Yesod hiding (derivePersistFieldJSON)
|
||||||
|
import Model.Types.JSON
|
||||||
|
import Model
|
||||||
|
|
||||||
|
import Data.Aeson.TH
|
||||||
|
import Utils.PathPiece
|
||||||
|
|
||||||
|
{-# ANN module ("HLint: ignore Use newtype instead of data" :: String) #-}
|
||||||
|
|
||||||
|
|
||||||
|
data Transaction
|
||||||
|
= TransactionTermEdit { transactionTerm :: TermId }
|
||||||
|
deriving (Eq, Ord, Read, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ constructorTagModifier = camelToPathPiece' 1
|
||||||
|
, fieldLabelModifier = camelToPathPiece' 1
|
||||||
|
, tagSingleConstructors = True
|
||||||
|
, sumEncoding = TaggedObject "transaction" "data"
|
||||||
|
} ''Transaction
|
||||||
|
|
||||||
|
derivePersistFieldJSON ''Transaction
|
||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
module Foundation where
|
module Foundation where
|
||||||
|
|
||||||
|
import ClassyPrelude.Yesod (getHttpManager)
|
||||||
import Import.NoFoundation
|
import Import.NoFoundation
|
||||||
import Database.Persist.Sql (ConnectionPool, runSqlPool)
|
import Database.Persist.Sql (ConnectionPool, runSqlPool)
|
||||||
import Text.Hamlet (hamletFile)
|
import Text.Hamlet (hamletFile)
|
||||||
@ -123,6 +124,12 @@ data UniWorX = UniWorX
|
|||||||
|
|
||||||
type SMTPPool = Pool SMTPConnection
|
type SMTPPool = Pool SMTPConnection
|
||||||
|
|
||||||
|
makeLenses_ ''UniWorX
|
||||||
|
instance HasInstanceID UniWorX InstanceId where
|
||||||
|
instanceID = _appInstanceID
|
||||||
|
instance HasHttpManager UniWorX Manager where
|
||||||
|
httpManager = _appHttpManager
|
||||||
|
|
||||||
-- This is where we define all of the routes in our application. For a full
|
-- This is where we define all of the routes in our application. For a full
|
||||||
-- explanation of the syntax, please see:
|
-- explanation of the syntax, please see:
|
||||||
-- http://www.yesodweb.com/book/routing-and-handlers
|
-- http://www.yesodweb.com/book/routing-and-handlers
|
||||||
@ -2190,12 +2197,6 @@ instance YesodAuth UniWorX where
|
|||||||
|
|
||||||
instance YesodAuthPersist UniWorX
|
instance YesodAuthPersist UniWorX
|
||||||
|
|
||||||
-- Useful when writing code that is re-usable outside of the Handler context.
|
|
||||||
-- An example is background jobs that send email.
|
|
||||||
-- This can also be useful for writing code that works across multiple Yesod applications.
|
|
||||||
instance HasHttpManager UniWorX where
|
|
||||||
getHttpManager = appHttpManager
|
|
||||||
|
|
||||||
unsafeHandler :: UniWorX -> Handler a -> IO a
|
unsafeHandler :: UniWorX -> Handler a -> IO a
|
||||||
unsafeHandler f h = do
|
unsafeHandler f h = do
|
||||||
logger <- makeLogger f
|
logger <- makeLogger f
|
||||||
|
|||||||
@ -181,7 +181,9 @@ termEditHandler term = do
|
|||||||
(FormSuccess res) -> do
|
(FormSuccess res) -> do
|
||||||
let tid = TermKey $ termName res
|
let tid = TermKey $ termName res
|
||||||
-- term <- runDB $ get $ TermKey termName
|
-- term <- runDB $ get $ TermKey termName
|
||||||
runDB $ repsert tid res
|
runDB $ do
|
||||||
|
repsert tid res
|
||||||
|
audit [] $ TransactionTermEdit tid
|
||||||
-- VOR INTERNATIONALISIERUNG:
|
-- VOR INTERNATIONALISIERUNG:
|
||||||
-- let tid = termToText $ termName res
|
-- let tid = termToText $ termName res
|
||||||
-- let msg = "Semester " `T.append` tid `T.append` " erfolgreich editiert."
|
-- let msg = "Semester " `T.append` tid `T.append` " erfolgreich editiert."
|
||||||
|
|||||||
@ -3,7 +3,7 @@ module Import.NoFoundation
|
|||||||
, MForm
|
, MForm
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField, identifyForm)
|
import ClassyPrelude.Yesod as Import hiding (formatTime, derivePersistFieldJSON, addMessage, addMessageI, (.=), MForm, Proxy, foldlM, static, boolField, identifyForm, HasHttpManager(..))
|
||||||
import Model as Import
|
import Model as Import
|
||||||
import Model.Types.JSON as Import
|
import Model.Types.JSON as Import
|
||||||
import Model.Migration as Import
|
import Model.Migration as Import
|
||||||
@ -34,6 +34,7 @@ import Data.Pool as Import (Pool)
|
|||||||
import Network.HaskellNet.SMTP as Import (SMTPConnection)
|
import Network.HaskellNet.SMTP as Import (SMTPConnection)
|
||||||
|
|
||||||
import Mail as Import
|
import Mail as Import
|
||||||
|
import Audit as Import
|
||||||
|
|
||||||
import Data.Data as Import (Data)
|
import Data.Data as Import (Data)
|
||||||
import Data.Typeable as Import (Typeable)
|
import Data.Typeable as Import (Typeable)
|
||||||
|
|||||||
@ -2,7 +2,6 @@ module Model.Migration.Types where
|
|||||||
|
|
||||||
import ClassyPrelude.Yesod
|
import ClassyPrelude.Yesod
|
||||||
import Data.Aeson.TH (deriveJSON, defaultOptions)
|
import Data.Aeson.TH (deriveJSON, defaultOptions)
|
||||||
import Database.Persist.Sql
|
|
||||||
|
|
||||||
import qualified Model as Current
|
import qualified Model as Current
|
||||||
import qualified Model.Types.JSON as Current
|
import qualified Model.Types.JSON as Current
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
{-# LANGUAGE GeneralizedNewtypeDeriving
|
{-# LANGUAGE GeneralizedNewtypeDeriving
|
||||||
, UndecidableInstances
|
, UndecidableInstances
|
||||||
#-}
|
#-}
|
||||||
{-# OPTIONS_GHC -fno-warn-orphans #-} -- for instance PathPiece (CI Text)
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
|
|
||||||
module Model.Types
|
module Model.Types
|
||||||
( module Model.Types
|
( module Model.Types
|
||||||
@ -9,6 +9,7 @@ module Model.Types
|
|||||||
, module Mail
|
, module Mail
|
||||||
, module Utils.DateTime
|
, module Utils.DateTime
|
||||||
, module Data.UUID.Types
|
, module Data.UUID.Types
|
||||||
|
, StdMethod, QueryText, SockAddr, PortNumber
|
||||||
) where
|
) where
|
||||||
|
|
||||||
import ClassyPrelude
|
import ClassyPrelude
|
||||||
@ -26,6 +27,7 @@ import Data.Universe
|
|||||||
import Data.Universe.Helpers
|
import Data.Universe.Helpers
|
||||||
import Data.UUID.Types (UUID)
|
import Data.UUID.Types (UUID)
|
||||||
import qualified Data.UUID.Types as UUID
|
import qualified Data.UUID.Types as UUID
|
||||||
|
import Data.Scientific (Scientific, toBoundedInteger)
|
||||||
|
|
||||||
import Data.Default
|
import Data.Default
|
||||||
|
|
||||||
@ -69,6 +71,7 @@ import Utils.DateTime (DateTimeFormat(..), SelDateTimeFormat(..))
|
|||||||
|
|
||||||
import Numeric.Natural
|
import Numeric.Natural
|
||||||
import Data.Word.Word24 (Word24)
|
import Data.Word.Word24 (Word24)
|
||||||
|
import Data.Word (Word16)
|
||||||
import Data.Bits
|
import Data.Bits
|
||||||
import Data.Ix
|
import Data.Ix
|
||||||
import Data.List (genericIndex, elemIndex)
|
import Data.List (genericIndex, elemIndex)
|
||||||
@ -80,6 +83,10 @@ import Data.Text.Metrics (damerauLevenshtein)
|
|||||||
|
|
||||||
import Data.Binary (Binary)
|
import Data.Binary (Binary)
|
||||||
|
|
||||||
|
import Network.HTTP.Types.URI (QueryText)
|
||||||
|
import Network.HTTP.Types.Method (StdMethod(..))
|
||||||
|
import Network.Socket
|
||||||
|
|
||||||
|
|
||||||
instance PathPiece UUID where
|
instance PathPiece UUID where
|
||||||
fromPathPiece = UUID.fromString . unpack
|
fromPathPiece = UUID.fromString . unpack
|
||||||
@ -786,6 +793,37 @@ deriveJSON defaultOptions
|
|||||||
derivePersistFieldJSON ''LecturerType
|
derivePersistFieldJSON ''LecturerType
|
||||||
|
|
||||||
|
|
||||||
|
deriving instance Generic StdMethod
|
||||||
|
deriving instance Generic SockAddr
|
||||||
|
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ constructorTagModifier = camelToPathPiece
|
||||||
|
} ''StdMethod
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ constructorTagModifier = camelToPathPiece' 2
|
||||||
|
} ''SockAddr
|
||||||
|
|
||||||
|
instance FromJSON PortNumber where
|
||||||
|
parseJSON = Aeson.withScientific "PortNumber" $ \sciNum -> case toBoundedInteger sciNum of
|
||||||
|
Just int -> return $ fromIntegral (int :: Word16)
|
||||||
|
Nothing -> fail "Expected whole number of plausible size to denote port"
|
||||||
|
instance ToJSON PortNumber where
|
||||||
|
toJSON = toJSON . (fromIntegral :: PortNumber -> Scientific)
|
||||||
|
|
||||||
|
|
||||||
|
data RequestInfo = RequestInfo
|
||||||
|
{ riMethod :: StdMethod
|
||||||
|
, riRemote :: SockAddr
|
||||||
|
, riPath :: [Text]
|
||||||
|
, riQueryString :: QueryText
|
||||||
|
} deriving (Eq, Ord, Show, Generic, Typeable)
|
||||||
|
|
||||||
|
deriveJSON defaultOptions
|
||||||
|
{ fieldLabelModifier = camelToPathPiece' 1
|
||||||
|
} ''RequestInfo
|
||||||
|
derivePersistFieldJSON ''RequestInfo
|
||||||
|
|
||||||
|
|
||||||
-- Type synonyms
|
-- Type synonyms
|
||||||
|
|
||||||
type Email = Text
|
type Email = Text
|
||||||
|
|||||||
@ -28,10 +28,10 @@ derivePersistFieldJSON tName = do
|
|||||||
| otherwise = cxt [[t|PersistField|] `appT` t]
|
| otherwise = cxt [[t|PersistField|] `appT` t]
|
||||||
sequence
|
sequence
|
||||||
[ instanceD iCxt ([t|PersistField|] `appT` t)
|
[ instanceD iCxt ([t|PersistField|] `appT` t)
|
||||||
[ funD (mkName "toPersistValue")
|
[ funD 'toPersistValue
|
||||||
[ clause [] (normalB [e|PersistDbSpecific . LBS.toStrict . JSON.encode|]) []
|
[ clause [] (normalB [e|PersistDbSpecific . LBS.toStrict . JSON.encode|]) []
|
||||||
]
|
]
|
||||||
, funD (mkName "fromPersistValue")
|
, funD 'fromPersistValue
|
||||||
[ do
|
[ do
|
||||||
bs <- newName "bs"
|
bs <- newName "bs"
|
||||||
clause [[p|PersistDbSpecific $(varP bs)|]] (normalB [e|first pack $ JSON.eitherDecodeStrict' $(varE bs)|]) []
|
clause [[p|PersistDbSpecific $(varP bs)|]] (normalB [e|first pack $ JSON.eitherDecodeStrict' $(varE bs)|]) []
|
||||||
@ -45,7 +45,7 @@ derivePersistFieldJSON tName = do
|
|||||||
]
|
]
|
||||||
]
|
]
|
||||||
, instanceD sqlCxt ([t|PersistFieldSql|] `appT` t)
|
, instanceD sqlCxt ([t|PersistFieldSql|] `appT` t)
|
||||||
[ funD (mkName "sqlType")
|
[ funD 'sqlType
|
||||||
[ clause [wildP] (normalB [e|SqlOther "jsonb"|]) []
|
[ clause [wildP] (normalB [e|SqlOther "jsonb"|]) []
|
||||||
]
|
]
|
||||||
]
|
]
|
||||||
|
|||||||
@ -14,7 +14,7 @@ import ClassyPrelude.Yesod
|
|||||||
import Data.UUID (UUID)
|
import Data.UUID (UUID)
|
||||||
import qualified Control.Exception as Exception
|
import qualified Control.Exception as Exception
|
||||||
import Data.Aeson (Result (..), fromJSON, withObject
|
import Data.Aeson (Result (..), fromJSON, withObject
|
||||||
,(.!=), (.:?), withScientific
|
,(.!=), (.:?)
|
||||||
)
|
)
|
||||||
import qualified Data.Aeson.Types as Aeson
|
import qualified Data.Aeson.Types as Aeson
|
||||||
import Data.Aeson.TH
|
import Data.Aeson.TH
|
||||||
@ -272,11 +272,6 @@ deriveJSON
|
|||||||
}
|
}
|
||||||
''LogLevel
|
''LogLevel
|
||||||
|
|
||||||
instance FromJSON HaskellNet.PortNumber where
|
|
||||||
parseJSON = withScientific "PortNumber" $ \sciNum -> case toBoundedInteger sciNum of
|
|
||||||
Just int -> return $ fromIntegral (int :: Word16)
|
|
||||||
Nothing -> fail "Expected whole number of plausible size to denote port"
|
|
||||||
|
|
||||||
deriveFromJSON
|
deriveFromJSON
|
||||||
defaultOptions
|
defaultOptions
|
||||||
{ constructorTagModifier = unpack . intercalate "-" . Text.splitOn "_" . toLower . pack
|
{ constructorTagModifier = unpack . intercalate "-" . Text.splitOn "_" . toLower . pack
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
|
{-# LANGUAGE UndecidableInstances #-}
|
||||||
|
{-# OPTIONS_GHC -fno-warn-orphans #-}
|
||||||
|
|
||||||
module Utils.Lens ( module Utils.Lens ) where
|
module Utils.Lens ( module Utils.Lens ) where
|
||||||
|
|
||||||
import Import.NoFoundation
|
import ClassyPrelude.Yesod hiding (HasHttpManager(..))
|
||||||
|
import qualified ClassyPrelude.Yesod as Yesod (HasHttpManager(..))
|
||||||
|
import Model
|
||||||
|
|
||||||
import Control.Lens as Utils.Lens hiding ((<.>))
|
import Control.Lens as Utils.Lens hiding ((<.>))
|
||||||
import Control.Lens.Extras as Utils.Lens (is)
|
import Control.Lens.Extras as Utils.Lens (is)
|
||||||
import Utils.Lens.TH as Utils.Lens (makeLenses_, makeClassyFor_)
|
import Utils.Lens.TH as Utils.Lens (makeLenses_, makeClassyFor_)
|
||||||
@ -94,3 +100,15 @@ makeLenses_ ''StudyTermCandidate
|
|||||||
-- makeClassy_ ''Load
|
-- makeClassy_ ''Load
|
||||||
|
|
||||||
|
|
||||||
|
--------------------------
|
||||||
|
-- Fields for `UniWorX` --
|
||||||
|
--------------------------
|
||||||
|
|
||||||
|
class HasInstanceID s a | s -> a where
|
||||||
|
instanceID :: Lens' s a
|
||||||
|
|
||||||
|
class HasHttpManager s a | s -> a where
|
||||||
|
httpManager :: Lens' s a
|
||||||
|
|
||||||
|
instance HasHttpManager s Manager => Yesod.HasHttpManager s where
|
||||||
|
getHttpManager = view httpManager
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import Settings
|
|||||||
import Control.Lens (review, preview)
|
import Control.Lens (review, preview)
|
||||||
import Data.Aeson (Value)
|
import Data.Aeson (Value)
|
||||||
import qualified Data.Aeson as Aeson
|
import qualified Data.Aeson as Aeson
|
||||||
|
import Data.Word (Word16)
|
||||||
|
|
||||||
import MailSpec ()
|
import MailSpec ()
|
||||||
|
|
||||||
@ -145,6 +146,20 @@ instance Arbitrary LecturerType where
|
|||||||
arbitrary = genericArbitrary
|
arbitrary = genericArbitrary
|
||||||
shrink = genericShrink
|
shrink = genericShrink
|
||||||
|
|
||||||
|
instance Arbitrary StdMethod where
|
||||||
|
arbitrary = genericArbitrary
|
||||||
|
shrink = genericShrink
|
||||||
|
|
||||||
|
instance Arbitrary SockAddr where
|
||||||
|
arbitrary = genericArbitrary
|
||||||
|
shrink = genericShrink
|
||||||
|
|
||||||
|
instance Arbitrary PortNumber where
|
||||||
|
arbitrary = (fromIntegral :: Word16 -> PortNumber) <$> arbitrary
|
||||||
|
|
||||||
|
instance Arbitrary RequestInfo where
|
||||||
|
arbitrary = genericArbitrary
|
||||||
|
shrink = genericShrink
|
||||||
|
|
||||||
spec :: Spec
|
spec :: Spec
|
||||||
spec = do
|
spec = do
|
||||||
@ -205,6 +220,14 @@ spec = do
|
|||||||
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
|
[ eqLaws, ordLaws, showReadLaws, jsonLaws, persistFieldLaws ]
|
||||||
lawsCheckHspec (Proxy @LecturerType)
|
lawsCheckHspec (Proxy @LecturerType)
|
||||||
[ eqLaws, ordLaws, showReadLaws, boundedEnumLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws ]
|
[ eqLaws, ordLaws, showReadLaws, boundedEnumLaws, finiteLaws, jsonLaws, pathPieceLaws, persistFieldLaws ]
|
||||||
|
lawsCheckHspec (Proxy @StdMethod)
|
||||||
|
[ eqLaws, ordLaws, showReadLaws, jsonLaws ]
|
||||||
|
lawsCheckHspec (Proxy @SockAddr)
|
||||||
|
[ eqLaws, ordLaws, jsonLaws ]
|
||||||
|
lawsCheckHspec (Proxy @PortNumber)
|
||||||
|
[ eqLaws, ordLaws, showReadLaws, jsonLaws ]
|
||||||
|
lawsCheckHspec (Proxy @RequestInfo)
|
||||||
|
[ eqLaws, ordLaws, jsonLaws, persistFieldLaws ]
|
||||||
|
|
||||||
describe "TermIdentifier" $ do
|
describe "TermIdentifier" $ do
|
||||||
it "has compatible encoding/decoding to/from Text" . property $
|
it "has compatible encoding/decoding to/from Text" . property $
|
||||||
|
|||||||
Reference in New Issue
Block a user