+Message-ID: <5dce90f2-f1e5-eecb-672e-f39def728f44@gmail.com>
+Subject: Alternative
+
+--------------8730PPHC3FpcshavBTup7Fxz
+Content-Type: multipart/mixed; boundary="------------CCuN7f3eLVIM0a1fSlNjk0pq"
+
+--------------CCuN7f3eLVIM0a1fSlNjk0pq
+Content-Type: multipart/alternative;
+ boundary="------------5kz0EAFr3j63ikmxfQPRt0bq"
+
+--------------5kz0EAFr3j63ikmxfQPRt0bq
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: base64
+
+VGhpcyBSaWNoIGZvcm1hdGVkIHRleHQNCg0KICAqIFdoYXQga2luZCBvZiBzaG9lcyBkbyBu
+aW5qYXMgd2Vhcj8gKlNuZWFrZXJzISoNCiAgKiBIb3cgZG9lcyBhIHBlbmd1aW4gYnVpbGQg
+aXRzIGhvdXNlPyAqSWdsb29zIGl0IHRvZ2V0aGVyKg0KDQpUaGlzIFJpY2ggZm9ybWF0ZWQg
+dGV4dA0KDQogICogL1doYXQga2luZCBvZiBzaG9lcyBkbyBuaW5qYXMgd2Vhcj8gLypTbmVh
+a2VycyEqDQogICogL0hvdyBkb2VzIGEgcGVuZ3VpbiBidWlsZCBpdHMgaG91c2U/LyoqXy8q
+SWdsb29zIGl0IHRvZ2V0aGVyLiovXw0KDQoNCg0K
+--------------5kz0EAFr3j63ikmxfQPRt0bq
+Content-Type: text/html; charset=UTF-8
+Content-Transfer-Encoding: quoted-printable
+
+
+
+
+
+
+
+ This Rich formated text
+
+
+ - What kind of shoes do ninjas wear? Sneakers!
+ - How does a penguin build its house? Igloos it together=
+li>
+
+
+ This Rich formated
+ text
+
+ - What kind of shoes do ninjas wear? Sneakers!
=
+
+ - How does a penguin build its house? Iglo=
+os
+ it together.
+
+
+
+
+
+
+
+
+
+
+
+--------------5kz0EAFr3j63ikmxfQPRt0bq--
+--------------CCuN7f3eLVIM0a1fSlNjk0pq
+Content-Type: application/pgp-keys; name="OpenPGP_0x161C0875822359F7.asc"
+Content-Disposition: attachment; filename="OpenPGP_0x161C0875822359F7.asc"
+Content-Description: OpenPGP public key
+Content-Transfer-Encoding: quoted-printable
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+6h8SuYtgnD84965xRiVAgtH4wCPN544N8CE=3D
+=3DNmqc
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------CCuN7f3eLVIM0a1fSlNjk0pq--
+
+--------------8730PPHC3FpcshavBTup7Fxz--
+
+--e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename=OpenPGP_signature
+Content-Type: application/pgp-signature; name=OpenPGP_signature.asc
+
+-----BEGIN PGP SIGNATURE-----
+Version: GopenPGP 2.7.1
+Comment: https://gopenpgp.org
+
+wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmRH94EFAwAAAAAACgkQ
+FhwIdYIjWfcFfgf/XHYGr9+DcntW0TLe6dO82xhLuO6HFmQnnChnfNHKA5U6pOeU
+5wJoEFL2O4WEU1hbuf5K0wpgPi8ZF+r966bCUTt+tYT/p7sKV0OXYJjtPYxiL+ju
+RaNZgK3rIDyi2DNjbRXSV1Y7H2S3pMLAwPio7ovNpe3OgfkAgFDdiG+NnXl8CzqN
+suhMwqJbLJRWlEaX7UdbcLimpNtPIU7wtr4YV0NIsqt3EV7AeHnVI9cHJMLvF5tB
+VrnkxXbdO3xEylUB+MNmX5NDIrCg5Iwwq4NMk6qaMK80J9XSxERln56SJVN/+tIu
+TZFgwBWM/n48prGXrEo8TPk5UkrVYhLXl/ndTg==
+=FIoh
+-----END PGP SIGNATURE-----
+--e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855--
diff --git a/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml b/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml
index 99a7d84e..7218ff61 100644
--- a/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml
+++ b/pkg/message/testdata/pgp-mime-body-signed-plaintext-with-pubkey.eml
@@ -1,103 +1,95 @@
-Content-Type: multipart/signed; micalg=pgp-sha256;
- protocol="application/pgp-signature";
- boundary="x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp"
-
-This is an OpenPGP/MIME signed message (RFC 4880 and 3156)
---x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp
-Content-Type: multipart/mixed; boundary="bBln6dwDJTLkin5LPHkHBQudqRLwIzTUH";
- protected-headers="v1"
-Subject: simple plaintext body
-From: "pm.bridge.qa"
-To: schizofrenic@pm.me
-Message-ID:
-
---bBln6dwDJTLkin5LPHkHBQudqRLwIzTUH
-Content-Type: multipart/mixed;
- boundary="------------1B34C666A4C2FB03E0324F1A"
-Content-Language: en-US
-
-This is a multi-part message in MIME format.
---------------1B34C666A4C2FB03E0324F1A
-Content-Type: text/plain; charset=utf-8; format=flowed
-Content-Transfer-Encoding: quoted-printable
-
-Why don't crabs give to charity? Because they're shellfish.
-
-
-
---------------1B34C666A4C2FB03E0324F1A
-Content-Type: application/pgp-keys;
- name="OpenPGP_0x161C0875822359F7.asc"
-Content-Transfer-Encoding: quoted-printable
-Content-Disposition: attachment;
- filename="OpenPGP_0x161C0875822359F7.asc"
-
------BEGIN PGP PUBLIC KEY BLOCK-----
-
-xsBNBFxlUPwBCACx954Ey4SD88f8DSKFw9BaZNXrNwYxNYSgqaqOGHQ0WllF3mstEhTfuxxCZ=
-pDh
-I5IhWCXUNxanzsFkn88mRDwFRVl2sf2aAG4/P/p1381oh2kd0UElMRQaQGzoCadQMaQOL9WYT=
-f4S
-PWSCzjrPyKgjq5FbqjbF/ndu376na9L+tnsEXyL6RrI6aZhjWG73xlqxS65dzTIYzsyM/P97x=
-Snd
-NvlvWtGvLlpFkzxfAEGpVzfOYVYFKoc8rGmUDwrDWYfk5JczRDDogJnY+BNMZf9pjSqk6rTyB=
-OfN
-H5fpU8r7A5Q7l+HVakvMUQ9DzDWJtg2ru1Y8hexnJOF68avO4+a1ABEBAAHNKEJyaWRnZSBLe=
-XUt
-RWh5aiA8cG0uYnJpZGdlLnFhQGdtYWlsLmNvbT7CwJQEEwEIAD4CGwMFCwkIBwIGFQoJCAsCB=
-BYC
-AwECHgECF4AWIQRc5gl5cC8oW/Mo+bEWHAh1giNZ9wUCYC32ygUJB4sMzgAKCRAWHAh1giNZ9=
-/K8
-B/4qs84Ii/zKH+q+C8vwO4jUJkOM73qD0pgB7zBs651zWbpgopyol1YUKNpFaHlx/Qch7RDI7=
-Vcz
-1+60/KZJSJR19/N2EDVbCUdh8ueioUp9X/218YWV2TRJNxTnljd4FAn7smZnXuP1TsLjQ6sKO=
-V0U
-u6JoiG6LZFXqDgxYpA++58Rkl6xaY6R71VkmVQlbEKtubX9AjHydq97Y+Jvn11XzWZaKhv4L7=
-6Pa
-4tMKXvvrKh1oywMmh6mZJo+5ZA/ABTkr45cwlTPYqGTS9+uvOHt+PH/oYwwJB4ls2cIAUldSj=
-TVQ
-IsseYz3LlbcCfKJiiCFxeHOQXA5J6zNLKOT58TsczsBNBFxlUPwBCADh2HsX23yVnJt9fxFz3=
-D07
-kCBNvu4HQfps6h1rgNxGhE32VmpESHebvIB5xjL6xKbIqqRa3x/7KDVBNJvca0gUsqEt5kzYF=
-88F
-yf2NBcejpIbcP7BS/g+C6KOowYj+Et26T6GdwFXExUcl80JvoX8yHQOfvJpdiBRbjyB8UqfCa=
-knm
-3c7dNuXmhflz/w3aBj32q9ZyGqA1NpHCpLyVAlvSNQ/pat/rGUCPZ9duw4KhUUqEmatQPVFPk=
-utT
-ouEZQbMK+i+chOH3AsKCuNDfvCDwirnsSqIJmAgl1lC4de+bsWYCMqN9ei99hOCRUyhZ3g3sr=
-8RB
-owVAdcvjZxeIDKALABEBAAHCwHwEGAEIACYCGwwWIQRc5gl5cC8oW/Mo+bEWHAh1giNZ9wUCY=
-C32
-lAUJB4sMmAAKCRAWHAh1giNZ9+Y2B/9rTKZaKviae+ummXNumXcrKvbkAAvfuLpKUn53FlQLm=
-L6H
-jB++lJnPWvVSzdZxdv8FiPP3d632XHKUrkQRQM/9byRDXDommi7Qttx7YCkhd4JLVYqJqpnAQ=
-xI5
-RMkXiZNWyr1lz8JOM1XvDk1M7sJwPMWews8VOIE03E1nt7AsQGnvHtadgEnQaufrYNX3hFA8S=
-osO
-HSnedcys6yrzCSIGCqCD9VHbnMtS4DOv0XJGh2hwc8omzH0KZA517dyKBorJRwadcVauGXDKx=
-Etv
-Im4rl94PR/3An1Mj6HeeVVpLqDQ5Jb9J90BahWeQ53FzRa4EQzYCw0nLnxcsT1ZEEP5u
-=3Dv/1p
------END PGP PUBLIC KEY BLOCK-----
-
---------------1B34C666A4C2FB03E0324F1A--
-
---bBln6dwDJTLkin5LPHkHBQudqRLwIzTUH--
-
---x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp
-Content-Type: application/pgp-signature; name="OpenPGP_signature.asc"
-Content-Description: OpenPGP digital signature
-Content-Disposition: attachment; filename="OpenPGP_signature"
-
------BEGIN PGP SIGNATURE-----
-
-wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmBa9YIFAwAAAAAACgkQFhwIdYIjWfem
-vQgAjUMAaxL7D6fRtFBqLjdQGr7PkDBigeQD9ax17CJFld7Zfo2dAYUzYJRi0HP0Kn1YCSBppF0w
-5/P8458H2sqfPC32ptbDCZ/seL0Rpt/gRx6yufbz7wQC0iUZxqxBq2Ox9PGZYSCrTO837lAVYxUo
-aMnDL/K9ohAGIyTZVv31z+r3LLWQsFpfpB5hJFqsjQXA9IGKSQIkWbaeE+0wveJSwqxdTwYvsHs2
-xjBw+s8tRHO/whP4pvzL185fGsHAb8x9a9oyoDVcszhw5xBpiWW37mI58qkQ6g+4wTarreuXGTp3
-RKgPupoYOMJja90yh3TWovcmuZz6QOgne5Rbn3s+Vg==
-=hUb8
------END PGP SIGNATURE-----
-
---x4FrOFG2PnNJvlbzxe80NPwxzh2yUHABp--
+Content-Type: multipart/signed;
+ boundary=e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855;
+ micalg=SHA-256; protocol="application/pgp-signature"
+
+--e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+Content-Type: multipart/mixed; boundary="------------bhHlRtbq4tEzp2NgVmAJ4AbV";
+ protected-headers="v1"
+From: "pm.bridge.qa"
+To: "InfernalBridgeTester@proton.me"
+Message-ID: <9bb09a2a-1442-9439-f53f-490df7e46331@gmail.com>
+Subject: simple plaintext body
+
+--------------bhHlRtbq4tEzp2NgVmAJ4AbV
+Content-Type: multipart/mixed; boundary="------------FHzf8jBNv06d9SowGc7KOjXr"
+
+--------------FHzf8jBNv06d9SowGc7KOjXr
+Content-Type: text/plain; charset=UTF-8; format=flowed
+Content-Transfer-Encoding: base64
+
+V2h5IGRvbid0IGNyYWJzIGdpdmUgdG8gY2hhcml0eT8gQmVjYXVzZSB0aGV5J3JlIHNoZWxs
+ZmlzaC4NCg0K
+--------------FHzf8jBNv06d9SowGc7KOjXr
+Content-Type: application/pgp-keys; name="OpenPGP_0x161C0875822359F7.asc"
+Content-Disposition: attachment; filename="OpenPGP_0x161C0875822359F7.asc"
+Content-Description: OpenPGP public key
+Content-Transfer-Encoding: quoted-printable
+
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+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
+6h8SuYtgnD84965xRiVAgtH4wCPN544N8CE=3D
+=3DNmqc
+-----END PGP PUBLIC KEY BLOCK-----
+
+--------------FHzf8jBNv06d9SowGc7KOjXr--
+
+--------------bhHlRtbq4tEzp2NgVmAJ4AbV--
+
+--e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
+Content-Description: OpenPGP digital signature
+Content-Disposition: attachment; filename=OpenPGP_signature
+Content-Type: application/pgp-signature; name=OpenPGP_signature.asc
+
+-----BEGIN PGP SIGNATURE-----
+Comment: https://gopenpgp.org
+Version: GopenPGP 2.7.1
+
+wsB5BAABCAAjFiEEXOYJeXAvKFvzKPmxFhwIdYIjWfcFAmRH6MwFAwAAAAAACgkQ
+FhwIdYIjWfdX5Qf/W2hFY5PiCrRTvcXGASc2RBLXmCh/mn0tTNUsGtyq/MhcNKfR
+9bSFCb3xz9q26MAJDHtO/Vm0lUjre42rLMkEIDIdJT960HIClELzmgglwFbVgdqy
+T0Psma8ySQpZ2LxZ1oleCXeXaxm4DOwQP+COfb5+FmLTA2z1djLA3HjFPNKglcUr
+atzCTvlt2yqwrx6aeqTxcFezPkl1o+kdqjMCMP0LFFuImuor0vaCFUz76hgNC3kk
+CflcYGPJIVH7D06UXkLKC5vnJZ+Pidn4K5sMkF3nXBlourmSU2cZFTNUUdHNAxi8
+s9XTavfQxm4fyLPyGcgNpdM6PhZW9r+lZkr66w==
+=se49
+-----END PGP SIGNATURE-----
+--e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855--
diff --git a/pkg/ports/ports.go b/pkg/ports/ports.go
index d5cfbaff..212a3d42 100644
--- a/pkg/ports/ports.go
+++ b/pkg/ports/ports.go
@@ -22,6 +22,7 @@ import (
"net"
"github.com/ProtonMail/proton-bridge/v3/internal/constants"
+ "golang.org/x/exp/slices"
)
const (
@@ -43,19 +44,19 @@ func IsPortFree(port int) bool {
func isOccupied(port string) bool {
// Try to create server at port.
- dummyserver, err := net.Listen("tcp", port)
+ dummyServer, err := net.Listen("tcp", port)
if err != nil {
return true
}
- _ = dummyserver.Close()
+ _ = dummyServer.Close()
return false
}
-// FindFreePortFrom finds first empty port, starting with `startPort`.
-func FindFreePortFrom(startPort int) int {
+// FindFreePortFrom finds first empty port, starting with `startPort`, and excluding ports listed in exclude.
+func FindFreePortFrom(startPort int, exclude ...int) int {
loopedOnce := false
freePort := startPort
- for !IsPortFree(freePort) {
+ for slices.Contains(exclude, freePort) || !IsPortFree(freePort) {
freePort++
if freePort >= maxPortNumber {
freePort = 1
diff --git a/pkg/ports/ports_test.go b/pkg/ports/ports_test.go
index a88bbce2..53276be7 100644
--- a/pkg/ports/ports_test.go
+++ b/pkg/ports/ports_test.go
@@ -32,12 +32,12 @@ func TestFreePort(t *testing.T) {
}
func TestOccupiedPort(t *testing.T) {
- dummyserver, err := net.Listen("tcp", ":"+strconv.Itoa(testPort))
+ dummyServer, err := net.Listen("tcp", ":"+strconv.Itoa(testPort))
require.NoError(t, err)
require.True(t, !IsPortFree(testPort), "port should be occupied")
- _ = dummyserver.Close()
+ _ = dummyServer.Close()
}
func TestFindFreePortFromDirectly(t *testing.T) {
@@ -46,11 +46,21 @@ func TestFindFreePortFromDirectly(t *testing.T) {
}
func TestFindFreePortFromNextOne(t *testing.T) {
- dummyserver, err := net.Listen("tcp", ":"+strconv.Itoa(testPort))
+ dummyServer, err := net.Listen("tcp", ":"+strconv.Itoa(testPort))
require.NoError(t, err)
foundPort := FindFreePortFrom(testPort)
require.Equal(t, testPort+1, foundPort)
- _ = dummyserver.Close()
+ _ = dummyServer.Close()
+}
+
+func TestFindFreePortExcluding(t *testing.T) {
+ dummyServer, err := net.Listen("tcp", ":"+strconv.Itoa(testPort))
+ require.NoError(t, err)
+
+ foundPort := FindFreePortFrom(testPort, testPort+1, testPort+2)
+ require.Equal(t, testPort+3, foundPort)
+
+ _ = dummyServer.Close()
}
diff --git a/tests/bdd_test.go b/tests/bdd_test.go
index f67caa70..92a73bed 100644
--- a/tests/bdd_test.go
+++ b/tests/bdd_test.go
@@ -107,7 +107,10 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^the header in the "([^"]*)" request to "([^"]*)" has "([^"]*)" set to "([^"]*)"$`, s.theHeaderInTheRequestToHasSetTo)
ctx.Step(`^the body in the "([^"]*)" request to "([^"]*)" is:$`, s.theBodyInTheRequestToIs)
ctx.Step(`^the API requires bridge version at least "([^"]*)"$`, s.theAPIRequiresBridgeVersion)
-
+ ctx.Step(`^the network port (\d+) is busy$`, s.networkPortIsBusy)
+ ctx.Step(`^the network port range (\d+)-(\d+) is busy$`, s.networkPortRangeIsBusy)
+ ctx.Step(`^bridge IMAP port is (\d+)`, s.bridgeIMAPPortIs)
+ ctx.Step(`^bridge SMTP port is (\d+)`, s.bridgeSMTPPortIs)
// ==== SETUP ====
ctx.Step(`^there exists an account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPassword)
ctx.Step(`^there exists a disabled account with username "([^"]*)" and password "([^"]*)"$`, s.thereExistsAnAccountWithUsernameAndPasswordWithDisablePrimary)
@@ -121,7 +124,7 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^the address "([^"]*)" of account "([^"]*)" has the following messages in "([^"]*)":$`, s.theAddressOfAccountHasTheFollowingMessagesInMailbox)
ctx.Step(`^the address "([^"]*)" of account "([^"]*)" has (\d+) messages in "([^"]*)"$`, s.theAddressOfAccountHasMessagesInMailbox)
ctx.Step(`^the following fields were changed in draft (\d+) for address "([^"]*)" of account "([^"]*)":$`, s.theFollowingFieldsWereChangedInDraftForAddressOfAccount)
- ctx.Step(`^draft (\d+) for address "([^"]*)" of account "([^"]*) was moved to trash$`, s.drafAtIndexWasMovedToTrashForAddressOfAccount)
+ ctx.Step(`^draft (\d+) for address "([^"]*)" of account "([^"]*)" was moved to trash$`, s.drafAtIndexWasMovedToTrashForAddressOfAccount)
// === REPORTER ===
ctx.Step(`^test skips reporter checks$`, s.skipReporterChecks)
@@ -132,15 +135,22 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^bridge stops$`, s.bridgeStops)
ctx.Step(`^bridge is version "([^"]*)" and the latest available version is "([^"]*)" reachable from "([^"]*)"$`, s.bridgeVersionIsAndTheLatestAvailableVersionIsReachableFrom)
ctx.Step(`^the user has disabled automatic updates$`, s.theUserHasDisabledAutomaticUpdates)
+ ctx.Step(`^the user has disabled automatic start`, s.theUserHasDisabledAutomaticStart)
+ ctx.Step(`^the user has enabled alternative routing`, s.theUserHasEnabledAlternativeRouting)
+ ctx.Step(`^the user set IMAP mode to SSL`, s.theUserSetIMAPModeToSSL)
+ ctx.Step(`^the user set SMTP mode to SSL`, s.theUserSetSMTPModeToSSL)
ctx.Step(`^the user changes the IMAP port to (\d+)$`, s.theUserChangesTheIMAPPortTo)
ctx.Step(`^the user changes the SMTP port to (\d+)$`, s.theUserChangesTheSMTPPortTo)
ctx.Step(`^the user sets the address mode of user "([^"]*)" to "([^"]*)"$`, s.theUserSetsTheAddressModeOfUserTo)
+ ctx.Step(`^the user changes the default keychain application`, s.theUserChangesTheDefaultKeychainApplication)
ctx.Step(`^the user changes the gluon path$`, s.theUserChangesTheGluonPath)
ctx.Step(`^the user deletes the gluon files$`, s.theUserDeletesTheGluonFiles)
ctx.Step(`^the user deletes the gluon cache$`, s.theUserDeletesTheGluonCache)
ctx.Step(`^the user reports a bug$`, s.theUserReportsABug)
ctx.Step(`^the user hides All Mail$`, s.theUserHidesAllMail)
ctx.Step(`^the user shows All Mail$`, s.theUserShowsAllMail)
+ ctx.Step(`^the user disables telemetry in bridge settings$`, s.theUserDisablesTelemetryInBridgeSettings)
+ ctx.Step(`^the user enables telemetry in bridge settings$`, s.theUserEnablesTelemetryInBridgeSettings)
ctx.Step(`^bridge sends a connection up event$`, s.bridgeSendsAConnectionUpEvent)
ctx.Step(`^bridge sends a connection down event$`, s.bridgeSendsAConnectionDownEvent)
ctx.Step(`^bridge sends a deauth event for user "([^"]*)"$`, s.bridgeSendsADeauthEventForUser)
@@ -153,6 +163,8 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^bridge sends an update not available event$`, s.bridgeSendsAnUpdateNotAvailableEvent)
ctx.Step(`^bridge sends a forced update event$`, s.bridgeSendsAForcedUpdateEvent)
ctx.Step(`^bridge reports a message with "([^"]*)"$`, s.bridgeReportsMessage)
+ ctx.Step(`^bridge telemetry feature is enabled$`, s.bridgeTelemetryFeatureEnabled)
+ ctx.Step(`^bridge telemetry feature is disabled$`, s.bridgeTelemetryFeatureDisabled)
// ==== FRONTEND ====
ctx.Step(`^frontend sees that bridge is version "([^"]*)"$`, s.frontendSeesThatBridgeIsVersion)
@@ -166,6 +178,7 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^user "([^"]*)" is listed but not connected$`, s.userIsListedButNotConnected)
ctx.Step(`^user "([^"]*)" is not listed$`, s.userIsNotListed)
ctx.Step(`^user "([^"]*)" finishes syncing$`, s.userFinishesSyncing)
+ ctx.Step(`^user "([^"]*)" has telemetry set to (\d+)$`, s.userHasTelemetrySetTo)
// ==== IMAP ====
ctx.Step(`^user "([^"]*)" connects IMAP client "([^"]*)"$`, s.userConnectsIMAPClient)
@@ -206,6 +219,7 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^IMAP client "([^"]*)" appends "([^"]*)" to "([^"]*)"$`, s.imapClientAppendsToMailbox)
ctx.Step(`^IMAP clients "([^"]*)" and "([^"]*)" move message with subject "([^"]*)" of "([^"]*)" to "([^"]*)" by ([^"]*) ([^"]*) ([^"]*)`, s.imapClientsMoveMessageWithSubjectUserFromToByOrderedOperations)
ctx.Step(`^IMAP client "([^"]*)" sees header "([^"]*)" in message with subject "([^"]*)" in "([^"]*)"$`, s.imapClientSeesHeaderInMessageWithSubject)
+ ctx.Step(`^IMAP client "([^"]*)" does not see header "([^"]*)" in message with subject "([^"]*)" in "([^"]*)"$`, s.imapClientDoesNotSeeHeaderInMessageWithSubject)
// ==== SMTP ====
ctx.Step(`^user "([^"]*)" connects SMTP client "([^"]*)"$`, s.userConnectsSMTPClient)
@@ -222,6 +236,12 @@ func TestFeatures(testingT *testing.T) {
ctx.Step(`^SMTP client "([^"]*)" sends RSET$`, s.smtpClientSendsReset)
ctx.Step(`^SMTP client "([^"]*)" sends the following message from "([^"]*)" to "([^"]*)":$`, s.smtpClientSendsTheFollowingMessageFromTo)
ctx.Step(`^SMTP client "([^"]*)" logs out$`, s.smtpClientLogsOut)
+
+ // ==== TELEMETRY ====
+ ctx.Step(`^bridge eventually sends the following heartbeat:$`, s.bridgeEventuallySendsTheFollowingHeartbeat)
+ ctx.Step(`^bridge needs to send heartbeat`, s.bridgeNeedsToSendHeartbeat)
+ ctx.Step(`^bridge do not need to send heartbeat`, s.bridgeDoNotNeedToSendHeartbeat)
+ ctx.Step(`^heartbeat is not whitelisted`, s.heartbeatIsNotwhitelisted)
},
Options: &godog.Options{
Format: "pretty",
diff --git a/tests/bridge_test.go b/tests/bridge_test.go
index 45f6e67a..7d3edd14 100644
--- a/tests/bridge_test.go
+++ b/tests/bridge_test.go
@@ -21,7 +21,9 @@ import (
"context"
"errors"
"fmt"
+ "net"
"os"
+ "strconv"
"time"
"github.com/Masterminds/semver/v3"
@@ -78,6 +80,10 @@ func (s *scenario) theUserSetsTheAddressModeOfUserTo(user, mode string) error {
}
}
+func (s *scenario) theUserChangesTheDefaultKeychainApplication() error {
+ return s.t.bridge.SetKeychainApp("CustomKeychainApp")
+}
+
func (s *scenario) theUserChangesTheGluonPath() error {
gluonDir, err := os.MkdirTemp(s.t.dir, "gluon")
if err != nil {
@@ -116,7 +122,6 @@ func (s *scenario) theUserHasDisabledAutomaticUpdates() error {
started = true
}
-
if err := s.t.bridge.SetAutoUpdate(false); err != nil {
return err
}
@@ -126,10 +131,26 @@ func (s *scenario) theUserHasDisabledAutomaticUpdates() error {
return err
}
}
-
return nil
}
+func (s *scenario) theUserHasDisabledAutomaticStart() error {
+ return s.t.bridge.SetAutostart(false)
+}
+
+func (s *scenario) theUserHasEnabledAlternativeRouting() error {
+ s.t.expectProxyCtlAllowProxy()
+ return s.t.bridge.SetProxyAllowed(true)
+}
+
+func (s *scenario) theUserSetIMAPModeToSSL() error {
+ return s.t.bridge.SetIMAPSSL(true)
+}
+
+func (s *scenario) theUserSetSMTPModeToSSL() error {
+ return s.t.bridge.SetSMTPSSL(true)
+}
+
func (s *scenario) theUserReportsABug() error {
return s.t.bridge.ReportBug(context.Background(), "osType", "osVersion", "description", "username", "email", "client", false)
}
@@ -284,6 +305,22 @@ func (s *scenario) bridgeReportsMessage(message string) error {
return nil
}
+func (s *scenario) bridgeTelemetryFeatureEnabled() error {
+ return s.checkTelemetry(true)
+}
+
+func (s *scenario) bridgeTelemetryFeatureDisabled() error {
+ return s.checkTelemetry(false)
+}
+
+func (s *scenario) checkTelemetry(expect bool) error {
+ res := s.t.bridge.IsTelemetryAvailable()
+ if res != expect {
+ return fmt.Errorf("expected telemetry feature %v but got %v ", expect, res)
+ }
+ return nil
+}
+
func (s *scenario) theUserHidesAllMail() error {
return s.t.bridge.SetShowAllMail(false)
}
@@ -291,3 +328,45 @@ func (s *scenario) theUserHidesAllMail() error {
func (s *scenario) theUserShowsAllMail() error {
return s.t.bridge.SetShowAllMail(true)
}
+
+func (s *scenario) theUserDisablesTelemetryInBridgeSettings() error {
+ return s.t.bridge.SetTelemetryDisabled(true)
+}
+
+func (s *scenario) theUserEnablesTelemetryInBridgeSettings() error {
+ return s.t.bridge.SetTelemetryDisabled(false)
+}
+
+func (s *scenario) networkPortIsBusy(port int) {
+ if listener, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port)); err == nil { // we ignore errors. Most likely port is already busy.
+ s.t.dummyListeners = append(s.t.dummyListeners, listener)
+ }
+}
+
+func (s *scenario) networkPortRangeIsBusy(startPort, endPort int) {
+ if startPort > endPort {
+ startPort, endPort = endPort, startPort
+ }
+
+ for port := startPort; port <= endPort; port++ {
+ s.networkPortIsBusy(port)
+ }
+}
+
+func (s *scenario) bridgeIMAPPortIs(expectedPort int) error {
+ actualPort := s.t.bridge.GetIMAPPort()
+ if actualPort != expectedPort {
+ return fmt.Errorf("expected IMAP port to be %v but got %v", expectedPort, actualPort)
+ }
+
+ return nil
+}
+
+func (s *scenario) bridgeSMTPPortIs(expectedPort int) error {
+ actualPort := s.t.bridge.GetSMTPPort()
+ if actualPort != expectedPort {
+ return fmt.Errorf("expected SMTP port to be %v but got %v", expectedPort, actualPort)
+ }
+
+ return nil
+}
diff --git a/tests/ctx_bridge_test.go b/tests/ctx_bridge_test.go
index 92cdf0e1..0afe93d7 100644
--- a/tests/ctx_bridge_test.go
+++ b/tests/ctx_bridge_test.go
@@ -174,6 +174,9 @@ func (t *testCtx) initBridge() (<-chan events.Event, error) {
}
t.bridge = bridge
+ t.heartbeat.setBridge(bridge)
+
+ bridge.StartHeartbeat(t.heartbeat)
return t.events.collectFrom(eventCh), nil
}
@@ -342,6 +345,9 @@ func (t *testCtx) closeFrontendClient() error {
return nil
}
+func (t *testCtx) expectProxyCtlAllowProxy() {
+ t.mocks.ProxyCtl.EXPECT().AllowProxy()
+}
type mockRestarter struct{}
diff --git a/tests/ctx_heartbeat_test.go b/tests/ctx_heartbeat_test.go
new file mode 100644
index 00000000..babf4f9e
--- /dev/null
+++ b/tests/ctx_heartbeat_test.go
@@ -0,0 +1,89 @@
+// Copyright (c) 2023 Proton AG
+//
+// This file is part of Proton Mail Bridge.
+//
+// Proton Mail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Proton Mail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Proton Mail Bridge. If not, see .
+
+package tests
+
+import (
+ "errors"
+ "testing"
+ "time"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/bridge"
+ "github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
+ "github.com/stretchr/testify/assert"
+)
+
+type heartbeatRecorder struct {
+ heartbeat telemetry.HeartbeatData
+ bridge *bridge.Bridge
+ reject bool
+ assert *assert.Assertions
+}
+
+func newHeartbeatRecorder(tb testing.TB) *heartbeatRecorder {
+ return &heartbeatRecorder{
+ heartbeat: telemetry.HeartbeatData{},
+ bridge: nil,
+ reject: false,
+ assert: assert.New(tb),
+ }
+}
+
+func (hb *heartbeatRecorder) setBridge(bridge *bridge.Bridge) {
+ hb.bridge = bridge
+}
+
+func (hb *heartbeatRecorder) GetLastHeartbeatSent() time.Time {
+ if hb.bridge == nil {
+ return time.Now()
+ }
+ return hb.bridge.GetLastHeartbeatSent()
+}
+
+func (hb *heartbeatRecorder) IsTelemetryAvailable() bool {
+ if hb.bridge == nil {
+ return false
+ }
+ return hb.bridge.IsTelemetryAvailable()
+}
+
+func (hb *heartbeatRecorder) SendHeartbeat(metrics *telemetry.HeartbeatData) bool {
+ if hb.bridge == nil {
+ return false
+ }
+
+ if len(hb.bridge.GetUserIDs()) == 0 {
+ return false
+ }
+
+ if hb.reject {
+ return false
+ }
+ hb.heartbeat = *metrics
+ return true
+}
+
+func (hb *heartbeatRecorder) SetLastHeartbeatSent(timestamp time.Time) error {
+ if hb.bridge == nil {
+ return errors.New("no bridge initialized")
+ }
+ return hb.bridge.SetLastHeartbeatSent(timestamp)
+}
+
+func (hb *heartbeatRecorder) rejectSend() {
+ hb.reject = true
+}
diff --git a/tests/ctx_test.go b/tests/ctx_test.go
index 13bff820..369e865f 100644
--- a/tests/ctx_test.go
+++ b/tests/ctx_test.go
@@ -20,6 +20,7 @@ package tests
import (
"context"
"fmt"
+ "net"
"net/smtp"
"net/url"
"regexp"
@@ -121,15 +122,16 @@ func newTestAddr(addrID, email string) *testAddr {
type testCtx struct {
// These are the objects supporting the test.
- dir string
- api API
- netCtl *proton.NetCtl
- locator *locations.Locations
- storeKey []byte
- version *semver.Version
- mocks *bridge.Mocks
- events *eventCollector
- reporter *reportRecorder
+ dir string
+ api API
+ netCtl *proton.NetCtl
+ locator *locations.Locations
+ storeKey []byte
+ version *semver.Version
+ mocks *bridge.Mocks
+ events *eventCollector
+ reporter *reportRecorder
+ heartbeat *heartbeatRecorder
// bridge holds the bridge app under test.
bridge *bridge.Bridge
@@ -160,6 +162,9 @@ type testCtx struct {
// errors holds test-related errors encountered while running test steps.
errors [][]error
errorsLock sync.RWMutex
+
+ // This slice contains the dummy listeners that are intended to block network ports.
+ dummyListeners []net.Listener
}
type imapClient struct {
@@ -176,15 +181,16 @@ func newTestCtx(tb testing.TB) *testCtx {
dir := tb.TempDir()
t := &testCtx{
- dir: dir,
- api: newTestAPI(),
- netCtl: proton.NewNetCtl(),
- locator: locations.New(bridge.NewTestLocationsProvider(dir), "config-name"),
- storeKey: []byte("super-secret-store-key"),
- version: defaultVersion,
- mocks: bridge.NewMocks(tb, defaultVersion, defaultVersion),
- events: newEventCollector(),
- reporter: newReportRecorder(tb),
+ dir: dir,
+ api: newTestAPI(),
+ netCtl: proton.NewNetCtl(),
+ locator: locations.New(bridge.NewTestLocationsProvider(dir), "config-name"),
+ storeKey: []byte("super-secret-store-key"),
+ version: defaultVersion,
+ mocks: bridge.NewMocks(tb, defaultVersion, defaultVersion),
+ events: newEventCollector(),
+ reporter: newReportRecorder(tb),
+ heartbeat: newHeartbeatRecorder(tb),
userByID: make(map[string]*testUser),
userUUIDByName: make(map[string]string),
@@ -437,6 +443,12 @@ func (t *testCtx) close(ctx context.Context) {
}
}
+ for _, listener := range t.dummyListeners {
+ if err := listener.Close(); err != nil {
+ logrus.WithError(err).Errorf("Failed to close dummy listener %v", listener.Addr())
+ }
+ }
+
t.api.Close()
t.events.close()
t.reporter.close()
diff --git a/tests/features/bridge/default_ports.feature b/tests/features/bridge/default_ports.feature
new file mode 100644
index 00000000..4c7f3703
--- /dev/null
+++ b/tests/features/bridge/default_ports.feature
@@ -0,0 +1,24 @@
+Feature: Bridge picks default ports wisely
+
+ Scenario: bridge picks ports for IMAP and SMTP using default values.
+ When bridge starts
+ Then bridge IMAP port is 1143
+ Then bridge SMTP port is 1025
+
+ Scenario: bridge picks ports for IMAP wisely when default port is busy.
+ When the network port 1143 is busy
+ And bridge starts
+ Then bridge IMAP port is 1144
+ Then bridge SMTP port is 1025
+
+ Scenario: bridge picks ports for SMTP wisely when default port is busy.
+ When the network port range 1025-1030 is busy
+ And bridge starts
+ Then bridge IMAP port is 1143
+ Then bridge SMTP port is 1031
+
+ Scenario: bridge picks ports for IMAP SMTP wisely when default ports are busy.
+ When the network port range 1025-1200 is busy
+ And bridge starts
+ Then bridge IMAP port is 1201
+ Then bridge SMTP port is 1202
diff --git a/tests/features/bridge/heartbeat.feature b/tests/features/bridge/heartbeat.feature
new file mode 100644
index 00000000..9584f800
--- /dev/null
+++ b/tests/features/bridge/heartbeat.feature
@@ -0,0 +1,129 @@
+Feature: Send Telemetry Heartbeat
+ Background:
+ Given there exists an account with username "[user:user1]" and password "password"
+ And bridge starts
+
+
+ Scenario: Send at first start - one user default settings
+ Then bridge telemetry feature is enabled
+ And bridge needs to send heartbeat
+ When the user logs in with username "[user:user1]" and password "password"
+ And user "[user:user1]" finishes syncing
+ Then bridge eventually sends the following heartbeat:
+ """
+ {
+ "MeasurementGroup": "bridge.any.usage",
+ "Event": "bridge_heartbeat",
+ "Values": {
+ "nb_account": 1
+ },
+ "Dimensions": {
+ "auto_update": "on",
+ "auto_start": "on",
+ "beta": "off",
+ "doh": "off",
+ "split_mode": "off",
+ "show_all_mail": "on",
+ "imap_connection_mode": "starttls",
+ "smtp_connection_mode": "starttls",
+ "imap_port": "default",
+ "smtp_port": "default",
+ "cache_location": "default",
+ "keychain_pref": "default",
+ "prev_version": "0.0.0",
+ "rollout": "42"
+ }
+ }
+ """
+ And bridge do not need to send heartbeat
+
+
+ Scenario: Send at first start - one user modified settings
+ Then bridge telemetry feature is enabled
+ And bridge needs to send heartbeat
+ When the user has disabled automatic updates
+ And the user has disabled automatic start
+ And the user has enabled alternative routing
+ And the user hides All Mail
+ And the user set IMAP mode to SSL
+ And the user set SMTP mode to SSL
+ And the user changes the IMAP port to 42695
+ And the user changes the SMTP port to 56942
+ And the user changes the gluon path
+ And the user changes the default keychain application
+ When the user logs in with username "[user:user1]" and password "password"
+ And user "[user:user1]" finishes syncing
+ Then bridge eventually sends the following heartbeat:
+ """
+ {
+ "MeasurementGroup": "bridge.any.usage",
+ "Event": "bridge_heartbeat",
+ "Values": {
+ "nb_account": 1
+ },
+ "Dimensions": {
+ "auto_update": "off",
+ "auto_start": "off",
+ "beta": "off",
+ "doh": "on",
+ "split_mode": "off",
+ "show_all_mail": "off",
+ "imap_connection_mode": "ssl",
+ "smtp_connection_mode": "ssl",
+ "imap_port": "custom",
+ "smtp_port": "custom",
+ "cache_location": "custom",
+ "keychain_pref": "custom",
+ "prev_version": "0.0.0",
+ "rollout": "42"
+ }
+ }
+ """
+ And bridge do not need to send heartbeat
+
+
+ Scenario: Send at first start - one user telemetry disabled
+ Then bridge telemetry feature is enabled
+ And bridge needs to send heartbeat
+ When the user disables telemetry in bridge settings
+ And the user logs in with username "[user:user1]" and password "password"
+ And user "[user:user1]" finishes syncing
+ And bridge needs to send heartbeat
+ Then the user sets the address mode of user "[user:user1]" to "split"
+ And the user enables telemetry in bridge settings
+ Then bridge eventually sends the following heartbeat:
+ """
+ {
+ "MeasurementGroup": "bridge.any.usage",
+ "Event": "bridge_heartbeat",
+ "Values": {
+ "nb_account": 1
+ },
+ "Dimensions": {
+ "auto_update": "on",
+ "auto_start": "on",
+ "beta": "off",
+ "doh": "off",
+ "split_mode": "on",
+ "show_all_mail": "on",
+ "imap_connection_mode": "starttls",
+ "smtp_connection_mode": "starttls",
+ "imap_port": "default",
+ "smtp_port": "default",
+ "cache_location": "default",
+ "keychain_pref": "default",
+ "prev_version": "0.0.0",
+ "rollout": "42"
+ }
+ }
+ """
+ And bridge do not need to send heartbeat
+
+
+ Scenario: GroupMeasurement rejected by API
+ Given heartbeat is not whitelisted
+ Then bridge telemetry feature is enabled
+ And bridge needs to send heartbeat
+ When the user logs in with username "[user:user1]" and password "password"
+ And user "[user:user1]" finishes syncing
+ Then bridge needs to send heartbeat
diff --git a/tests/features/updates.feature b/tests/features/bridge/updates.feature
similarity index 100%
rename from tests/features/updates.feature
rename to tests/features/bridge/updates.feature
diff --git a/tests/features/imap/message/delete_from_trash.feature b/tests/features/imap/message/delete_from_trash.feature
index fdc5105e..7d80ece4 100644
--- a/tests/features/imap/message/delete_from_trash.feature
+++ b/tests/features/imap/message/delete_from_trash.feature
@@ -6,7 +6,7 @@ Feature: IMAP remove messages from Trash
| mbox | folder |
| label | label |
- Scenario Outline: Message in Trash and some other label is not permanently deleted
+ Scenario Outline: Message in Trash and some other label is permanently deleted
Given the address "[user:user]@[domain]" of account "[user:user]" has the following messages in "Trash":
| from | to | subject | body |
| john.doe@mail.com | [user:user]@[domain] | foo | hello |
@@ -26,8 +26,8 @@ Feature: IMAP remove messages from Trash
When IMAP client "1" expunges
Then it succeeds
And IMAP client "1" eventually sees 1 messages in "Trash"
- And IMAP client "1" eventually sees 2 messages in "All Mail"
- And IMAP client "1" eventually sees 1 messages in "Labels/label"
+ And IMAP client "1" eventually sees 1 messages in "All Mail"
+ And IMAP client "1" eventually sees 0 messages in "Labels/label"
Scenario Outline: Message in Trash only is permanently deleted
Given the address "[user:user]@[domain]" of account "[user:user]" has the following messages in "Trash":
diff --git a/tests/features/imap/message/drafts.feature b/tests/features/imap/message/drafts.feature
index 6f185418..2c8e6ae7 100644
--- a/tests/features/imap/message/drafts.feature
+++ b/tests/features/imap/message/drafts.feature
@@ -34,6 +34,7 @@ Feature: IMAP Draft messages
| to | subject | body |
| someone@example.com | Basic Draft | This is a draft, but longer |
And IMAP client "1" eventually sees 1 messages in "Drafts"
+ And IMAP client "1" does not see header "Reply-To" in message with subject "Basic Draft" in "Drafts"
Scenario: Draft edited remotely
When the following fields were changed in draft 1 for address "[user:user]@[domain]" of account "[user:user]":
@@ -43,9 +44,10 @@ Feature: IMAP Draft messages
| to | subject | body |
| someone@example.com | Basic Draft | This is a draft body, but longer |
And IMAP client "1" eventually sees 1 messages in "Drafts"
+ And IMAP client "1" does not see header "Reply-To" in message with subject "Basic Draft" in "Drafts"
Scenario: Draft moved to trash remotely
- When draft 1 for address "[user:user]@[domain]" of account "[user:user] was moved to trash
+ When draft 1 for address "[user:user]@[domain]" of account "[user:user]" was moved to trash
Then IMAP client "1" eventually sees the following messages in "Trash":
| body |
| This is a dra |
diff --git a/tests/features/smtp/send/send_reply.feature b/tests/features/smtp/send/send_reply.feature
index 47f9acf4..a3c17143 100644
--- a/tests/features/smtp/send/send_reply.feature
+++ b/tests/features/smtp/send/send_reply.feature
@@ -5,9 +5,9 @@ Feature: SMTP send reply
And there exists an account with username "[user:user2]" and password "password"
And bridge starts
And the user logs in with username "[user:user1]" and password "password"
+ And user "[user:user1]" finishes syncing
And user "[user:user1]" connects and authenticates SMTP client "1"
And user "[user:user1]" connects and authenticates IMAP client "1"
- And user "[user:user1]" finishes syncing
@long-black
Scenario: Reply with In-Reply-To but no References
@@ -33,8 +33,8 @@ Feature: SMTP send reply
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
- | from | subject | message-id |
- | [user:user1]@[domain] | Please Reply | |
+ | from | subject | message-id | reply-to |
+ | [user:user1]@[domain] | Please Reply | | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
@@ -53,8 +53,8 @@ Feature: SMTP send reply
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | | |
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
- | from | subject | body | in-reply-to | references |
- | [user:user2]@[domain] | FW - Please Reply | Heya | | |
+ | from | subject | body | in-reply-to | references | reply-to |
+ | [user:user2]@[domain] | FW - Please Reply | Heya | | | [user:user2]@[domain] |
@long-black
Scenario: Reply with References but no In-Reply-To
@@ -80,8 +80,8 @@ Feature: SMTP send reply
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
- | from | subject | message-id |
- | [user:user1]@[domain] | Please Reply | |
+ | from | subject | message-id | reply-to |
+ | [user:user1]@[domain] | Please Reply | | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
@@ -100,8 +100,8 @@ Feature: SMTP send reply
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | | |
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
- | from | subject | body | in-reply-to | references |
- | [user:user2]@[domain] | FW - Please Reply | Heya | | |
+ | from | subject | body | in-reply-to | references | reply-to |
+ | [user:user2]@[domain] | FW - Please Reply | Heya | | | [user:user2]@[domain] |
@long-black
@@ -128,8 +128,8 @@ Feature: SMTP send reply
And user "[user:user2]" finishes syncing
# User2 receive the message.
Then IMAP client "2" eventually sees the following messages in "INBOX":
- | from | subject | message-id |
- | [user:user1]@[domain] | Please Reply | |
+ | from | subject | message-id | reply-to |
+ | [user:user1]@[domain] | Please Reply | | [user:user1]@[domain] |
# User2 reply to it.
When SMTP client "2" sends the following message from "[user:user2]@[domain]" to "[user:user1]@[domain]":
"""
@@ -149,5 +149,5 @@ Feature: SMTP send reply
| [user:user2]@[domain] | [user:user1]@[domain] | FW - Please Reply | | |
# User1 receive the reply.|
And IMAP client "1" eventually sees the following messages in "INBOX":
- | from | subject | body | in-reply-to | references |
- | [user:user2]@[domain] | FW - Please Reply | Heya | | |
\ No newline at end of file
+ | from | subject | body | in-reply-to | references | reply-to |
+ | [user:user2]@[domain] | FW - Please Reply | Heya | | | [user:user2]@[domain] |
\ No newline at end of file
diff --git a/tests/features/user/telemetry.feature b/tests/features/user/telemetry.feature
new file mode 100644
index 00000000..ff98029b
--- /dev/null
+++ b/tests/features/user/telemetry.feature
@@ -0,0 +1,29 @@
+Feature: Bridge send usage metrics
+ Background:
+ Given there exists an account with username "[user:user1]" and password "password"
+ And there exists an account with username "[user:user2]" and password "password"
+ And bridge starts
+
+
+ Scenario: Telemetry availability - No user
+ Then bridge telemetry feature is enabled
+ When the user disables telemetry in bridge settings
+ Then bridge telemetry feature is disabled
+ When the user enables telemetry in bridge settings
+ Then bridge telemetry feature is enabled
+
+
+ Scenario: Telemetry availability - Multi user
+ When the user logs in with username "[user:user1]" and password "password"
+ And user "[user:user1]" finishes syncing
+ Then bridge telemetry feature is enabled
+ When the user logs in with username "[user:user2]" and password "password"
+ And user "[user:user2]" finishes syncing
+ When user "[user:user2]" has telemetry set to 0
+ Then bridge telemetry feature is disabled
+ When user "[user:user2]" has telemetry set to 1
+ Then bridge telemetry feature is enabled
+ When the user disables telemetry in bridge settings
+ Then bridge telemetry feature is disabled
+ When the user enables telemetry in bridge settings
+ Then bridge telemetry feature is enabled
\ No newline at end of file
diff --git a/tests/heartbeat_test.go b/tests/heartbeat_test.go
new file mode 100644
index 00000000..65faa254
--- /dev/null
+++ b/tests/heartbeat_test.go
@@ -0,0 +1,87 @@
+// Copyright (c) 2023 Proton AG
+//
+// This file is part of Proton Mail Bridge.
+//
+// Proton Mail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Proton Mail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Proton Mail Bridge. If not, see .
+
+package tests
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/ProtonMail/proton-bridge/v3/internal/telemetry"
+ "github.com/cucumber/godog"
+ "github.com/sirupsen/logrus"
+)
+
+func (s *scenario) bridgeEventuallySendsTheFollowingHeartbeat(text *godog.DocString) error {
+ return eventually(func() error {
+ err := s.bridgeSendsTheFollowingHeartbeat(text)
+ logrus.WithError(err).Trace("Matching eventually")
+ return err
+ })
+}
+
+func (s *scenario) bridgeSendsTheFollowingHeartbeat(text *godog.DocString) error {
+ var wantHeartbeat telemetry.HeartbeatData
+ err := json.Unmarshal([]byte(text.Content), &wantHeartbeat)
+ if err != nil {
+ return err
+ }
+
+ return matchHeartbeat(s.t.heartbeat.heartbeat, wantHeartbeat)
+}
+
+func (s *scenario) bridgeNeedsToSendHeartbeat() error {
+ last := s.t.heartbeat.GetLastHeartbeatSent()
+ if !isAnotherDay(last, time.Now()) {
+ return fmt.Errorf("heartbeat already sent at %s", last)
+ }
+ return nil
+}
+
+func (s *scenario) bridgeDoNotNeedToSendHeartbeat() error {
+ last := s.t.heartbeat.GetLastHeartbeatSent()
+ if isAnotherDay(last, time.Now()) {
+ return fmt.Errorf("heartbeat needs to be sent - last %s", last)
+ }
+ return nil
+}
+
+func (s *scenario) heartbeatIsNotwhitelisted() error {
+ s.t.heartbeat.rejectSend()
+ return nil
+}
+
+func matchHeartbeat(have, want telemetry.HeartbeatData) error {
+ if have == (telemetry.HeartbeatData{}) {
+ return errors.New("no heartbeat send (yet)")
+ }
+
+ // Ignore rollout number
+ want.Dimensions.Rollout = have.Dimensions.Rollout
+
+ if have != want {
+ return fmt.Errorf("missing heartbeat: have %#v, want %#v", have, want)
+ }
+
+ return nil
+}
+
+func isAnotherDay(last, now time.Time) bool {
+ return now.Year() > last.Year() || (now.Year() == last.Year() && now.YearDay() > last.YearDay())
+}
diff --git a/tests/imap_test.go b/tests/imap_test.go
index 8cae5707..155b6e37 100644
--- a/tests/imap_test.go
+++ b/tests/imap_test.go
@@ -297,7 +297,6 @@ func (s *scenario) imapClientSeesTheFollowingMessagesInMailbox(clientID, mailbox
if err != nil {
return err
}
-
return matchMessages(haveMessages, wantMessages)
}
@@ -575,6 +574,14 @@ func (s *scenario) imapClientSeesHeaderInMessageWithSubject(clientID, headerStri
return fmt.Errorf("could not find message with given subject '%v'", subject)
}
+func (s *scenario) imapClientDoesNotSeeHeaderInMessageWithSubject(clientID, headerString, subject, mailbox string) error {
+ err := s.imapClientSeesHeaderInMessageWithSubject(clientID, headerString, subject, mailbox)
+ if err == nil {
+ return fmt.Errorf("message header contains '%v'", headerString)
+ }
+ return nil
+}
+
func clientList(client *client.Client) []*imap.MailboxInfo {
resCh := make(chan *imap.MailboxInfo)
diff --git a/tests/types_test.go b/tests/types_test.go
index c59b13dc..183637d6 100644
--- a/tests/types_test.go
+++ b/tests/types_test.go
@@ -43,10 +43,11 @@ type Message struct {
MessageID string `bdd:"message-id"`
Date string `bdd:"date"`
- From string `bdd:"from"`
- To string `bdd:"to"`
- CC string `bdd:"cc"`
- BCC string `bdd:"bcc"`
+ From string `bdd:"from"`
+ To string `bdd:"to"`
+ CC string `bdd:"cc"`
+ BCC string `bdd:"bcc"`
+ ReplyTo string `bdd:"reply-to"`
Unread bool `bdd:"unread"`
Deleted bool `bdd:"deleted"`
@@ -158,6 +159,10 @@ func newMessageFromIMAP(msg *imap.Message) Message {
message.BCC = msg.Envelope.Bcc[0].Address()
}
+ if len(msg.Envelope.ReplyTo) > 0 {
+ message.ReplyTo = msg.Envelope.ReplyTo[0].Address()
+ }
+
return message
}
diff --git a/tests/user_test.go b/tests/user_test.go
index 3be34f5f..17542987 100644
--- a/tests/user_test.go
+++ b/tests/user_test.go
@@ -414,6 +414,18 @@ func (s *scenario) userFinishesSyncing(username string) error {
return s.bridgeSendsSyncStartedAndFinishedEventsForUser(username)
}
+func (s *scenario) userHasTelemetrySetTo(username string, telemetry int) error {
+ return s.t.withClientPass(context.Background(), username, s.t.getUserByName(username).userPass, func(ctx context.Context, c *proton.Client) error {
+ var req proton.SetTelemetryReq
+ req.Telemetry = proton.SettingsBool(telemetry)
+ _, err := c.SetUserSettingsTelemetry(ctx, req)
+ if err != nil {
+ return err
+ }
+ return nil
+ })
+}
+
func (s *scenario) addAdditionalAddressToAccount(username, address string, disabled bool) error {
userID := s.t.getUserByName(username).getUserID()
diff --git a/utils/port-blocker/port-blocker.go b/utils/port-blocker/port-blocker.go
new file mode 100644
index 00000000..9a358f44
--- /dev/null
+++ b/utils/port-blocker/port-blocker.go
@@ -0,0 +1,83 @@
+// Copyright (c) 2023 Proton AG
+//
+// This file is part of Proton Mail Bridge.
+//
+// Proton Mail Bridge is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Proton Mail Bridge is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Proton Mail Bridge. If not, see .
+
+// port-blocker is a command-line that ensure a port or range of ports is occupied by creating listeners.
+package main
+
+import (
+ "fmt"
+ "net"
+ "os"
+ "strconv"
+)
+
+func main() {
+ argCount := len(os.Args)
+ if (len(os.Args) < 2) || (argCount > 3) {
+ exitWithUsage("Invalid number of arguments.")
+ }
+
+ startPort := parsePort(os.Args[1])
+ endPort := startPort
+ if argCount == 3 {
+ endPort = parsePort(os.Args[2])
+ }
+
+ runBlocker(startPort, endPort)
+}
+
+func parsePort(portString string) int {
+ result, err := strconv.Atoi(portString)
+ if err != nil {
+ exitWithUsage(fmt.Sprintf("Invalid port '%v'.", portString))
+ }
+
+ if (result < 1024) || (result > 65535) { // ports below 1024 are reserved.
+ exitWithUsage("Ports must be in the range [1024-65535].")
+ }
+
+ return result
+}
+
+func exitWithUsage(message string) {
+ fmt.Printf("Usage: port-blocker []\n")
+ if len(message) > 0 {
+ fmt.Println(message)
+ }
+ os.Exit(1)
+}
+
+func runBlocker(startPort, endPort int) {
+ if endPort < startPort {
+ exitWithUsage("startPort must be less than or equal to endPort.")
+ }
+
+ for port := startPort; port <= endPort; port++ {
+ listener, err := net.Listen("tcp", "127.0.0.1:"+strconv.Itoa(port))
+ if err != nil {
+ fmt.Printf("Port %v is already blocked. Skipping.\n", port)
+ } else {
+ //goland:noinspection GoDeferInLoop
+ defer func() {
+ _ = listener.Close()
+ }()
+ }
+ }
+
+ fmt.Println("Blocking requested ports. Press enter to exit.")
+ _, _ = fmt.Scanln()
+}