Skip to content

Commit

Permalink
Reorganize tests, add mock test, add ci for mock tests and spec tests
Browse files Browse the repository at this point in the history
  • Loading branch information
cotrone committed Sep 20, 2023
1 parent aaa8172 commit 78afc99
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 38 deletions.
19 changes: 19 additions & 0 deletions .github/workflows/web-push-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
name: "Web Push Tests"
on:
pull_request:
push:
jobs:
tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: cachix/install-nix-action@v22
with:
github_access_token: ${{ secrets.GITHUB_TOKEN }}
- uses: cachix/cachix-action@v12
with:
name: web-push
authToken: ${{ secrets.CACHIX_AUTH_TOKEN }}
- uses: DeterminateSystems/magic-nix-cache-action@v2
- run: nix build .#web-push-test
- run: nix run .#web-push-test
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@

[![Example Browser Tests](https://github.com/cotrone/web-push/actions/workflows/web-push-example.yml/badge.svg)](https://github.com/cotrone/web-push/actions/workflows/web-push-example.yml)

Helper functions to send messages using Web Push protocol.
Send web push notifications to browsers

Forked from https://github.com/sarthakbagaria/web-push

## Usage

Expand Down
12 changes: 11 additions & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
flake-utils.lib.eachSystem [ "x86_64-linux" "x86_64-darwin" "aarch64-darwin" ] (system:
let
compiler-nix-name = "ghc928";
# Web push is a JS project designed to emulate the behaviour of browsers
# Web push is a JS project that emulates the behaviour of push notifications in browsers and the servers that deliver notifications to them
web-push-testing = pkgs.buildNpmPackage {
pname = "web-push-testing";
version = "1.0.0";
Expand Down Expand Up @@ -48,6 +48,15 @@
];
};
})];
# Wrap the web-push-test binary to include the web-push-testing-server binary in the PATH
web-push-test = pkgs.symlinkJoin {
name = "web-push-test";
nativeBuildInputs = [ pkgs.makeWrapper ];
paths = [ web-push-testing flake.packages."web-push:test:web-push-test" ];
postFixup = ''
wrapProgram $out/bin/web-push-test --prefix PATH : ${web-push-testing}/bin
'';
};
web-push-example-test = pkgs.writeScriptBin "web-push-example-test" ''
export CHROME_BINARY=${pkgs.google-chrome}/bin/google-chrome-stable
export FIREFOX_BINARY=${pkgs.firefox}/bin/firefox
Expand Down Expand Up @@ -103,6 +112,7 @@
packages = flake.packages // {
web-push-testing = web-push-testing;
web-push-example-test = web-push-example-test;
web-push-test = web-push-test;
};
});
}
22 changes: 10 additions & 12 deletions src/Web/WebPush.hs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ module Web.WebPush (
, VAPIDKeys
, VAPIDKeysMinDetails(..)
, PushNotification
, PushNotificationCreated(..)
, PushNotificationError(..)
, PushEndpoint
, PushP256dh
Expand Down Expand Up @@ -86,7 +87,6 @@ generateVAPIDKeys = do
, publicCoordY = pubY
}


-- | Read VAPID key pair from the 3 integers minimally representing a unique key pair.
readVAPIDKeys :: VAPIDKeysMinDetails -> VAPIDKeys
readVAPIDKeys VAPIDKeysMinDetails {..} =
Expand Down Expand Up @@ -120,7 +120,8 @@ sendPushNotification vapidKeys httpManager pushNotification = do
let proto = if secure initReq then "https://" else "http://"
payload = PushNotificationPayload {
payloadAudience = TE.decodeUtf8With TE.lenientDecode $ proto <> host initReq
, payloadExpiration = round time + 3000 -- TODO configurable expiration
, payloadExpiration = round time + fromIntegral (pushNotification ^. pushExpireInSeconds)

, payloadSubject = "mailto:" <> pushNotification ^. pushSenderEmail -- This is marked as optional in the spec but is required by at least firefox and chrome
}
jwt <- webPushJWT vapidKeys payload
Expand Down Expand Up @@ -170,7 +171,6 @@ sendPushNotification vapidKeys httpManager pushNotification = do
toPushNotificationError (PushEncryptCryptoError err) = MessageEncryptionFailed err
toPushNotificationError (PushEncryptParseKeyError err) = KeyParseError err
toPushNotificationError (PushEncodeApplicationPublicKeyError err) = ApplicationKeyEncodeError err

cryptoKeyHeader :: ECDSA.PublicKey -> ECC.Point -> Either String C8.ByteString
cryptoKeyHeader vapidPublic ecdhServerPublic = do
let encodePublic = fmap b64UrlNoPadding . ecPublicKeyToBytes
Expand All @@ -196,8 +196,6 @@ sendPushNotification vapidKeys httpManager pushNotification = do
-- decode the message through service workers on browsers before trying to read the JSON
plainMessage64Encoded = A.encode $ pushNotification ^. pushMessage



type PushEndpoint = T.Text
type PushP256dh = T.Text
type PushAuth = T.Text
Expand Down Expand Up @@ -254,11 +252,11 @@ data VAPIDKeysMinDetails = VAPIDKeysMinDetails { privateNumber :: Integer
-- |'RecepientEndpointNotFound' comes up when the endpoint is no longer recognized by the push service.
-- This may happen if the user has cancelled the push subscription, and hence deleted the endpoint.
-- You may want to delete the endpoint from database in this case, or if 'EndpointParseFailed'.
data PushNotificationError = EndpointParseFailed HttpException
| MessageEncryptionFailed CryptoError
| KeyParseError CryptoError
| ApplicationKeyEncodeError String
| RecepientEndpointNotFound
| PushRequestFailed SomeException
| PushRequestNotCreated (Response BSL.ByteString)
data PushNotificationError = EndpointParseFailed HttpException -- ^ Endpoint URL could not be parsed
| MessageEncryptionFailed CryptoError -- ^ Message encryption failed
| KeyParseError CryptoError -- ^ Public key parsing failed
| ApplicationKeyEncodeError String -- ^ Application server key encoding failed
| RecepientEndpointNotFound -- ^ The endpoint is no longer recognized by the push service
| PushRequestFailed SomeException -- ^ Push request failed
| PushRequestNotCreated (Response BSL.ByteString) -- ^ Push request failed with non-201 status code
deriving (Show, Exception)
9 changes: 5 additions & 4 deletions src/Web/WebPush/Internal.hs
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,15 @@ module Web.WebPush.Internal where
import Control.Monad.IO.Class (MonadIO, liftIO)
import Crypto.Cipher.AES (AES128)
import qualified Crypto.Cipher.Types as Cipher
import qualified Crypto.ECC
import Crypto.Error (CryptoError, eitherCryptoError)
import Crypto.Hash.Algorithms (SHA256 (..))
import qualified Crypto.MAC.HMAC as HMAC
import qualified Crypto.PubKey.ECC.DH as ECDH
import qualified Crypto.PubKey.ECC.ECDSA as ECDSA
import qualified Crypto.PubKey.ECC.P256 as P256
import qualified Crypto.PubKey.ECC.Types as ECC
import qualified Crypto.PubKey.ECC.Types as ECCTypes
import Data.Aeson ((.=))
import qualified Data.Aeson as A
import Data.Bifunctor
Expand All @@ -22,13 +25,10 @@ import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64.URL
import qualified Data.ByteString.Lazy as LB
import Data.Data
import Data.Text (Text)
import Data.Word (Word16, Word64, Word8)
import GHC.Int (Int64)
import qualified Crypto.ECC
import Data.Data
import qualified Crypto.PubKey.ECC.Types as ECCTypes
import qualified Crypto.PubKey.ECC.P256 as P256

type VAPIDKeys = ECDSA.KeyPair

Expand Down Expand Up @@ -191,5 +191,6 @@ bytes32Int (d,c,b,a) = (Bits.shiftL (fromIntegral d) (64*3)) +
(fromIntegral a)

-- at most places we do not need the padding in base64 url encoding
-- TODO this could be removed
b64UrlNoPadding :: ByteString -> ByteString
b64UrlNoPadding = fst . BS.breakSubstring "=" . B64.URL.encode
15 changes: 15 additions & 0 deletions test/Main.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
module Main where

import WebPushEncryptionSpec
import WebPushMock
import Test.Tasty
import Test.Tasty.Hspec

main :: IO ()
main = do
encryptionSpec <- testSpecs spec
defaultMain $
testGroup "Tests" [
testGroup "WebPushEncryptionSpec" encryptionSpec
, testGroup "Mock" [testSendMessage]
]
1 change: 0 additions & 1 deletion test/Spec.hs

This file was deleted.

26 changes: 13 additions & 13 deletions test/WebPushEncryptionSpec.hs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Web.WebPush.Internal
import Test.Hspec

import qualified Data.Binary as Binary
import qualified Data.ByteString.Base64.URL as B64.URL
import Data.ByteString.Base64.URL
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LB
Expand All @@ -22,44 +22,44 @@ spec = describe "Web Push Encryption Test" $ do
{ applicationServerPrivateKey = bytes32Int $ bsTo32Bytes $ BS.concat [ "nCScek-QpEjmOOlT-rQ38nZ"
, "zvdPlqa00Zy0i6m2OJvY"
]
, userAgentPublicKeyBytes = B64.URL.decodeLenient $ BS.concat [ "BCEkBjzL8Z3C-oi2Q7oE5t2Np-"
, userAgentPublicKeyBytes = decodeBase64Lenient $ BS.concat [ "BCEkBjzL8Z3C-oi2Q7oE5t2Np-"
, "p7osjGLg93qUP0wvqR"
, "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQU"
]
, authenticationSecret = B64.URL.decodeLenient "R29vIGdvbyBnJyBqb29iIQ"
, salt = B64.URL.decodeLenient "lngarbyKfMoi9Z75xYXmkg"
, authenticationSecret = decodeBase64Lenient "R29vIGdvbyBnJyBqb29iIQ"
, salt = decodeBase64Lenient "lngarbyKfMoi9Z75xYXmkg"
, plainText = "I am the walrus"
, paddingLength = 0
}

encryptionOutput = webPushEncrypt encryptionInput

expectedEncryptionOutput = EncryptionOutput
{ sharedECDHSecretBytes = B64.URL.decodeLenient $ BS.concat [ "RNjC-"
{ sharedECDHSecretBytes = decodeBase64Lenient $ BS.concat [ "RNjC-"
-- NOTE: the specs example might have printed this wrong
-- there should be two consecutive hyphens and not one
-- near the end of the encoded string, just before "NOQ6Y"
, "NVW4BGJbxWPW7G2mowsLeDa53LYKYm4--NOQ6Y"
]
, inputKeyingMaterialBytes = B64.URL.decodeLenient $ BS.concat [ "EhpZec37Ptm4IRD5-jtZ0q6r1iK5vYmY1tZwtN8"
, inputKeyingMaterialBytes = decodeBase64Lenient $ BS.concat [ "EhpZec37Ptm4IRD5-jtZ0q6r1iK5vYmY1tZwtN8"
, "fbZY"
]
, contentEncryptionKeyContext = B64.URL.decodeLenient $ BS.concat [ "Q29udGVudC1FbmNvZGluZzogYWVzZ2NtAFAtMjU2AABB BCEkBjzL8Z3C-"
, contentEncryptionKeyContext = decodeBase64Lenient $ BS.concat [ "Q29udGVudC1FbmNvZGluZzogYWVzZ2NtAFAtMjU2AABB BCEkBjzL8Z3C-"
, "oi2Q7oE5t2Np-p7osjGLg93qUP0wvqR"
, "T21EEWyf0cQDQcakQMqz4hQKYOQ3il2nNZct4HgAUQUA"
, "QQTaEQ22_OCRpvIOWeQhcbq0qrF1iddSLX1xFmFSxPOW"
, "OwmJA417CBHOGqsWGkNRvAapFwiegz6Q61rXVo_5roB1"
]
, contentEncryptionKey = B64.URL.decodeLenient "AN2-xhvFWeYh5z0fcDu0Ww"
, nonceContext = B64.URL.decodeLenient $ BS.concat [ "Q29udGVudC1FbmNvZGluZzogbm9uY2UAUC0yNT"
, contentEncryptionKey = decodeBase64Lenient "AN2-xhvFWeYh5z0fcDu0Ww"
, nonceContext = decodeBase64Lenient $ BS.concat [ "Q29udGVudC1FbmNvZGluZzogbm9uY2UAUC0yNT"
, "YAAEEE ISQGPMvxncL6iLZDugTm3Y2n6nuiyMYuD3epQ_TC-pFP"
, "bUQRbJ_RxANBxqRAyrPiFApg5DeKXac1ly3geABRBQBB"
, "BNoRDbb84JGm8g5Z5CFxurSqsXWJ11ItfXEWYVLE85Y7"
, "CYkDjXsIEc4aqxYaQ1G8BqkXCJ6DPpDrWtdWj_mugHU"
]
, nonce = B64.URL.decodeLenient "JY1Okw5rw1Drkg9J"
, paddedPlainText = B64.URL.decodeLenient "AABJIGFtIHRoZSB3YWxydXM"
, encryptedMessage = B64.URL.decodeLenient $ BS.concat [ "6nqAQUME8hNqw5J3kl8cpVVJylXKYqZOeseZG8UueKpA" ]
, nonce = decodeBase64Lenient "JY1Okw5rw1Drkg9J"
, paddedPlainText = decodeBase64Lenient "AABJIGFtIHRoZSB3YWxydXM"
, encryptedMessage = decodeBase64Lenient $ BS.concat [ "6nqAQUME8hNqw5J3kl8cpVVJylXKYqZOeseZG8UueKpA" ]
}

it "should match the shared secret" $ do
Expand Down Expand Up @@ -90,4 +90,4 @@ spec = describe "Web Push Encryption Test" $ do

where
bsTo32Bytes :: ByteString -> Bytes32
bsTo32Bytes = Binary.decode . LB.fromStrict . B64.URL.decodeLenient
bsTo32Bytes = Binary.decode . LB.fromStrict . decodeBase64Lenient
134 changes: 134 additions & 0 deletions test/WebPushMock.hs
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module WebPushMock where

import Control.Lens hiding ((.=))
import Data.Aeson
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import Data.ByteString.Base64.URL
import qualified Data.ByteString.Lazy as BSL
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.Encoding as TE
import Data.Time
import qualified Network.HTTP.Client as HTTP
import Network.Wreq
import System.Process
import Test.Tasty
import Test.Tasty.HUnit
import Web.WebPush

{-
These are tests to be ran against a mock web-push server from https://github.com/marc1706/web-push-testing
-}

testSendMessage :: TestTree
testSendMessage =
withResource initWebPushTestingServer terminateProcess $ \_ -> do
testCaseSteps "Mock subscription" $ \step -> do
step "Checking status"
_status <- webPushStatus

keys <- either fail (pure . readVAPIDKeys) =<< generateVAPIDKeys
publicKeyBytes <- either fail (pure . BS.pack) $ vapidPublicKeyBytes keys
let subscriptionOptions = SubscribeOptions True publicKeyBytes

step "Creating a test subscription to the mock server"
subscribeResponse <- webPushSubscribe subscriptionOptions
let subscription = subscribeResponse ^. responseBody

step "Sending message through the mock server"
time <- getCurrentTime
let notification = (mkPushNotification (endpoint subscription) (p256dh subscription) (auth' subscription))
& pushExpireInSeconds .~ 60 * 60 * 12
& pushMessage .~ message
& pushSenderEmail .~ "test@example.com"
message :: Value
message = object [
"title" .= ("Web Push Test" :: Text)
, "body" .= ("Hello" :: Text)
, "icon" .= ("" :: Text)
, "tag" .= Text.pack (show time)
, "url" .= ("http://localhost:3000" :: Text)
]
encodedMessage = TE.decodeUtf8 $ BSL.toStrict $ encode message
endpointManager <- HTTP.newManager HTTP.defaultManagerSettings
either (assertFailure . show) (const (pure ())) =<< sendPushNotification keys endpointManager notification

step "Getting notifications"
notificationsResponse <- webPushGetNotificationsFor (clientHash subscription)
let notifications = notificationsResponse ^. responseBody . to messages
assertEqual "One notification" 1 (length notifications)
assertEqual "Notification content" encodedMessage (head notifications)

webPushTestingPort :: Int
webPushTestingPort = 8090

webPushTestingUrl :: String
webPushTestingUrl = "http://localhost:" <> show webPushTestingPort

-- Initializes the web push testing server binary from path
initWebPushTestingServer :: IO ProcessHandle
initWebPushTestingServer = do
(_stdIn, _stdOut, _stdErr, procHandle) <- createProcess (proc "web-push-testing-server" [show webPushTestingPort])
pure procHandle

{-
API for the mock server
-}
webPushStatus :: IO (Response BSL.ByteString)
webPushStatus = post (webPushTestingUrl <> "/status") (mempty :: ByteString)

newtype ClientHash = ClientHash {
unClientHash :: Text
} deriving (Eq, Ord, Show)

instance ToJSON ClientHash where
toJSON (ClientHash hash) = object ["clientHash" .= hash]

data SubscribeOptions = SubscribeOptions {
userVisibleOnly :: Bool,
applicationServerKey :: ByteString
} deriving (Eq, Ord, Show)

instance ToJSON SubscribeOptions where
toJSON opts = object [
"userVisibleOnly" .= stringlyBool (userVisibleOnly opts)
, "applicationServerKey" .= encodeBase64Unpadded (applicationServerKey opts)
]
where
stringlyBool :: Bool -> Text
stringlyBool True = "true"
stringlyBool False = "false"

data SubscriptionResult = SubscriptionResult {
endpoint :: Text,
p256dh :: Text,
auth' :: Text,
clientHash :: ClientHash
} deriving (Eq, Ord, Show)

instance FromJSON SubscriptionResult where
parseJSON = withObject "SubscriptionResult" $ \o -> do
dataObj <- o .: "data"
SubscriptionResult
<$> dataObj .: "endpoint"
<*> (dataObj .: "keys" >>= (.: "p256dh"))
<*> (dataObj .: "keys" >>= (.: "auth"))
<*> (ClientHash <$> dataObj .: "clientHash")

webPushSubscribe :: SubscribeOptions -> IO (Response SubscriptionResult)
webPushSubscribe opts = asJSON =<< post (webPushTestingUrl <> "/subscribe") (toJSON opts)

data GetNotifications = GetNotifications {
messages :: [Text]
} deriving (Eq, Ord, Show)

instance FromJSON GetNotifications where
parseJSON = withObject "GetNotifications" $ \o -> do
d <- o .: "data"
GetNotifications <$> d .: "messages"

webPushGetNotificationsFor :: ClientHash -> IO (Response GetNotifications)
webPushGetNotificationsFor client = asJSON =<< post (webPushTestingUrl <> "/get-notifications") (toJSON client)

0 comments on commit 78afc99

Please sign in to comment.