We build too many walls and not enough bridges

This commit is contained in:
Jakub
2020-04-08 12:59:16 +02:00
commit 17f4d6097a
494 changed files with 62753 additions and 0 deletions

41
test/Makefile Normal file
View File

@ -0,0 +1,41 @@
.PHONY: check-has-go install-godog test test-live test-debug test-live-debug
export GO111MODULE=on
export VERSION:=1.2.5-integrationtest
export VERBOSITY?=fatal
export TEST_DATA=testdata
check-has-go:
@which go || (echo "Install Go-lang!" && exit 1)
install-godog: check-has-go
go get github.com/cucumber/godog/cmd/godog@v0.8.1
test:
which godog || $(MAKE) install-godog
TEST_ENV=fake TEST_ACCOUNTS=accounts/fake.json godog --tags="~@ignore" $(FEATURES)
# Doesn't work in parallel!
# Provide TEST_ACCOUNTS with your accounts.
test-live:
which godog || $(MAKE) install-godog
TEST_ENV=live godog --tags="~@ignore && ~@ignore-live" $(FEATURES)
# Doesn't work in parallel!
# Provide TEST_ACCOUNTS with your accounts.
# We need to pass build tag which is not possible with godog command.
# Tests against staging env are intended for debug purposes or checking new changes on API.
test-stage:
TEST_ENV=live go test -tags=$(TAGS) -- $(FEATURES)
test-debug:
TEST_ENV=fake dlv test -- $(FEATURES)
test-live-debug:
TEST_ENV=live dlv test -- $(FEATURES)
# -run flag is not working anyway, but lets keep it there to note we really do not want to run tests.
# To properly benchmark sync/fetch, we need everything empty. For that is better to start everything
# again and safest way is to run only one loop per run.
bench:
TEST_DATA=../testdata go test -run='^$$' -bench=. -benchtime=1x -timeout=60m ./benchmarks/...

130
test/README.md Normal file
View File

@ -0,0 +1,130 @@
# Integration tests
This folder contains integration tests of the Bridge app.
## What and how we are testing
```mermaid
graph LR
S[Server]
C[Client]
U[User]
Creds[Credentials store]
subgraph "Bridge app"
Core[Bridge core]
Store
Frontend["Qt / CLI"]
IMAP
SMTP
API[PMAPI]
IMAP --> Core
SMTP --> Core
Frontend --> Core
Store --> Core
Core --> API
end
C --> IMAP
C --> SMTP
U --> Frontend
API --> S
Core --> Creds
```
We want to test Bridge app from outside as much as possible. So we mock server (API),
credentials store and call commands to IMAP or SMTP the same way as client would do.
## Example test
BDD test in gherkin (cucumber) format (https://cucumber.io/docs/gherkin/reference/).
```
Feature: IMAP update messages
Background:
Given there is connected user "user"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | body | read | starred |
| john.doe@mail.com | user@pm.me | foo | hello | false | false |
| jane.doe@mail.com | name@pm.me | bar | world | true | true |
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
Scenario: Mark message as read
When IMAP client marks message "1" as read
Then IMAP response is "OK"
And message "1" in "INBOX" for "user" is marked as read
And message "1" in "INBOX" for "user" is marked as unstarred
```
Is translated into code with godog (https://github.com/cucumber/godog/).
```go
// Registration
func FeatureContext(s *godog.Suite) {
s.Step(`^there is connected user "([^"]*)"$`, thereIsConnectedUser)
}
// Godog step function
func thereIsConnectedUser(username string) error {
account := ctx.GetTestAccount(username)
if account == nil {
return godog.ErrPending
}
ctx.GetPMAPIController().AddUser(account.User, account.Addresses)
return ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword())
}
```
## BDD
BDD has three parts:
* `Given` (setup),
* `When` (action)
* and `Then` (check).
Setup has to prepare context and always end without error. Action, on
the other hand, needs to always end without error, but store it in
the context. Check should analyze the status of the bridge, store or
API and also check whether something failed before.
Therefore we cannot use a sentence such as `there is user` for both
setup and check steps. We always begin setup steps with `there is/are`,
while check steps are written in the form `something is/has feature`.
Actions are written in the form `something does action`. By doing this
we can always be sure what each steps does or should do.
In the code, we separate those parts in its own files to make sure
it's clear how the function should be implemented.
## API faked by fakeapi or liveapi
We need to control what server returns. Instead of using raw JSONs,
we fake the whole pmapi for local testing. Fake pmapi behaves as much
as possible the same way as real server, but does not follow every
single detail. Otherwise we would end up with writing complete server. :-)
For both -- fake local pmapi and real live server -- we use controller.
Controller is available on test context and does setup like setting up
internet connection, user settings, labels or messages.
Accounts for each environment are set up in `accounts` folder. Each
test function should use `TestAccount` object obtained by test ID
(such as `user` or `userMultipleAddress` for users, or `primary`
or `secondary` for addresses) and use available functions to get real
IDs (even if fake API uses the test IDs as real ones).
Testing against live is using real users and doesn't work in parallel.
Only one job against live at a time can be running.
## External e-mail accounts
We have some external accounts which we are using for testing:
* pm.bridge.qa@gmail.com
* bridge-qa@yandex.ru
* bridgeqa@seznam.cz
For access, ask bridge team.

192
test/accounts/account.go Normal file
View File

@ -0,0 +1,192 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package accounts
import (
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"sort"
"strings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
const (
testUserKey = "user_key.json"
testAddressKey = "address_key.json"
testKeyPassphrase = "testpassphrase"
)
type TestAccount struct {
user *pmapi.User
addressToBeUsed *pmapi.Address
addressesByBDDAddressID map[string]*pmapi.Address
password string
mailboxPassword string
twoFAEnabled bool
}
func newTestAccount(
user *pmapi.User,
addressesByBDDAddressID map[string]*pmapi.Address,
addressIDToBeUsed string,
password,
mailboxPassword string,
twoFAEnabled bool,
) *TestAccount {
account := &TestAccount{
user: user,
addressesByBDDAddressID: addressesByBDDAddressID,
password: password,
mailboxPassword: mailboxPassword,
twoFAEnabled: twoFAEnabled,
}
if addressIDToBeUsed == "" {
account.addressToBeUsed = account.Addresses().Main()
} else {
for addressID, address := range addressesByBDDAddressID {
if addressID == addressIDToBeUsed {
account.addressToBeUsed = address
}
}
}
if account.addressToBeUsed == nil {
// Return nothing which will be interpreted as not implemented the same way the whole account.
return nil
}
account.initKeys()
return account
}
func (a *TestAccount) initKeys() {
if a.user.Keys.Keys != nil {
return
}
userKeys := loadPMKeys(readTestFile(testUserKey))
_ = userKeys.KeyRing.Unlock([]byte(testKeyPassphrase))
addressKeys := loadPMKeys(readTestFile(testAddressKey))
_ = addressKeys.KeyRing.Unlock([]byte(testKeyPassphrase))
a.user.Keys = *userKeys
for _, addressEmail := range a.Addresses().ActiveEmails() {
a.Addresses().ByEmail(addressEmail).Keys = *addressKeys
}
}
func readTestFile(fileName string) []byte {
testDataFolder := os.Getenv("TEST_DATA")
path := filepath.Join(testDataFolder, fileName)
data, err := ioutil.ReadFile(path) //nolint[gosec]
if err != nil {
panic(err)
}
return data
}
func loadPMKeys(jsonKeys []byte) (keys *pmapi.PMKeys) {
_ = json.Unmarshal(jsonKeys, &keys)
return
}
func (a *TestAccount) User() *pmapi.User {
return a.user
}
func (a *TestAccount) UserID() string {
return a.user.ID
}
func (a *TestAccount) Username() string {
return a.user.Name
}
func (a *TestAccount) Addresses() *pmapi.AddressList {
addressArray := []*pmapi.Address{}
for _, address := range a.addressesByBDDAddressID {
addressArray = append(addressArray, address)
}
// The order of addresses is important in PMAPI because the primary
// address is always the first in array. We are using map to define
// testing addresses which can cause random re-schuffle between tests
sort.SliceStable(
addressArray,
func(i, j int) bool {
return addressArray[i].Order < addressArray[j].Order
},
)
addresses := pmapi.AddressList(addressArray)
return &addresses
}
func (a *TestAccount) Address() string {
return a.addressToBeUsed.Email
}
func (a *TestAccount) AddressID() string {
return a.addressToBeUsed.ID
}
// EnsureAddressID accepts address (simply the address) or bddAddressID used
// in tests (in format [bddAddressID]) and returns always the real address ID.
// If the address is not found, the ID of main address is returned.
func (a *TestAccount) EnsureAddressID(addressOrAddressTestID string) string {
if strings.HasPrefix(addressOrAddressTestID, "[") {
addressTestID := addressOrAddressTestID[1 : len(addressOrAddressTestID)-1]
address := a.addressesByBDDAddressID[addressTestID]
return address.ID
}
for _, address := range a.addressesByBDDAddressID {
if address.Email == addressOrAddressTestID {
return address.ID
}
}
return a.AddressID()
}
// EnsureAddress accepts address (simply the address) or bddAddressID used
// in tests (in format [bddAddressID]) and returns always the address.
// If the address ID cannot be found, the original value is returned.
func (a *TestAccount) EnsureAddress(addressOrAddressTestID string) string {
if strings.HasPrefix(addressOrAddressTestID, "[") {
addressTestID := addressOrAddressTestID[1 : len(addressOrAddressTestID)-1]
address := a.addressesByBDDAddressID[addressTestID]
return address.Email
}
return addressOrAddressTestID
}
func (a *TestAccount) Password() string {
return a.password
}
func (a *TestAccount) MailboxPassword() string {
return a.mailboxPassword
}
func (a *TestAccount) IsTwoFAEnabled() bool {
return a.twoFAEnabled
}
func (a *TestAccount) BridgePassword() string {
return BridgePassword
}

79
test/accounts/accounts.go Normal file
View File

@ -0,0 +1,79 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package accounts
import (
"encoding/json"
"io/ioutil"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
// BridgePassword is password to be used for IMAP or SMTP under tests.
const BridgePassword = "bridgepassword"
type TestAccounts struct {
Users map[string]*pmapi.User // Key is user ID used in BDD.
Addresses map[string]map[string]*pmapi.Address // Key is real user ID, second key is address ID used in BDD.
Passwords map[string]string // Key is real user ID.
MailboxPasswords map[string]string // Key is real user ID.
TwoFAs map[string]bool // Key is real user ID.
}
func Load(path string) (*TestAccounts, error) {
data, err := ioutil.ReadFile(path) //nolint[gosec]
if err != nil {
return nil, errors.Wrap(err, "failed to load JSON")
}
var testAccounts TestAccounts
err = json.Unmarshal(data, &testAccounts)
if err != nil {
return nil, errors.Wrap(err, "failed to unmarshal JSON")
}
return &testAccounts, nil
}
func (a *TestAccounts) GetTestAccount(username string) *TestAccount {
return a.GetTestAccountWithAddress(username, "")
}
func (a *TestAccounts) GetTestAccountWithAddress(username, addressID string) *TestAccount {
// Do lookup by full address and convert to name in tests.
// Used by getting real data to ensure correct address or address ID.
for key, user := range a.Users {
if user.Name == username {
username = key
break
}
}
user, ok := a.Users[username]
if !ok {
return nil
}
return newTestAccount(
user,
a.Addresses[user.Name],
addressID,
a.Passwords[user.Name],
a.MailboxPasswords[user.Name],
a.TwoFAs[user.Name],
)
}

105
test/accounts/fake.json Normal file
View File

@ -0,0 +1,105 @@
{
"users": {
"user": {
"ID": "1",
"Name": "user"
},
"user2fa": {
"ID": "2",
"Name": "user2fa"
},
"userAddressWithCapitalLetter": {
"ID": "3",
"Name": "userAddressWithCapitalLetter"
},
"userMoreAddresses": {
"ID": "4",
"Name": "userMoreAddresses"
},
"userDisabledPrimaryAddress": {
"ID": "5",
"Name": "userDisabledPrimaryAddress"
}
},
"addresses": {
"user": {
"userAddress": {
"ID": "userAddress",
"Email": "user@pm.me",
"Order": 1,
"Receive": 1
}
},
"user2fa": {
"user2faAddress": {
"ID": "user2faAddress",
"Email": "user@pm.me",
"Order": 1,
"Receive": 1
}
},
"userAddressWithCapitalLetter": {
"userAddressWithCapitalLetterAddress": {
"ID": "userAddressWithCapitalLetterAddress",
"Email": "uSeR@pm.me",
"Order": 1,
"Receive": 1
}
},
"userMoreAddresses": {
"primary": {
"ID": "primary",
"Email": "primaryaddress@pm.me",
"Order": 1,
"Receive": 1
},
"secondary": {
"ID": "secondary",
"Email": "secondaryaddress@pm.me",
"Order": 2,
"Receive": 1
},
"disabled": {
"ID": "disabled",
"Email": "disabledaddress@pm.me",
"Order": 3,
"Receive": 0
}
},
"userDisabledPrimaryAddress": {
"primary": {
"ID": "primary",
"Email": "user@pm.me",
"Order": 1,
"Receive": 0
},
"secondary": {
"ID": "secondary",
"Email": "user@pm.me",
"Order": 2,
"Receive": 1
}
}
},
"passwords": {
"user": "password",
"user2fa": "password",
"userAddressWithCapitalLetter": "password",
"userMoreAddresses": "password",
"userDisabledPrimaryAddress": "password"
},
"mailboxPasswords": {
"user": "password",
"user2fa": "password",
"userAddressWithCapitalLetter": "password",
"userMoreAddresses": "password",
"userDisabledPrimaryAddress": "password"
},
"twoFAs": {
"user": false,
"user2fa": true,
"userAddressWithCapitalLetter": false,
"userMoreAddresses": false,
"userDisabledPrimaryAddress": false
}
}

79
test/api_checks_test.go Normal file
View File

@ -0,0 +1,79 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"fmt"
"regexp"
"strings"
"github.com/cucumber/godog"
"github.com/cucumber/godog/gherkin"
)
func APIChecksFeatureContext(s *godog.Suite) {
s.Step(`^API endpoint "([^"]*)" is called with:$`, apiIsCalledWith)
s.Step(`^message is sent with API call:$`, messageIsSentWithAPICall)
}
func apiIsCalledWith(endpoint string, data *gherkin.DocString) error {
split := strings.Split(endpoint, " ")
method := split[0]
path := split[1]
request := []byte(data.Content)
if !ctx.GetPMAPIController().WasCalled(method, path, request) {
return fmt.Errorf("%s was not called with %s", endpoint, request)
}
return nil
}
func messageIsSentWithAPICall(data *gherkin.DocString) error {
endpoint := "POST /messages"
if err := apiIsCalledWith(endpoint, data); err != nil {
return err
}
for _, request := range ctx.GetPMAPIController().GetCalls("POST", "/messages") {
if !checkAllRequiredFieldsForSendingMessage(request) {
return fmt.Errorf("%s was not called with all required fields: %s", endpoint, request)
}
}
return nil
}
func checkAllRequiredFieldsForSendingMessage(request []byte) bool {
if matches := regexp.MustCompile(`"Subject":`).Match(request); !matches {
return false
}
if matches := regexp.MustCompile(`"ToList":`).Match(request); !matches {
return false
}
if matches := regexp.MustCompile(`"CCList":`).Match(request); !matches {
return false
}
if matches := regexp.MustCompile(`"BCCList":`).Match(request); !matches {
return false
}
if matches := regexp.MustCompile(`"AddressID":`).Match(request); !matches {
return false
}
if matches := regexp.MustCompile(`"Body":`).Match(request); !matches {
return false
}
return true
}

66
test/bdd_test.go Normal file
View File

@ -0,0 +1,66 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/ProtonMail/proton-bridge/test/context"
"github.com/cucumber/godog"
)
func FeatureContext(s *godog.Suite) {
s.BeforeScenario(beforeScenario)
s.AfterScenario(afterScenario)
APIChecksFeatureContext(s)
BridgeActionsFeatureContext(s)
BridgeChecksFeatureContext(s)
BridgeSetupFeatureContext(s)
IMAPActionsAuthFeatureContext(s)
IMAPActionsMailboxFeatureContext(s)
IMAPActionsMessagesFeatureContext(s)
IMAPChecksFeatureContext(s)
IMAPSetupFeatureContext(s)
SMTPActionsAuthFeatureContext(s)
SMTPChecksFeatureContext(s)
SMTPSetupFeatureContext(s)
StoreActionsFeatureContext(s)
StoreChecksFeatureContext(s)
StoreSetupFeatureContext(s)
}
var ctx *context.TestContext //nolint[gochecknoglobals]
func beforeScenario(scenario interface{}) {
ctx = context.New()
}
func afterScenario(scenario interface{}, err error) {
if err != nil {
for _, user := range ctx.GetBridge().GetUsers() {
user.GetStore().TestDumpDB(ctx.GetTestingT())
}
}
ctx.Cleanup()
if err != nil {
ctx.GetPMAPIController().PrintCalls()
}
}

View File

@ -0,0 +1,104 @@
#!/usr/bin/env python
import glob
import pandas as pd
import re
def print_header(report_file):
print('\n======== %s ========' %
(report_file.replace("./bench-", "").replace(".log", "")))
rx_line = {
'exists': re.compile(r'.*Res[A-Za-z]?: [*] (?P<mails>\d+) EXISTS.*\n'),
'bench': re.compile(r'Benchmark(?P<name>[^ \t]+)[ \t]+(?P<rpts>\d+)[ \t]+(?P<ns>\d+) ns/op.*\n'),
# 'total' : re.compile(r'ok[ \t]+(?P<pkg>[^ \t]+)[ \t]+(?P<time>[^ \t\n]+)[ \t]*\n'),
}
def parse_line(line):
for key, rx in rx_line.items():
match = rx.search(line)
if match:
return key, match
# if there are no matches
return None, None
rx_count = re.compile(r'Fetch/1:(?P<count>\d+)-')
def parse_file(filepath):
data = [] # create an empty list to collect the data
# open the file and read through it line by line
with open(filepath, 'r') as file_object:
line = file_object.readline()
last_count = 0
mails = 1
while line:
# at each line check for a match with a regex
key, match = parse_line(line)
# print(line, key, match)
if key != None:
row = match.groupdict()
if key == 'exists':
mails = int(row['mails'])
last_count = 0
if key == 'bench':
match = rx_count.search(row['name'])
row['mails'] = mails - last_count
if match:
count = int(match.group('count'))
if count < mails:
row['mails'] = count - last_count
last_count = count
row['rpts'] = int(row['rpts'])
row['ns'] = int(row['ns'])
row['time/op'] = human_duration(row['ns'])
if row['mails'] > 0:
row['time/mails'] = human_duration(
row['ns']/row['mails']
)
data.append(row)
if key == 'total':
row['name'] = key
data.append(row)
line = file_object.readline()
return data
def human_duration(duration_ns):
unit = 'ns'
factor = 1.
unit_factors = [
('us', 1.e3),
('ms', 1.e3),
('s ', 1.e3),
('m ', 60.),
('h ', 60.),
('d ', 24.),
('w ', 7.),
('m ', 30./7.),
('y ', 12.),
]
for unit_factor in unit_factors:
if (abs(duration_ns) / factor / unit_factor[1]) < 1.0:
break
unit = unit_factor[0]
factor *= unit_factor[1]
return "%4.2f%s" % (duration_ns/factor, unit)
def print_table(data):
data = pd.DataFrame(data)
data.set_index('name', inplace=True)
print(data)
if __name__ == "__main__":
# for d in [ 0.5, 1, 2, 5, 1e3, 5e3, 1e4, 1e5, 1e6, 1e9, 2e9, 1e10, 1e11, 1e12, ]: print(human_duration(int(d)))
for report_file in glob.glob("./*.log"):
print_header(report_file)
print_table(parse_file(report_file))

View File

@ -0,0 +1,201 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package benchmarks
import (
"strings"
"testing"
"time"
"github.com/ProtonMail/proton-bridge/test/context"
"github.com/ProtonMail/proton-bridge/test/mocks"
)
func benchTestContext() (*context.TestContext, *mocks.IMAPClient) {
ctx := context.New()
username := "user"
account := ctx.GetTestAccount(username)
if account == nil {
panic("account " + username + " does not exist")
}
_ = ctx.GetPMAPIController().AddUser(account.User(), account.Addresses(), account.Password(), account.IsTwoFAEnabled())
if err := ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword()); err != nil {
panic(err)
}
imapClient := ctx.GetIMAPClient("client")
imapClient.Login(account.Address(), account.BridgePassword())
// waitForSync between bridge and API. There is no way to know precisely
// from the outside when the bridge is synced. We could wait for first
// response from any fetch, but we don't know how many messages should be
// there. Unless we hard code the number of messages.
// Please, check this time is enough when doing benchmarks and don't forget
// to exclude this time from total time.
time.Sleep(10 * time.Second)
return ctx, imapClient
}
func BenchmarkIMAPFetch(b *testing.B) {
tc, c := benchTestContext()
defer tc.Cleanup()
c.Select("All Mail").AssertOK()
fetchBench := []struct{ ids, args string }{
{"1:10", "rfc822.size"},
{"1:100", "rfc822.size"},
{"1:1000", "rfc822.size"},
{"1:*", "rfc822.size"},
}
for _, bd := range fetchBench {
ids, args := bd.ids, bd.args // pin
b.Run(ids+"-"+args, func(b *testing.B) {
for i := 0; i < b.N; i++ {
c.Fetch(ids, args)
}
})
}
}
func BenchmarkCachingFetch(b *testing.B) {
tc, c := benchTestContext()
defer tc.Cleanup()
c.Select("\"All Mail\"").AssertOK()
ids := "1:100"
args := "body.peek[]"
tries := []string{"long", "short"}
for _, try := range tries {
b.Run(strings.Join([]string{ids, args, try}, "-"), func(b *testing.B) {
for i := 0; i < b.N; i++ {
c.Fetch(ids, args)
}
})
}
}
func BenchmarkIMAPAppleMail(b *testing.B) {
tc, c := benchTestContext()
defer tc.Cleanup()
// assume we have at least 50 messages in INBOX
idRange := "1:50"
newUID := "50" // assume that Apple mail don't know about this mail
// I will use raw send command to completely reproduce the calls
// (including quotation and case sensitivity)
b.Run("default", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, command := range []string{
"CAPABILITY",
"ID (" +
`"name" "Mac OS X Mail" ` +
`"version" "11.5 (3445.9.1)" ` +
`"os" "Mac OS X" ` +
`"os-version" "10.13.6 (17G3025)" ` +
`"vendor" "Apple Inc."` +
")",
`LIST "" ""`,
`STATUS INBOX (MESSAGES UIDNEXT UIDVALIDITY UNSEEN)`,
`SELECT INBOX`,
`FETCH ` + idRange + ` (FLAGS UID)`,
`FETCH ` + idRange + " " +
`(` +
`INTERNALDATE UID RFC822.SIZE FLAGS ` +
`BODY.PEEK[` +
`HEADER.FIELDS (` +
`date subject from to cc message-id in-reply-to references ` +
`x-priority x-uniform-type-identifier x-universally-unique-identifier ` +
`list-id list-unsubscribe` +
`)])`,
`UID FETCH ` + newUID + ` (BODYSTRUCTURE BODY.PEEK[HEADER])`,
// if email has attachment it is splitted to several fetches
//`UID FETCH 133 (BODY.PEEK[3]<0.5877469> BODY.PEEK[1] BODY.PEEK[2])`,
//`UID FETCH 133 BODY.PEEK[3]<5877469.2925661>`,
// here I will just use section download, which is used by AppleMail
`UID FETCH ` + newUID + ` BODY.PEEK[1]`,
// here I will just use partial download, which is used by AppleMail
`UID FETCH ` + newUID + ` BODY.PEEK[]<0.2000>`,
} {
c.SendCommand(command).AssertOK()
}
}
})
}
func BenchmarkIMAPOutlook(b *testing.B) {
tc, c := benchTestContext()
defer tc.Cleanup()
// assume we have at least 50 messages in INBOX
idRange := "1:50"
// I will use raw send command to completely reproduce the calls
// (including quotation and case sensitivity)
b.Run("default", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, command := range []string{
/*
"ID ("+
`"name" "Mac OS X Mail" `+
`"version" "11.5 (3445.9.1)" `+
`"os" "Mac OS X" `+
`"os-version" "10.13.6 (17G3025)" `+
`"vendor" "Apple Inc."`+
")",
*/
`SELECT "INBOX"`,
`UID SEARCH ` + idRange + ` SINCE 01-Sep-2019`,
`UID FETCH 1:* (UID FLAGS)`,
`UID FETCH ` + idRange + ` (UID FLAGS RFC822.SIZE BODY.PEEK[] INTERNALDATE)`,
} {
c.SendCommand(command).AssertOK()
}
}
})
}
func BenchmarkIMAPThunderbird(b *testing.B) {
tc, c := benchTestContext()
defer tc.Cleanup()
// I will use raw send command to completely reproduce the calls
// (including quotation and case sensitivity)
b.Run("default", func(b *testing.B) {
for i := 0; i < b.N; i++ {
for _, command := range []string{
`capability`,
`ID ("name" "Thunderbird" "version" "68.2.0")`,
`select "INBOX"`,
`getquotaroot "INBOX"`,
`UID fetch 1:* (FLAGS)`,
} {
c.SendCommand(command).AssertOK()
}
}
})
}

134
test/bridge_actions_test.go Normal file
View File

@ -0,0 +1,134 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"time"
"github.com/cucumber/godog"
)
func BridgeActionsFeatureContext(s *godog.Suite) {
s.Step(`^bridge starts$`, bridgeStarts)
s.Step(`^bridge syncs "([^"]*)"$`, bridgeSyncsUser)
s.Step(`^"([^"]*)" logs in to bridge$`, userLogsInToBridge)
s.Step(`^"([^"]*)" logs in to bridge with bad password$`, userLogsInToBridgeWithBadPassword)
s.Step(`^"([^"]*)" logs out from bridge$`, userLogsOutFromBridge)
s.Step(`^"([^"]*)" changes the address mode$`, userChangesTheAddressMode)
s.Step(`^user deletes "([^"]*)" from bridge$`, userDeletesUserFromBridge)
s.Step(`^user deletes "([^"]*)" from bridge with cache$`, userDeletesUserFromBridgeWithCache)
s.Step(`^the internet connection is lost$`, theInternetConnectionIsLost)
s.Step(`^the internet connection is restored$`, theInternetConnectionIsRestored)
s.Step(`^(\d+) seconds pass$`, secondsPass)
}
func bridgeStarts() error {
ctx.SetLastBridgeError(ctx.RestartBridge())
return nil
}
func bridgeSyncsUser(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
if err := ctx.WaitForSync(account.Username()); err != nil {
return internalError(err, "waiting for sync")
}
ctx.SetLastBridgeError(ctx.GetTestingError())
return nil
}
func userLogsInToBridge(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.SetLastBridgeError(ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword()))
return nil
}
func userLogsInToBridgeWithBadPassword(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.SetLastBridgeError(ctx.LoginUser(account.Username(), "you shall not pass!", "123"))
return nil
}
func userLogsOutFromBridge(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.SetLastBridgeError(ctx.LogoutUser(account.Username()))
return nil
}
func userChangesTheAddressMode(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
if err := bridgeUser.SwitchAddressMode(); err != nil {
return err
}
ctx.EventuallySyncIsFinishedForUsername(account.Username())
return nil
}
func userDeletesUserFromBridge(bddUserID string) error {
return deleteUserFromBridge(bddUserID, false)
}
func userDeletesUserFromBridgeWithCache(bddUserID string) error {
return deleteUserFromBridge(bddUserID, true)
}
func deleteUserFromBridge(bddUserID string, cache bool) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
return ctx.GetBridge().DeleteUser(bridgeUser.ID(), cache)
}
func theInternetConnectionIsLost() error {
ctx.GetPMAPIController().TurnInternetConnectionOff()
return nil
}
func theInternetConnectionIsRestored() error {
ctx.GetPMAPIController().TurnInternetConnectionOn()
return nil
}
func secondsPass(seconds int) error {
time.Sleep(time.Duration(seconds) * time.Second)
return nil
}

198
test/bridge_checks_test.go Normal file
View File

@ -0,0 +1,198 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"time"
"github.com/cucumber/godog"
a "github.com/stretchr/testify/assert"
)
func BridgeChecksFeatureContext(s *godog.Suite) {
s.Step(`^bridge response is "([^"]*)"$`, bridgeResponseIs)
s.Step(`^"([^"]*)" has address mode in "([^"]*)" mode$`, userHasAddressModeInMode)
s.Step(`^"([^"]*)" is disconnected$`, userIsDisconnected)
s.Step(`^"([^"]*)" is connected$`, userIsConnected)
s.Step(`^"([^"]*)" has database file$`, userHasDatabaseFile)
s.Step(`^"([^"]*)" does not have database file$`, userDoesNotHaveDatabaseFile)
s.Step(`^"([^"]*)" has loaded store$`, userHasLoadedStore)
s.Step(`^"([^"]*)" does not have loaded store$`, userDoesNotHaveLoadedStore)
s.Step(`^"([^"]*)" has running event loop$`, userHasRunningEventLoop)
s.Step(`^"([^"]*)" does not have running event loop$`, userDoesNotHaveRunningEventLoop)
s.Step(`^"([^"]*)" does not have API auth$`, doesNotHaveAPIAuth)
s.Step(`^"([^"]*)" has API auth$`, hasAPIAuth)
}
func bridgeResponseIs(expectedResponse string) error {
err := ctx.GetLastBridgeError()
if expectedResponse == "OK" {
a.NoError(ctx.GetTestingT(), err)
} else {
a.EqualError(ctx.GetTestingT(), err, expectedResponse)
}
return ctx.GetTestingError()
}
func userHasAddressModeInMode(bddUserID, wantAddressMode string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
addressMode := "split"
if bridgeUser.IsCombinedAddressMode() {
addressMode = "combined"
}
a.Equal(ctx.GetTestingT(), wantAddressMode, addressMode)
return ctx.GetTestingError()
}
func userIsDisconnected(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
a.False(ctx.GetTestingT(), bridgeUser.IsConnected())
return ctx.GetTestingError()
}
func userIsConnected(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
t := ctx.GetTestingT()
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
a.True(ctx.GetTestingT(), bridgeUser.IsConnected())
a.NotEmpty(t, bridgeUser.GetPrimaryAddress())
a.NotEmpty(t, bridgeUser.GetStoreAddresses())
return ctx.GetTestingError()
}
func userHasDatabaseFile(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
filePath := ctx.GetDatabaseFilePath(account.UserID())
a.FileExists(ctx.GetTestingT(), filePath)
return ctx.GetTestingError()
}
func userDoesNotHaveDatabaseFile(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
filePath := ctx.GetDatabaseFilePath(account.UserID())
a.NoFileExists(ctx.GetTestingT(), filePath)
return ctx.GetTestingError()
}
func userHasLoadedStore(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
a.NotNil(ctx.GetTestingT(), bridgeUser.GetStore())
return ctx.GetTestingError()
}
func userDoesNotHaveLoadedStore(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
a.Nil(ctx.GetTestingT(), bridgeUser.GetStore())
return ctx.GetTestingError()
}
func userHasRunningEventLoop(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
store, err := ctx.GetStore(account.Username())
if err != nil {
return internalError(err, "getting store of %s", account.Username())
}
a.Eventually(ctx.GetTestingT(), func() bool {
return store.TestGetEventLoop() != nil && store.TestGetEventLoop().IsRunning()
}, 5*time.Second, 10*time.Millisecond)
return ctx.GetTestingError()
}
func userDoesNotHaveRunningEventLoop(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
store, err := ctx.GetStore(account.Username())
if err != nil {
return internalError(err, "getting store of %s", account.Username())
}
a.Eventually(ctx.GetTestingT(), func() bool {
return store.TestGetEventLoop() == nil || !store.TestGetEventLoop().IsRunning()
}, 5*time.Second, 10*time.Millisecond)
return ctx.GetTestingError()
}
func hasAPIAuth(accountName string) error {
account := ctx.GetTestAccount(accountName)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
a.True(ctx.GetTestingT(), bridgeUser.HasAPIAuth())
return ctx.GetTestingError()
}
func doesNotHaveAPIAuth(accountName string) error {
account := ctx.GetTestAccount(accountName)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
a.False(ctx.GetTestingT(), bridgeUser.HasAPIAuth())
return ctx.GetTestingError()
}

139
test/bridge_setup_test.go Normal file
View File

@ -0,0 +1,139 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"os"
"time"
"github.com/cucumber/godog"
a "github.com/stretchr/testify/assert"
)
func BridgeSetupFeatureContext(s *godog.Suite) {
s.Step(`^there is no internet connection$`, thereIsNoInternetConnection)
s.Step(`^there is user "([^"]*)"$`, thereIsUser)
s.Step(`^there is connected user "([^"]*)"$`, thereIsConnectedUser)
s.Step(`^there is disconnected user "([^"]*)"$`, thereIsDisconnectedUser)
s.Step(`^there is database file for "([^"]*)"$`, thereIsDatabaseFileForUser)
s.Step(`^there is no database file for "([^"]*)"$`, thereIsNoDatabaseFileForUser)
s.Step(`^there is "([^"]*)" in "([^"]*)" address mode$`, thereIsUserWithAddressMode)
}
func thereIsNoInternetConnection() error {
ctx.GetPMAPIController().TurnInternetConnectionOff()
return nil
}
func thereIsUser(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
err := ctx.GetPMAPIController().AddUser(account.User(), account.Addresses(), account.Password(), account.IsTwoFAEnabled())
return internalError(err, "adding user %s", account.Username())
}
func thereIsConnectedUser(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
err := ctx.GetPMAPIController().AddUser(account.User(), account.Addresses(), account.Password(), account.IsTwoFAEnabled())
if err != nil {
return internalError(err, "adding user %s", account.Username())
}
return ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword())
}
func thereIsDisconnectedUser(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
err := ctx.GetPMAPIController().AddUser(account.User(), account.Addresses(), account.Password(), account.IsTwoFAEnabled())
if err != nil {
return internalError(err, "adding user %s", account.Username())
}
err = ctx.LoginUser(account.Username(), account.Password(), account.MailboxPassword())
if err != nil {
return internalError(err, "logging user %s in", account.Username())
}
user, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
err = user.Logout()
if err != nil {
return internalError(err, "disconnecting user %s", account.Username())
}
// We need to wait till event loop is stopped because when it's stopped
// logout is also called and if we would do login at the same time, it
// wouldn't work. 100 ms after event loop is stopped should be enough.
a.Eventually(ctx.GetTestingT(), func() bool {
return !user.GetStore().TestGetEventLoop().IsRunning()
}, 1*time.Second, 10*time.Millisecond)
time.Sleep(100 * time.Millisecond)
return ctx.GetTestingError()
}
func thereIsDatabaseFileForUser(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
filePath := ctx.GetDatabaseFilePath(account.UserID())
_, err := os.Stat(filePath)
return internalError(err, "getting database file of %s", account.Username())
}
func thereIsNoDatabaseFileForUser(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
filePath := ctx.GetDatabaseFilePath(account.UserID())
if _, err := os.Stat(filePath); err != nil {
return nil
}
return internalError(os.Remove(filePath), "removing database file of %s", account.Username())
}
func thereIsUserWithAddressMode(bddUserID, wantAddressMode string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
bridgeUser, err := ctx.GetUser(account.Username())
if err != nil {
return internalError(err, "getting user %s", account.Username())
}
addressMode := "split"
if bridgeUser.IsCombinedAddressMode() {
addressMode = "combined"
}
if wantAddressMode != addressMode {
err := bridgeUser.SwitchAddressMode()
if err != nil {
return internalError(err, "switching mode")
}
}
ctx.EventuallySyncIsFinishedForUsername(bridgeUser.Username())
return nil
}

57
test/context/accounts.go Normal file
View File

@ -0,0 +1,57 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"os"
"github.com/ProtonMail/proton-bridge/test/accounts"
)
func newTestAccounts() *accounts.TestAccounts {
envFile := os.Getenv("TEST_ACCOUNTS")
data, err := accounts.Load(envFile)
if err != nil {
panic(err)
}
return data
}
func (ctx *TestContext) GetTestAccount(bddUserID string) *accounts.TestAccount {
return ctx.testAccounts.GetTestAccount(bddUserID)
}
func (ctx *TestContext) GetTestAccountWithAddress(bddUserID, addressID string) *accounts.TestAccount {
return ctx.testAccounts.GetTestAccountWithAddress(bddUserID, addressID)
}
func (ctx *TestContext) EnsureAddressID(bddUserID, addressOrAddressTestID string) string {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return addressOrAddressTestID
}
return account.EnsureAddressID(addressOrAddressTestID)
}
func (ctx *TestContext) EnsureAddress(bddUserID, addressOrAddressTestID string) string {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return addressOrAddressTestID
}
return account.EnsureAddress(addressOrAddressTestID)
}

42
test/context/bddt.go Normal file
View File

@ -0,0 +1,42 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"fmt"
"github.com/hashicorp/go-multierror"
"github.com/pkg/errors"
)
type bddT struct {
err *multierror.Error
}
func (t *bddT) Errorf(msg string, args ...interface{}) {
err := fmt.Errorf(msg, args...)
t.err = multierror.Append(t.err, err)
}
func (t *bddT) FailNow() {
t.err = multierror.Append(t.err, errors.New("failed by calling FailNow"))
}
func (t *bddT) getErrors() error {
return t.err.ErrorOrNil()
}

84
test/context/bridge.go Normal file
View File

@ -0,0 +1,84 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"os"
"runtime"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/preferences"
"github.com/ProtonMail/proton-bridge/pkg/listener"
)
// GetBridge returns bridge instance.
func (ctx *TestContext) GetBridge() *bridge.Bridge {
return ctx.bridge
}
// withBridgeInstance creates a bridge instance for use in the test.
// Every TestContext has this by default and thus this doesn't need to be exported.
func (ctx *TestContext) withBridgeInstance() {
pmapiFactory := func(userID string) bridge.PMAPIProvider {
return ctx.pmapiController.GetClient(userID)
}
ctx.bridge = newBridgeInstance(ctx.t, ctx.cfg, ctx.credStore, ctx.listener, pmapiFactory)
ctx.addCleanupChecked(ctx.bridge.ClearData, "Cleaning bridge data")
}
// RestartBridge closes store for each user and recreates a bridge instance the same way as `withBridgeInstance`.
// NOTE: This is a very problematic method. It doesn't stop the goroutines doing the event loop and the sync.
// These goroutines can continue to run and can cause problems or unexpected behaviour (especially
// regarding authorization, because if an auth fails, it will log out the user).
// To truly emulate bridge restart, we need a way to immediately stop those goroutines.
// I have added a channel that waits up to one second for the event loop to stop, but that isn't great.
func (ctx *TestContext) RestartBridge() error {
for _, user := range ctx.bridge.GetUsers() {
_ = user.GetStore().Close()
}
ctx.withBridgeInstance()
return nil
}
// newBridgeInstance creates a new bridge instance configured to use the given config/credstore.
func newBridgeInstance(
t *bddT,
cfg *fakeConfig,
credStore bridge.CredentialsStorer,
eventListener listener.Listener,
pmapiFactory bridge.PMAPIProviderFactory,
) *bridge.Bridge {
version := os.Getenv("VERSION")
bridge.UpdateCurrentUserAgent(version, runtime.GOOS, "", "")
panicHandler := &panicHandler{t: t}
pref := preferences.New(cfg)
return bridge.New(cfg, pref, panicHandler, eventListener, version, pmapiFactory, credStore)
}
// SetLastBridgeError sets the last error that occurred while executing a bridge action.
func (ctx *TestContext) SetLastBridgeError(err error) {
ctx.bridgeLastError = err
}
// GetLastBridgeError returns the last error that occurred while executing a bridge action.
func (ctx *TestContext) GetLastBridgeError() error {
return ctx.bridgeLastError
}

View File

@ -0,0 +1,49 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"bytes"
"io/ioutil"
"runtime/pprof"
)
type panicHandler struct {
t *bddT
}
func newPanicHandler(t *bddT) *panicHandler {
return &panicHandler{
t: t,
}
}
// HandlePanic makes the panicHandler implement the panicHandler interface for bridge.
func (ph *panicHandler) HandlePanic() {
r := recover()
if r != nil {
ph.t.Errorf("panic: %s", r)
r := bytes.NewBufferString("")
_ = pprof.Lookup("goroutine").WriteTo(r, 2)
b, err := ioutil.ReadAll(r)
ph.t.Errorf("pprof details: %s %s", err, b)
ph.t.FailNow()
}
}

148
test/context/bridge_user.go Normal file
View File

@ -0,0 +1,148 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"fmt"
"math/rand"
"path/filepath"
"time"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/store"
"github.com/ProtonMail/proton-bridge/pkg/srp"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
)
// LoginUser logs in the user with the given username, password, and mailbox password.
func (ctx *TestContext) LoginUser(username, password, mailboxPassword string) (err error) {
srp.RandReader = rand.New(rand.NewSource(42))
client, auth, err := ctx.bridge.Login(username, password)
if err != nil {
return errors.Wrap(err, "failed to login")
}
if auth.HasTwoFactor() {
if _, err := client.Auth2FA("2fa code", auth); err != nil {
return errors.Wrap(err, "failed to login with 2FA")
}
}
user, err := ctx.bridge.FinishLogin(client, auth, mailboxPassword)
if err != nil {
return errors.Wrap(err, "failed to finish login")
}
ctx.addCleanupChecked(user.Logout, "Logging out user")
return
}
// GetUser retrieves the bridge user matching the given query string.
func (ctx *TestContext) GetUser(username string) (*bridge.User, error) {
return ctx.bridge.GetUser(username)
}
// GetStore retrieves the store for given username.
func (ctx *TestContext) GetStore(username string) (*store.Store, error) {
user, err := ctx.GetUser(username)
if err != nil {
return nil, err
}
return user.GetStore(), nil
}
// GetStoreAddress retrieves the store address for given username and addressID.
func (ctx *TestContext) GetStoreAddress(username, addressID string) (*store.Address, error) {
store, err := ctx.GetStore(username)
if err != nil {
return nil, err
}
return store.GetAddress(addressID)
}
// GetStoreMailbox retrieves the store mailbox for given username, address ID and mailbox name.
func (ctx *TestContext) GetStoreMailbox(username, addressID, mailboxName string) (*store.Mailbox, error) {
address, err := ctx.GetStoreAddress(username, addressID)
if err != nil {
return nil, err
}
return address.GetMailbox(mailboxName)
}
// GetDatabaseFilePath returns the file path of the user's store file.
func (ctx *TestContext) GetDatabaseFilePath(userID string) string {
// We cannot use store to get information because we need to check db file also when user is deleted from bridge.
fileName := fmt.Sprintf("mailbox-%v.db", userID)
return filepath.Join(ctx.cfg.GetDBDir(), fileName)
}
// WaitForSync waits for sync to be done.
func (ctx *TestContext) WaitForSync(username string) error {
store, err := ctx.GetStore(username)
if err != nil {
return err
}
// First wait for ongoing sync to be done before starting and waiting for new one.
ctx.eventuallySyncIsFinished(store)
store.TestSync()
ctx.eventuallySyncIsFinished(store)
return nil
}
func (ctx *TestContext) eventuallySyncIsFinished(store *store.Store) {
assert.Eventually(ctx.t, func() bool { return !store.TestIsSyncRunning() }, 30*time.Second, 10*time.Millisecond)
}
// EventuallySyncIsFinishedForUsername will wait until sync is finished or
// deadline is reached see eventuallySyncIsFinished for timing
func (ctx *TestContext) EventuallySyncIsFinishedForUsername(username string) {
store, err := ctx.GetStore(username)
assert.Nil(ctx.t, err)
ctx.eventuallySyncIsFinished(store)
}
// LogoutUser logs out the given user.
func (ctx *TestContext) LogoutUser(query string) (err error) {
user, err := ctx.bridge.GetUser(query)
if err != nil {
return errors.Wrap(err, "failed to get user")
}
if err = user.Logout(); err != nil {
return errors.Wrap(err, "failed to logout user")
}
return
}
// DeleteUser deletes the given user.
func (ctx *TestContext) DeleteUser(query string, deleteStore bool) (err error) {
user, err := ctx.bridge.GetUser(query)
if err != nil {
return errors.Wrap(err, "failed to get user")
}
if err = ctx.bridge.DeleteUser(user.ID(), deleteStore); err != nil {
err = errors.Wrap(err, "failed to delete user")
}
return
}

87
test/context/cleaner.go Normal file
View File

@ -0,0 +1,87 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"fmt"
"path/filepath"
"runtime"
"github.com/stretchr/testify/require"
)
// Cleaner is a test step that cleans up some stuff post-test.
type Cleaner struct {
// file is the filename of the caller.
file string
// lineNumber is the line number of the caller.
lineNumber int
// label is a descriptive label of the step being performed.
label string
// ctx is the TestContext on which the step operates.
ctx *TestContext
// cleanup is callback doing clean up.
cleanup func()
}
// Execute runs the cleaner operation.
func (c *Cleaner) Execute() {
c.ctx.logger.WithField("from", c.From()).Info(c.label)
c.cleanup()
}
// From returns the filepath and line number of the place where this cleaner was created.
func (c *Cleaner) From() string {
return fmt.Sprintf("%v:%v", c.file, c.lineNumber)
}
// addCleanup adds an operation to be performed at the end of the test.
func (ctx *TestContext) addCleanup(c func(), label string) {
cleaner := &Cleaner{
cleanup: c,
label: label,
ctx: ctx,
}
if _, file, line, ok := runtime.Caller(1); ok {
cleaner.file, cleaner.lineNumber = filepath.Base(file), line
}
ctx.cleanupSteps = append(ctx.cleanupSteps, cleaner)
}
// addCleanupChecked adds an operation that may return an error to be performed at the end of the test.
// If the operation fails, the test is failed.
func (ctx *TestContext) addCleanupChecked(f func() error, label string) {
checkedFunction := func() {
err := f()
require.NoError(ctx.t, err)
}
cleaner := &Cleaner{
cleanup: checkedFunction,
label: label,
ctx: ctx,
}
if _, file, line, ok := runtime.Caller(1); ok {
cleaner.file, cleaner.lineNumber = filepath.Base(file), line
}
ctx.cleanupSteps = append(ctx.cleanupSteps, cleaner)
}

91
test/context/config.go Normal file
View File

@ -0,0 +1,91 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"io/ioutil"
"math/rand"
"os"
"path/filepath"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
type fakeConfig struct {
dir string
tm *pmapi.TokenManager
}
// newFakeConfig creates a temporary folder for files.
// It's expected the test calls `ClearData` before finish to remove it from the file system.
func newFakeConfig() *fakeConfig {
dir, err := ioutil.TempDir("", "example")
if err != nil {
panic(err)
}
return &fakeConfig{
dir: dir,
tm: pmapi.NewTokenManager(),
}
}
func (c *fakeConfig) ClearData() error {
return os.RemoveAll(c.dir)
}
func (c *fakeConfig) GetAPIConfig() *pmapi.ClientConfig {
return &pmapi.ClientConfig{
AppVersion: "Bridge_" + os.Getenv("VERSION"),
ClientID: "bridge",
// TokenManager should not be required, but PMAPI still doesn't handle not-set cases everywhere.
TokenManager: c.tm,
}
}
func (c *fakeConfig) GetDBDir() string {
return c.dir
}
func (c *fakeConfig) GetLogDir() string {
return c.dir
}
func (c *fakeConfig) GetLogPrefix() string {
return "test"
}
func (c *fakeConfig) GetPreferencesPath() string {
return filepath.Join(c.dir, "prefs.json")
}
func (c *fakeConfig) GetTLSCertPath() string {
return filepath.Join(c.dir, "cert.pem")
}
func (c *fakeConfig) GetTLSKeyPath() string {
return filepath.Join(c.dir, "key.pem")
}
func (c *fakeConfig) GetEventsPath() string {
return filepath.Join(c.dir, "events.json")
}
func (c *fakeConfig) GetIMAPCachePath() string {
return filepath.Join(c.dir, "user_info.json")
}
func (c *fakeConfig) GetDefaultAPIPort() int {
return 21042
}
func (c *fakeConfig) GetDefaultIMAPPort() int {
return 21100 + rand.Intn(100)
}
func (c *fakeConfig) GetDefaultSMTPPort() int {
return 21200 + rand.Intn(100)
}

118
test/context/context.go Normal file
View File

@ -0,0 +1,118 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
// Package context allows integration tests to be written in a fluent, english-like way.
package context
import (
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/pkg/listener"
"github.com/ProtonMail/proton-bridge/test/accounts"
"github.com/ProtonMail/proton-bridge/test/mocks"
"github.com/sirupsen/logrus"
)
type server interface {
ListenAndServe()
Close()
}
// TestContext manages a bridge test (mocked API, bridge instance, IMAP/SMTP servers, setup steps).
type TestContext struct {
// Base setup for the whole bridge (core & imap & smtp).
t *bddT
cfg *fakeConfig
listener listener.Listener
pmapiController PMAPIController // pmapiController is used to create pmapi clients (either real or fake) and control server state.
testAccounts *accounts.TestAccounts
// Bridge core related variables.
bridge *bridge.Bridge
bridgeLastError error
credStore bridge.CredentialsStorer
// IMAP related variables.
imapAddr string
imapServer server
imapClients map[string]*mocks.IMAPClient
imapLastResponses map[string]*mocks.IMAPResponse
// SMTP related variables.
smtpAddr string
smtpServer server
smtpClients map[string]*mocks.SMTPClient
smtpLastResponses map[string]*mocks.SMTPResponse
// These are the cleanup steps executed when Cleanup() is called.
cleanupSteps []*Cleaner
// logger allows logging of test labels to be handled differently (silenced/diverted/whatever).
logger logrus.FieldLogger
}
// New returns a new test TestContext.
func New() *TestContext {
setLogrusVerbosityFromEnv()
cfg := newFakeConfig()
ctx := &TestContext{
t: &bddT{},
cfg: cfg,
listener: listener.New(),
pmapiController: newPMAPIController(),
testAccounts: newTestAccounts(),
credStore: newFakeCredStore(),
imapClients: make(map[string]*mocks.IMAPClient),
imapLastResponses: make(map[string]*mocks.IMAPResponse),
smtpClients: make(map[string]*mocks.SMTPClient),
smtpLastResponses: make(map[string]*mocks.SMTPResponse),
logger: logrus.StandardLogger(),
}
// Ensure that the config is cleaned up after the test is over.
ctx.addCleanupChecked(cfg.ClearData, "Cleaning bridge config data")
// Create bridge instance under test.
ctx.withBridgeInstance()
return ctx
}
// Cleanup runs through all cleanup steps.
// This can be a deferred call so that it is run even if the test steps failed the test.
func (ctx *TestContext) Cleanup() *TestContext {
for _, cleanup := range ctx.cleanupSteps {
cleanup.Execute()
}
return ctx
}
// GetPMAPIController returns API controller, either fake or live.
func (ctx *TestContext) GetPMAPIController() PMAPIController {
return ctx.pmapiController
}
// GetTestingT returns testing.T compatible struct.
func (ctx *TestContext) GetTestingT() *bddT { //nolint[golint]
return ctx.t
}
// GetTestingError returns error if test failed by using custom TestingT.
func (ctx *TestContext) GetTestingError() error {
return ctx.t.getErrors()
}

102
test/context/credentials.go Normal file
View File

@ -0,0 +1,102 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"strings"
"github.com/ProtonMail/proton-bridge/internal/bridge/credentials"
)
// bridgePassword is password to be used for IMAP or SMTP under tests.
const bridgePassword = "bridgepassword"
type fakeCredStore struct {
credentials map[string]*credentials.Credentials
}
// newFakeCredStore returns a fake credentials store (optionally configured with the given credentials).
func newFakeCredStore(initCreds ...*credentials.Credentials) (c *fakeCredStore) {
c = &fakeCredStore{credentials: map[string]*credentials.Credentials{}}
for _, creds := range initCreds {
if creds == nil {
continue
}
c.credentials[creds.UserID] = &credentials.Credentials{}
*c.credentials[creds.UserID] = *creds
}
return
}
func (c *fakeCredStore) List() (userIDs []string, err error) {
keys := []string{}
for key := range c.credentials {
keys = append(keys, key)
}
return keys, nil
}
func (c *fakeCredStore) Add(userID, userName, apiToken, mailboxPassword string, emails []string) (*credentials.Credentials, error) {
bridgePassword := bridgePassword
if c, ok := c.credentials[userID]; ok {
bridgePassword = c.BridgePassword
}
c.credentials[userID] = &credentials.Credentials{
UserID: userID,
Name: userName,
Emails: strings.Join(emails, ";"),
APIToken: apiToken,
MailboxPassword: mailboxPassword,
BridgePassword: bridgePassword,
IsCombinedAddressMode: true, // otherwise by default starts in split mode
}
return c.Get(userID)
}
func (c *fakeCredStore) Get(userID string) (*credentials.Credentials, error) {
return c.credentials[userID], nil
}
func (c *fakeCredStore) SwitchAddressMode(userID string) error {
return nil
}
func (c *fakeCredStore) UpdateEmails(userID string, emails []string) error {
return nil
}
func (c *fakeCredStore) UpdateToken(userID, apiToken string) error {
creds, err := c.Get(userID)
if err != nil {
return err
}
creds.APIToken = apiToken
return nil
}
func (c *fakeCredStore) Logout(userID string) error {
c.credentials[userID].APIToken = ""
c.credentials[userID].MailboxPassword = ""
return nil
}
func (c *fakeCredStore) Delete(userID string) error {
delete(c.credentials, userID)
return nil
}

View File

@ -0,0 +1,40 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"os"
"time"
)
const (
EnvName = "TEST_ENV"
EnvFake = "fake"
EnvLive = "live"
)
func (ctx *TestContext) EventLoopTimeout() time.Duration {
switch os.Getenv(EnvName) {
case EnvFake:
return 5 * time.Second
case EnvLive:
return 60 * time.Second
default:
panic("unknown env")
}
}

80
test/context/imap.go Normal file
View File

@ -0,0 +1,80 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"fmt"
"time"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/imap"
"github.com/ProtonMail/proton-bridge/internal/preferences"
"github.com/ProtonMail/proton-bridge/pkg/config"
"github.com/ProtonMail/proton-bridge/test/mocks"
"github.com/stretchr/testify/require"
)
// GetIMAPClient gets the imap client by name; if it doesn't exist yet it creates it.
func (ctx *TestContext) GetIMAPClient(handle string) *mocks.IMAPClient {
if client, ok := ctx.imapClients[handle]; ok {
return client
}
return ctx.newIMAPClient(handle)
}
func (ctx *TestContext) newIMAPClient(handle string) *mocks.IMAPClient {
ctx.withIMAPServer()
client := mocks.NewIMAPClient(ctx.t, handle, ctx.imapAddr)
ctx.imapClients[handle] = client
ctx.addCleanup(client.Close, "Closing IMAP client")
return client
}
// withIMAPServer starts an imap server and connects it to the bridge instance.
// Every TestContext has this by default and thus this doesn't need to be exported.
func (ctx *TestContext) withIMAPServer() {
if ctx.imapServer != nil {
return
}
ph := newPanicHandler(ctx.t)
pref := preferences.New(ctx.cfg)
port := pref.GetInt(preferences.IMAPPortKey)
tls, _ := config.GetTLSConfig(ctx.cfg)
backend := imap.NewIMAPBackend(ph, ctx.listener, ctx.cfg, ctx.bridge)
server := imap.NewIMAPServer(true, true, port, tls, backend, ctx.listener)
go server.ListenAndServe()
require.NoError(ctx.t, waitForPort(port, 5*time.Second))
ctx.imapServer = server
ctx.imapAddr = fmt.Sprintf("%v:%v", bridge.Host, port)
ctx.addCleanup(ctx.imapServer.Close, "Closing IMAP server")
}
// SetIMAPLastResponse sets the last IMAP response that was received.
func (ctx *TestContext) SetIMAPLastResponse(handle string, resp *mocks.IMAPResponse) {
ctx.imapLastResponses[handle] = resp
}
// GetIMAPLastResponse returns the last IMAP response that was received.
func (ctx *TestContext) GetIMAPLastResponse(handle string) *mocks.IMAPResponse {
return ctx.imapLastResponses[handle]
}

View File

@ -0,0 +1,84 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"os"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/ProtonMail/proton-bridge/test/fakeapi"
"github.com/ProtonMail/proton-bridge/test/liveapi"
)
type PMAPIController interface {
GetClient(userID string) bridge.PMAPIProvider
TurnInternetConnectionOff()
TurnInternetConnectionOn()
AddUser(user *pmapi.User, addresses *pmapi.AddressList, password string, twoFAEnabled bool) error
AddUserLabel(username string, label *pmapi.Label) error
GetLabelIDs(username string, labelNames []string) ([]string, error)
AddUserMessage(username string, message *pmapi.Message) error
GetMessageID(username, messageIndex string) string
PrintCalls()
WasCalled(method, path string, expectedRequest []byte) bool
GetCalls(method, path string) [][]byte
}
func newPMAPIController() PMAPIController {
switch os.Getenv(EnvName) {
case EnvFake:
return newFakePMAPIController()
case EnvLive:
return newLivePMAPIController()
default:
panic("unknown env")
}
}
func newFakePMAPIController() PMAPIController {
return newFakePMAPIControllerWrap(fakeapi.NewController())
}
type fakePMAPIControllerWrap struct {
*fakeapi.Controller
}
func newFakePMAPIControllerWrap(controller *fakeapi.Controller) PMAPIController {
return &fakePMAPIControllerWrap{Controller: controller}
}
func (s *fakePMAPIControllerWrap) GetClient(userID string) bridge.PMAPIProvider {
return s.Controller.GetClient(userID)
}
func newLivePMAPIController() PMAPIController {
return newLiveAPIControllerWrap(liveapi.NewController())
}
type liveAPIControllerWrap struct {
*liveapi.Controller
}
func newLiveAPIControllerWrap(controller *liveapi.Controller) PMAPIController {
return &liveAPIControllerWrap{Controller: controller}
}
func (s *liveAPIControllerWrap) GetClient(userID string) bridge.PMAPIProvider {
return s.Controller.GetClient(userID)
}

81
test/context/smtp.go Normal file
View File

@ -0,0 +1,81 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"fmt"
"time"
"github.com/ProtonMail/proton-bridge/internal/bridge"
"github.com/ProtonMail/proton-bridge/internal/preferences"
"github.com/ProtonMail/proton-bridge/internal/smtp"
"github.com/ProtonMail/proton-bridge/pkg/config"
"github.com/ProtonMail/proton-bridge/test/mocks"
"github.com/stretchr/testify/require"
)
// GetSMTPClient gets the smtp client by name; if it doesn't exist yet it creates it.
func (ctx *TestContext) GetSMTPClient(handle string) *mocks.SMTPClient {
if client, ok := ctx.smtpClients[handle]; ok {
return client
}
return ctx.newSMTPClient(handle)
}
func (ctx *TestContext) newSMTPClient(handle string) *mocks.SMTPClient {
ctx.withSMTPServer()
client := mocks.NewSMTPClient(ctx.t, handle, ctx.smtpAddr)
ctx.smtpClients[handle] = client
ctx.addCleanup(client.Close, "Closing SMTP client")
return client
}
// withSMTPServer starts an smtp server and connects it to the bridge instance.
// Every TestContext has this by default and thus this doesn't need to be exported.
func (ctx *TestContext) withSMTPServer() {
if ctx.smtpServer != nil {
return
}
ph := newPanicHandler(ctx.t)
pref := preferences.New(ctx.cfg)
tls, _ := config.GetTLSConfig(ctx.cfg)
port := pref.GetInt(preferences.SMTPPortKey)
useSSL := pref.GetBool(preferences.SMTPSSLKey)
backend := smtp.NewSMTPBackend(ph, ctx.listener, pref, ctx.bridge)
server := smtp.NewSMTPServer(true, port, useSSL, tls, backend, ctx.listener)
go server.ListenAndServe()
require.NoError(ctx.t, waitForPort(port, 5*time.Second))
ctx.smtpServer = server
ctx.smtpAddr = fmt.Sprintf("%v:%v", bridge.Host, port)
ctx.addCleanup(ctx.smtpServer.Close, "Closing SMTP server")
}
// SetSMTPLastResponse sets the last SMTP response that was received.
func (ctx *TestContext) SetSMTPLastResponse(handle string, resp *mocks.SMTPResponse) {
ctx.smtpLastResponses[handle] = resp
}
// GetSMTPLastResponse returns the last IMAP response that was received.
func (ctx *TestContext) GetSMTPLastResponse(handle string) *mocks.SMTPResponse {
return ctx.smtpLastResponses[handle]
}

87
test/context/utils.go Normal file
View File

@ -0,0 +1,87 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package context
import (
"fmt"
"net"
"os"
"strconv"
"strings"
"time"
"github.com/sirupsen/logrus"
)
func waitForPort(port int, timeout time.Duration) error {
return waitUntilTrue(timeout, func() bool {
conn, err := net.DialTimeout("tcp", "127.0.0.1:"+strconv.Itoa(port), timeout)
if err != nil {
return false
}
if conn != nil {
if err := conn.Close(); err != nil {
return false
}
}
return true
})
}
// waitUntilTrue can use Eventually from
// https://godoc.org/github.com/stretchr/testify/require#Assertions.Eventually
func waitUntilTrue(timeout time.Duration, callback func() bool) error {
endTime := time.Now().Add(timeout)
for {
if time.Now().After(endTime) {
return fmt.Errorf("Timeout")
}
if callback() {
return nil
}
time.Sleep(50 * time.Millisecond)
}
}
func setLogrusVerbosityFromEnv() {
verbosityName := os.Getenv("VERBOSITY")
switch strings.ToLower(verbosityName) {
case "panic":
logrus.SetLevel(logrus.PanicLevel)
case "fatal":
logrus.SetLevel(logrus.FatalLevel)
case "error":
logrus.SetLevel(logrus.ErrorLevel)
case "warning", "warn":
logrus.SetLevel(logrus.WarnLevel)
case "info":
logrus.SetLevel(logrus.InfoLevel)
case "debug":
logrus.SetLevel(logrus.DebugLevel)
case "trace":
logrus.SetLevel(logrus.TraceLevel)
default:
logrus.SetLevel(logrus.FatalLevel)
}
logrus.SetFormatter(&logrus.TextFormatter{
ForceColors: true,
FullTimestamp: true,
TimestampFormat: time.StampMilli,
})
}

View File

@ -0,0 +1,47 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"encoding/base64"
"io"
"io/ioutil"
"strings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) GetAttachment(attachmentID string) (io.ReadCloser, error) {
if err := api.checkAndRecordCall(GET, "/attachments/"+attachmentID, nil); err != nil {
return nil, err
}
data := strings.NewReader("data")
return ioutil.NopCloser(data), nil
}
func (api *FakePMAPI) CreateAttachment(attachment *pmapi.Attachment, data io.Reader, signature io.Reader) (*pmapi.Attachment, error) {
if err := api.checkAndRecordCall(POST, "/attachments", nil); err != nil {
return nil, err
}
bytes, err := ioutil.ReadAll(data)
if err != nil {
return nil, err
}
attachment.KeyPackets = base64.StdEncoding.EncodeToString(bytes)
return attachment, nil
}

153
test/fakeapi/auth.go Normal file
View File

@ -0,0 +1,153 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"strings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) SetAuths(auths chan<- *pmapi.Auth) {
api.auths = auths
}
func (api *FakePMAPI) AuthInfo(username string) (*pmapi.AuthInfo, error) {
if err := api.checkInternetAndRecordCall(POST, "/auth/info", &pmapi.AuthInfoReq{
Username: username,
}); err != nil {
return nil, err
}
authInfo := &pmapi.AuthInfo{}
user, ok := api.controller.usersByUsername[username]
if !ok {
// If username is wrong, API server will return empty but
// positive response
return authInfo, nil
}
authInfo.TwoFA = user.get2FAInfo()
return authInfo, nil
}
func (api *FakePMAPI) Auth(username, password string, authInfo *pmapi.AuthInfo) (*pmapi.Auth, error) {
if err := api.checkInternetAndRecordCall(POST, "/auth", &pmapi.AuthReq{
Username: username,
}); err != nil {
return nil, err
}
session, err := api.controller.createSessionIfAuthorized(username, password)
if err != nil {
return nil, err
}
api.setUID(session.uid)
if err := api.setUser(username); err != nil {
return nil, err
}
user := api.controller.usersByUsername[username]
auth := &pmapi.Auth{
TwoFA: user.get2FAInfo(),
RefreshToken: session.refreshToken,
}
api.sendAuth(auth)
return auth, nil
}
func (api *FakePMAPI) Auth2FA(twoFactorCode string, auth *pmapi.Auth) (*pmapi.Auth2FA, error) {
if err := api.checkInternetAndRecordCall(POST, "/auth/2fa", &pmapi.Auth2FAReq{
TwoFactorCode: twoFactorCode,
}); err != nil {
return nil, err
}
if api.uid == "" {
return nil, pmapi.ErrInvalidToken
}
session, ok := api.controller.sessionsByUID[api.uid]
if !ok {
return nil, pmapi.ErrInvalidToken
}
session.hasFullScope = true
return &pmapi.Auth2FA{
Scope: "full",
}, nil
}
func (api *FakePMAPI) AuthRefresh(token string) (*pmapi.Auth, error) {
if api.lastToken == "" {
api.lastToken = token
}
split := strings.Split(token, ":")
if len(split) != 2 {
return nil, pmapi.ErrInvalidToken
}
if err := api.checkInternetAndRecordCall(POST, "/auth/refresh", &pmapi.AuthRefreshReq{
ResponseType: "token",
GrantType: "refresh_token",
UID: split[0],
RefreshToken: split[1],
RedirectURI: "https://protonmail.ch",
State: "random_string",
}); err != nil {
return nil, err
}
session, ok := api.controller.sessionsByUID[split[0]]
if !ok || session.refreshToken != split[1] {
api.log.WithField("token", token).
WithField("session", session).
Warn("Refresh token failed")
// The API server will respond normal error not 401 (check api)
// i.e. should not use `sendAuth(nil)`
api.setUID("")
return nil, pmapi.ErrInvalidToken
}
api.setUID(split[0])
if err := api.setUser(session.username); err != nil {
return nil, err
}
api.controller.refreshTheTokensForSession(session)
api.lastToken = split[0] + ":" + session.refreshToken
auth := &pmapi.Auth{
RefreshToken: session.refreshToken,
}
api.sendAuth(auth)
return auth, nil
}
func (api *FakePMAPI) Logout() error {
if err := api.checkAndRecordCall(DELETE, "/auth", nil); err != nil {
return err
}
// Logout will also emit change to auth channel
api.sendAuth(nil)
api.controller.deleteSession(api.uid)
api.unsetUser()
return nil
}

50
test/fakeapi/contacts.go Normal file
View File

@ -0,0 +1,50 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"fmt"
"net/url"
"strconv"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) DecryptAndVerifyCards(cards []pmapi.Card) ([]pmapi.Card, error) {
return cards, nil
}
func (api *FakePMAPI) GetContactEmailByEmail(email string, page int, pageSize int) ([]pmapi.ContactEmail, error) {
v := url.Values{}
v.Set("Page", strconv.Itoa(page))
if pageSize > 0 {
v.Set("PageSize", strconv.Itoa(pageSize))
}
v.Set("Email", email)
if err := api.checkAndRecordCall(GET, "/contacts/emails?"+v.Encode(), nil); err != nil {
return nil, err
}
return []pmapi.ContactEmail{}, nil
}
func (api *FakePMAPI) GetContactByID(contactID string) (pmapi.Contact, error) {
if err := api.checkAndRecordCall(GET, "/contacts/"+contactID, nil); err != nil {
return pmapi.Contact{}, err
}
return pmapi.Contact{}, fmt.Errorf("contact %s does not exist", contactID)
}

View File

@ -0,0 +1,71 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"sync"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/sirupsen/logrus"
)
type Controller struct {
// Internal states.
lock *sync.RWMutex
fakeAPIs []*FakePMAPI
calls []*fakeCall
labelIDGenerator idGenerator
messageIDGenerator idGenerator
tokenGenerator idGenerator
// State controlled by test.
noInternetConnection bool
usersByUsername map[string]*fakeUser
sessionsByUID map[string]*fakeSession
addressesByUsername map[string]*pmapi.AddressList
labelsByUsername map[string][]*pmapi.Label
messagesByUsername map[string][]*pmapi.Message
log *logrus.Entry
}
func NewController() *Controller {
return &Controller{
lock: &sync.RWMutex{},
fakeAPIs: []*FakePMAPI{},
calls: []*fakeCall{},
labelIDGenerator: 100, // We cannot use system label IDs.
messageIDGenerator: 0,
tokenGenerator: 1000, // No specific reason; 1000 simply feels right.
noInternetConnection: false,
usersByUsername: map[string]*fakeUser{},
sessionsByUID: map[string]*fakeSession{},
addressesByUsername: map[string]*pmapi.AddressList{},
labelsByUsername: map[string][]*pmapi.Label{},
messagesByUsername: map[string][]*pmapi.Message{},
log: logrus.WithField("pkg", "fakeapi-controller"),
}
}
func (cntrl *Controller) GetClient(userID string) *FakePMAPI {
fakeAPI := New(cntrl)
cntrl.fakeAPIs = append(cntrl.fakeAPIs, fakeAPI)
return fakeAPI
}

View File

@ -0,0 +1,93 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"encoding/json"
"fmt"
"github.com/nsf/jsondiff"
)
type method string
const (
GET method = "GET"
POST method = "POST"
PUT method = "PUT"
DELETE method = "DELETE"
)
type fakeCall struct {
method method
path string
request []byte
}
func (cntrl *Controller) recordCall(method method, path string, req interface{}) {
cntrl.lock.Lock()
defer cntrl.lock.Unlock()
request := []byte{}
if req != nil {
var err error
request, err = json.Marshal(req)
if err != nil {
panic(err)
}
}
cntrl.calls = append(cntrl.calls, &fakeCall{
method: method,
path: path,
request: request,
})
}
func (cntrl *Controller) PrintCalls() {
fmt.Println("API calls:")
for idx, call := range cntrl.calls {
fmt.Printf("%02d: [%s] %s\n", idx+1, call.method, call.path)
if call.request != nil && string(call.request) != "null" {
fmt.Printf("\t%s\n", call.request)
}
}
}
func (cntrl *Controller) WasCalled(method, path string, expectedRequest []byte) bool {
for _, call := range cntrl.calls {
if string(call.method) != method && call.path != path {
continue
}
diff, _ := jsondiff.Compare(call.request, expectedRequest, &jsondiff.Options{})
isSuperset := diff == jsondiff.FullMatch || diff == jsondiff.SupersetMatch
if isSuperset {
return true
}
}
return false
}
func (cntrl *Controller) GetCalls(method, path string) [][]byte {
requests := [][]byte{}
for _, call := range cntrl.calls {
if string(call.method) == method && call.path == path {
requests = append(requests, call.request)
}
}
return requests
}

View File

@ -0,0 +1,142 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"fmt"
"strings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
var systemLabelNameToID = map[string]string{ //nolint[gochecknoglobals]
"INBOX": pmapi.InboxLabel,
"Trash": pmapi.TrashLabel,
"Spam": pmapi.SpamLabel,
"All Mail": pmapi.AllMailLabel,
"Archive": pmapi.ArchiveLabel,
"Sent": pmapi.SentLabel,
"Drafts": pmapi.DraftLabel,
}
func (cntrl *Controller) TurnInternetConnectionOff() {
cntrl.log.Warn("Turning OFF internet")
cntrl.noInternetConnection = true
}
func (cntrl *Controller) TurnInternetConnectionOn() {
cntrl.log.Warn("Turning ON internet")
cntrl.noInternetConnection = false
}
func (cntrl *Controller) AddUser(user *pmapi.User, addresses *pmapi.AddressList, password string, twoFAEnabled bool) error {
cntrl.usersByUsername[user.Name] = &fakeUser{
user: user,
password: password,
has2FA: twoFAEnabled,
}
cntrl.addressesByUsername[user.Name] = addresses
return nil
}
func (cntrl *Controller) AddUserLabel(username string, label *pmapi.Label) error {
if _, ok := cntrl.labelsByUsername[username]; !ok {
cntrl.labelsByUsername[username] = []*pmapi.Label{}
}
labelName := getLabelNameWithoutPrefix(label.Name)
for _, existingLabel := range cntrl.labelsByUsername[username] {
if existingLabel.Name == labelName {
return fmt.Errorf("folder or label %s already exists", label.Name)
}
}
label.Exclusive = getLabelExclusive(label.Name)
prefix := "label"
if label.Exclusive == 1 {
prefix = "folder"
}
label.ID = cntrl.labelIDGenerator.next(prefix)
label.Name = labelName
cntrl.labelsByUsername[username] = append(cntrl.labelsByUsername[username], label)
cntrl.resetUsers()
return nil
}
func (cntrl *Controller) GetLabelIDs(username string, labelNames []string) ([]string, error) {
labelIDs := []string{}
for _, labelName := range labelNames {
labelID, err := cntrl.getLabelID(username, labelName)
if err != nil {
return nil, err
}
labelIDs = append(labelIDs, labelID)
}
return labelIDs, nil
}
func (cntrl *Controller) getLabelID(username, labelName string) (string, error) {
if labelID, ok := systemLabelNameToID[labelName]; ok {
return labelID, nil
}
labelName = getLabelNameWithoutPrefix(labelName)
for _, label := range cntrl.labelsByUsername[username] {
if label.Name == labelName {
return label.ID, nil
}
}
return "", fmt.Errorf("label %s:%s does not exist", username, labelName)
}
func getLabelNameWithoutPrefix(name string) string {
if strings.HasPrefix(name, "Folders/") {
return strings.TrimPrefix(name, "Folders/")
}
if strings.HasPrefix(name, "Labels/") {
return strings.TrimPrefix(name, "Labels/")
}
return name
}
func getLabelExclusive(name string) int {
if strings.HasPrefix(name, "Folders/") {
return 1
}
return 0
}
func (cntrl *Controller) AddUserMessage(username string, message *pmapi.Message) error {
if _, ok := cntrl.messagesByUsername[username]; !ok {
cntrl.messagesByUsername[username] = []*pmapi.Message{}
}
message.ID = cntrl.messageIDGenerator.next("")
message.LabelIDs = append(message.LabelIDs, pmapi.AllMailLabel)
cntrl.messagesByUsername[username] = append(cntrl.messagesByUsername[username], message)
cntrl.resetUsers()
return nil
}
func (cntrl *Controller) resetUsers() {
for _, fakeAPI := range cntrl.fakeAPIs {
_ = fakeAPI.setUser(fakeAPI.username)
}
}
func (cntrl *Controller) GetMessageID(username, messageIndex string) string {
return messageIndex
}

View File

@ -0,0 +1,57 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"errors"
)
type fakeSession struct {
username string
uid, refreshToken string
hasFullScope bool
}
var errWrongNameOrPassword = errors.New("Incorrect login credentials. Please try again") //nolint[stylecheck]
func (cntrl *Controller) createSessionIfAuthorized(username, password string) (*fakeSession, error) {
// get user
user, ok := cntrl.usersByUsername[username]
if !ok || user.password != password {
return nil, errWrongNameOrPassword
}
// create session
session := &fakeSession{
username: username,
uid: cntrl.tokenGenerator.next("uid"),
hasFullScope: !user.has2FA,
}
cntrl.refreshTheTokensForSession(session)
cntrl.sessionsByUID[session.uid] = session
return session, nil
}
func (cntrl *Controller) refreshTheTokensForSession(session *fakeSession) {
session.refreshToken = cntrl.tokenGenerator.next("refresh")
}
func (cntrl *Controller) deleteSession(uid string) {
delete(cntrl.sessionsByUID, uid)
}

View File

@ -0,0 +1,37 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import "github.com/ProtonMail/proton-bridge/pkg/pmapi"
type fakeUser struct {
user *pmapi.User
password string
has2FA bool
}
func (fu *fakeUser) get2FAInfo() *pmapi.TwoFactorInfo {
twoFAEnabled := 0
if fu.has2FA {
twoFAEnabled = 1
}
return &pmapi.TwoFactorInfo{
Enabled: twoFAEnabled,
TOTP: 0,
}
}

60
test/fakeapi/counts.go Normal file
View File

@ -0,0 +1,60 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import "github.com/ProtonMail/proton-bridge/pkg/pmapi"
func (api *FakePMAPI) CountMessages(addressID string) ([]*pmapi.MessagesCount, error) {
if err := api.checkAndRecordCall(GET, "/messages/count?AddressID="+addressID, nil); err != nil {
return nil, err
}
return api.getCounts(addressID), nil
}
func (api *FakePMAPI) getAllCounts() []*pmapi.MessagesCount {
return api.getCounts("")
}
func (api *FakePMAPI) getCounts(addressID string) []*pmapi.MessagesCount {
allCounts := map[string]*pmapi.MessagesCount{}
for _, message := range api.messages {
if addressID != "" && message.AddressID != addressID {
continue
}
for _, labelID := range message.LabelIDs {
if counts, ok := allCounts[labelID]; ok {
counts.Total++
if message.Unread == 1 {
counts.Unread++
}
} else {
allCounts[labelID] = &pmapi.MessagesCount{
LabelID: labelID,
Total: 1,
Unread: message.Unread,
}
}
}
}
res := []*pmapi.MessagesCount{}
for _, counts := range allCounts {
res = append(res, counts)
}
return res
}

105
test/fakeapi/events.go Normal file
View File

@ -0,0 +1,105 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) GetEvent(eventID string) (*pmapi.Event, error) {
if err := api.checkAndRecordCall(GET, "/events/"+eventID, nil); err != nil {
return nil, err
}
// Request for empty ID returns the latest event.
if eventID == "" {
return api.events[len(api.events)-1], nil
}
// Otherwise it tries to find specific ID and return all next events merged into one.
var foundEvent *pmapi.Event
mergedEvent := &pmapi.Event{}
for _, event := range api.events {
if event.EventID == eventID {
foundEvent = event
continue
}
if foundEvent != nil {
mergedEvent.EventID = event.EventID
mergedEvent.Refresh |= event.Refresh
mergedEvent.Messages = append(mergedEvent.Messages, event.Messages...)
mergedEvent.MessageCounts = append(mergedEvent.MessageCounts, event.MessageCounts...)
mergedEvent.Labels = append(mergedEvent.Labels, event.Labels...)
mergedEvent.Notices = append(mergedEvent.Notices, event.Notices...)
mergedEvent.User = event.User
}
}
// If there isn't next event, return the same one.
if mergedEvent.EventID == "" {
return foundEvent, nil
}
return mergedEvent, nil
}
func (api *FakePMAPI) addEventLabel(action pmapi.EventAction, label *pmapi.Label) {
api.addEvent(&pmapi.Event{
EventID: api.eventIDGenerator.next("event"),
Labels: []*pmapi.EventLabel{{
EventItem: pmapi.EventItem{
ID: label.ID,
Action: action,
},
Label: label,
}},
})
}
func (api *FakePMAPI) addEventMessage(action pmapi.EventAction, message *pmapi.Message) {
created := message
updated := &pmapi.EventMessageUpdated{
ID: message.ID,
Subject: &message.Subject,
Unread: &message.Unread,
Flags: &message.Flags,
Sender: message.Sender,
ToList: &message.ToList,
CCList: &message.CCList,
BCCList: &message.BCCList,
Time: message.Time,
LabelIDs: message.LabelIDs,
}
if action == pmapi.EventCreate {
updated = nil
} else {
created = nil
}
api.addEvent(&pmapi.Event{
EventID: api.eventIDGenerator.next("event"),
Messages: []*pmapi.EventMessage{{
EventItem: pmapi.EventItem{
ID: message.ID,
Action: action,
},
Created: created,
Updated: updated,
}},
MessageCounts: api.getAllCounts(),
})
}
func (api *FakePMAPI) addEvent(event *pmapi.Event) {
api.events = append(api.events, event)
}

158
test/fakeapi/fakeapi.go Normal file
View File

@ -0,0 +1,158 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"errors"
"fmt"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/sirupsen/logrus"
)
var errBadRequest = errors.New("NOT OK: 400 Bad Request")
type FakePMAPI struct {
username string
controller *Controller
eventIDGenerator idGenerator
auths chan<- *pmapi.Auth
user *pmapi.User
addresses *pmapi.AddressList
labels []*pmapi.Label
messages []*pmapi.Message
events []*pmapi.Event
// uid represents the API UID. It is the unique session ID.
uid, lastToken string
log *logrus.Entry
}
func New(controller *Controller) *FakePMAPI {
fakePMAPI := &FakePMAPI{
controller: controller,
log: logrus.WithField("pkg", "fakeapi"),
}
fakePMAPI.addEvent(&pmapi.Event{
EventID: fakePMAPI.eventIDGenerator.last("event"),
Refresh: 0,
More: 0,
})
return fakePMAPI
}
func (api *FakePMAPI) checkAndRecordCall(method method, path string, request interface{}) error {
if err := api.checkInternetAndRecordCall(method, path, request); err != nil {
return err
}
// Try re-auth
if api.uid == "" && api.lastToken != "" {
api.log.WithField("lastToken", api.lastToken).Warn("Handling unauthorized status")
if _, err := api.AuthRefresh(api.lastToken); err != nil {
return err
}
}
// Check client is authenticated. There is difference between
// * invalid token
// * and missing token
// but API treats it the same
if api.uid == "" {
return pmapi.ErrInvalidToken
}
// Any route (except Auth and AuthRefresh) can end with wrong
// token and it should be translated into logout
session, ok := api.controller.sessionsByUID[api.uid]
if !ok {
api.setUID("") // all consecutive requests will not send auth nil
api.sendAuth(nil)
return pmapi.ErrInvalidToken
} else if !session.hasFullScope {
// This is exact error string from the server (at least from documentation).
return errors.New("Access token does not have sufficient scope") //nolint[stylecheck]
}
return nil
}
func (api *FakePMAPI) checkInternetAndRecordCall(method method, path string, request interface{}) error {
api.log.WithField(string(method), path).Trace("CALL")
api.controller.recordCall(method, path, request)
if api.controller.noInternetConnection {
return pmapi.ErrAPINotReachable
}
return nil
}
func (api *FakePMAPI) sendAuth(auth *pmapi.Auth) {
if auth != nil {
auth.DANGEROUSLYSetUID(api.uid)
}
if api.auths != nil {
api.auths <- auth
}
}
func (api *FakePMAPI) setUser(username string) error {
api.username = username
api.log = api.log.WithField("username", username)
user, ok := api.controller.usersByUsername[username]
if !ok {
return fmt.Errorf("user %s does not exist", username)
}
api.user = user.user
addresses, ok := api.controller.addressesByUsername[username]
if !ok {
addresses = &pmapi.AddressList{}
}
api.addresses = addresses
labels, ok := api.controller.labelsByUsername[username]
if !ok {
labels = []*pmapi.Label{}
}
api.labels = labels
messages, ok := api.controller.messagesByUsername[username]
if !ok {
messages = []*pmapi.Message{}
}
api.messages = messages
return nil
}
func (api *FakePMAPI) setUID(uid string) {
api.uid = uid
api.log = api.log.WithField("uid", api.uid)
api.log.Info("UID updated")
}
func (api *FakePMAPI) unsetUser() {
api.setUID("")
api.user = nil
api.labels = nil
api.messages = nil
api.events = nil
}

View File

@ -0,0 +1,31 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import "fmt"
type idGenerator int
func (g *idGenerator) last(prefix string) string {
return fmt.Sprintf("%s%d", prefix, *g)
}
func (g *idGenerator) next(prefix string) string {
(*g)++
return fmt.Sprintf("%s%d", prefix, *g)
}

65
test/fakeapi/keys.go Normal file
View File

@ -0,0 +1,65 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import "github.com/ProtonMail/proton-bridge/pkg/pmapi"
// publicKey is used from pmapi unit tests.
// For now we need just some key, no need to have some specific one.
const publicKey = `-----BEGIN PGP PUBLIC KEY BLOCK-----
Version: OpenPGP.js v0.7.1
Comment: http://openpgpjs.org
xsBNBFRJbc0BCAC0mMLZPDBbtSCWvxwmOfXfJkE2+ssM3ux21LhD/bPiWefE
WSHlCjJ8PqPHy7snSiUuxuj3f9AvXPvg+mjGLBwu1/QsnSP24sl3qD2onl39
vPiLJXUqZs20ZRgnvX70gjkgEzMFBxINiy2MTIG+4RU8QA7y8KzWev0btqKi
MeVa+GLEHhgZ2KPOn4Jv1q4bI9hV0C9NUe2tTXS6/Vv3vbCY7lRR0kbJ65T5
c8CmpqJuASIJNrSXM/Q3NnnsY4kBYH0s5d2FgbASQvzrjuC2rngUg0EoPsrb
DEVRA2/BCJonw7aASiNCrSP92lkZdtYlax/pcoE/mQ4WSwySFmcFT7yFABEB
AAHNBlVzZXJJRMLAcgQQAQgAJgUCVEltzwYLCQgHAwIJED62JZ7fId8kBBUI
AgoDFgIBAhsDAh4BAAD0nQf9EtH9TC0JqSs8q194Zo244jjlJFM3EzxOSULq
0zbywlLORfyoo/O8jU/HIuGz+LT98JDtnltTqfjWgu6pS3ZL2/L4AGUKEoB7
OI6oIdRwzMc61sqI+Qpbzxo7rzufH4CiXZc6cxORUgL550xSCcqnq0q1mds7
h5roKDzxMW6WLiEsc1dN8IQKzC7Ec5wA7U4oNGsJ3TyI8jkIs0IhXrRCd26K
0TW8Xp6GCsfblWXosR13y89WVNgC+xrrJKTZEisc0tRlneIgjcwEUvwfIg2n
9cDUFA/5BsfzTW5IurxqDEziIVP0L44PXjtJrBQaGMPlEbtP5i2oi3OADVX2
XbvsRc7ATQRUSW3PAQgAkPnu5fps5zhOB/e618v/iF3KiogxUeRhA68TbvA+
xnFfTxCx2Vo14aOL0CnaJ8gO5yRSqfomL2O1kMq07N1MGbqucbmc+aSfoElc
+Gd5xBE/w3RcEhKcAaYTi35vG22zlZup4x3ElioyIarOssFEkQgNNyDf5AXZ
jdHLA6qVxeqAb/Ff74+y9HUmLPSsRU9NwFzvK3Jv8C/ubHVLzTYdFgYkc4W1
Uug9Ou08K+/4NEMrwnPFBbZdJAuUjQz2zW2ZiEKiBggiorH2o5N3mYUnWEmU
vqL3EOS8TbWo8UBIW3DDm2JiZR8VrEgvBtc9mVDUj/x+5pR07Fy1D6DjRmAc
9wARAQABwsBfBBgBCAATBQJUSW3SCRA+tiWe3yHfJAIbDAAA/iwH/ik9RKZM
B9Ir0x5mGpKPuqhugwrc3d04m1sOdXJm2NtD4ddzSEvzHwaPNvEvUl5v7FVM
zf6+6mYGWHyNP4+e7RtwYLlRpud6smuGyDSsotUYyumiqP6680ZIeWVQ+a1T
ThNs878mAJy1FhvQFdTmA8XIC616hDFpamQKPlpoO1a0wZnQhrPwT77HDYEE
a+hqY4Jr/a7ui40S+7xYRHKL/7ZAS4/grWllhU3dbNrwSzrOKwrA/U0/9t73
8Ap6JL71YymDeaL4sutcoaahda1pTrMWePtrCltz6uySwbZs7GXoEzjX3EAH
+6qhkUJtzMaE3YEFEoQMGzcDTUEfXCJ3zJw=
=yT9U
-----END PGP PUBLIC KEY BLOCK-----
`
func (api *FakePMAPI) GetPublicKeysForEmail(email string) (keys []pmapi.PublicKey, internal bool, err error) {
if err := api.checkAndRecordCall(GET, "/keys?Email="+email, nil); err != nil {
return nil, false, err
}
return []pmapi.PublicKey{{
PublicKey: publicKey,
}}, true, nil
}

90
test/fakeapi/labels.go Normal file
View File

@ -0,0 +1,90 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"fmt"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) isLabelFolder(labelID string) bool {
for _, label := range api.labels {
if label.ID == labelID {
return label.Exclusive == 1
}
}
return labelID == pmapi.InboxLabel || labelID == pmapi.ArchiveLabel || labelID == pmapi.SentLabel
}
func (api *FakePMAPI) ListLabels() ([]*pmapi.Label, error) {
if err := api.checkAndRecordCall(GET, "/labels/1", nil); err != nil {
return nil, err
}
return api.labels, nil
}
func (api *FakePMAPI) CreateLabel(label *pmapi.Label) (*pmapi.Label, error) {
if err := api.checkAndRecordCall(POST, "/labels", &pmapi.LabelReq{Label: label}); err != nil {
return nil, err
}
for _, existingLabel := range api.labels {
if existingLabel.Name == label.Name {
return nil, fmt.Errorf("folder or label %s already exists", label.Name)
}
}
prefix := "label"
if label.Exclusive == 1 {
prefix = "folder"
}
label.ID = api.controller.labelIDGenerator.next(prefix)
api.labels = append(api.labels, label)
api.addEventLabel(pmapi.EventCreate, label)
return label, nil
}
func (api *FakePMAPI) UpdateLabel(label *pmapi.Label) (*pmapi.Label, error) {
if err := api.checkAndRecordCall(PUT, "/labels", &pmapi.LabelReq{Label: label}); err != nil {
return nil, err
}
for idx, existingLabel := range api.labels {
if existingLabel.ID == label.ID {
// Request doesn't have to include all properties and these have to stay the same.
label.Type = existingLabel.Type
label.Exclusive = existingLabel.Exclusive
api.labels[idx] = label
api.addEventLabel(pmapi.EventUpdate, label)
return label, nil
}
}
return nil, fmt.Errorf("label %s does not exist", label.ID)
}
func (api *FakePMAPI) DeleteLabel(labelID string) error {
if err := api.checkAndRecordCall(DELETE, "/labels/"+labelID, nil); err != nil {
return err
}
for idx, existingLabel := range api.labels {
if existingLabel.ID == labelID {
api.labels = append(api.labels[:idx], api.labels[idx+1:]...)
api.addEventLabel(pmapi.EventDelete, existingLabel)
return nil
}
}
return fmt.Errorf("label %s does not exist", labelID)
}

374
test/fakeapi/messages.go Normal file
View File

@ -0,0 +1,374 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"bytes"
"fmt"
"net/mail"
"time"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
var errWasNotUpdated = errors.New("message was not updated")
func (api *FakePMAPI) GetMessage(apiID string) (*pmapi.Message, error) {
if err := api.checkAndRecordCall(GET, "/messages/"+apiID, nil); err != nil {
return nil, err
}
for _, message := range api.messages {
if message.ID == apiID {
return message, nil
}
}
return nil, fmt.Errorf("message %s not found", apiID)
}
// ListMessages does not implement following filters:
// * Sort (it sorts by ID only), but Desc works
// * Keyword
// * To
// * Subject
// * ID
// * Attachments
// * AutoWildcard
func (api *FakePMAPI) ListMessages(filter *pmapi.MessagesFilter) ([]*pmapi.Message, int, error) {
if err := api.checkAndRecordCall(GET, "/messages", filter); err != nil {
return nil, 0, err
}
pageSize := filter.PageSize
if pageSize > 150 {
pageSize = 150
}
messages := []*pmapi.Message{}
messageCount := 0
skipByIDBegin := true
skipByIDEnd := false
skipByPaging := pageSize * filter.Page
for idx := 0; idx < len(api.messages); idx++ {
var message *pmapi.Message
if !*filter.Desc {
message = api.messages[idx]
if filter.BeginID == "" || message.ID == filter.BeginID {
skipByIDBegin = false
}
} else {
message = api.messages[len(api.messages)-1-idx]
if filter.EndID == "" || message.ID == filter.EndID {
skipByIDBegin = false
}
}
if skipByIDBegin || skipByIDEnd {
continue
}
if !*filter.Desc {
if message.ID == filter.EndID {
skipByIDEnd = true
}
} else {
if message.ID == filter.BeginID {
skipByIDEnd = true
}
}
if !isMessageMatchingFilter(filter, message) {
continue
}
messageCount++
if skipByPaging > 0 {
skipByPaging--
continue
}
if len(messages) == pageSize || (filter.Limit != 0 && len(messages) == filter.Limit) {
continue
}
messages = append(messages, copyFilteredMessage(message))
}
return messages, messageCount, nil
}
func isMessageMatchingFilter(filter *pmapi.MessagesFilter, message *pmapi.Message) bool {
if filter.ExternalID != "" && filter.ExternalID != message.ExternalID {
return false
}
if filter.ConversationID != "" && filter.ConversationID != message.ConversationID {
return false
}
if filter.AddressID != "" && filter.AddressID != message.AddressID {
return false
}
if filter.From != "" && filter.From != message.Sender.Address {
return false
}
if filter.LabelID != "" && !hasItem(message.LabelIDs, filter.LabelID) {
return false
}
if filter.Begin != 0 && filter.Begin > message.Time {
return false
}
if filter.End != 0 && filter.End < message.Time {
return false
}
if filter.Unread != nil {
wantUnread := 0
if *filter.Unread {
wantUnread = 1
}
if message.Unread != wantUnread {
return false
}
}
return true
}
func copyFilteredMessage(message *pmapi.Message) *pmapi.Message {
filteredMessage := &pmapi.Message{}
*filteredMessage = *message
filteredMessage.Body = ""
filteredMessage.Header = nil
return filteredMessage
}
func (api *FakePMAPI) CreateDraft(message *pmapi.Message, parentID string, action int) (*pmapi.Message, error) {
if err := api.checkAndRecordCall(POST, "/messages", &pmapi.DraftReq{
Message: message,
ParentID: parentID,
Action: action,
AttachmentKeyPackets: []string{},
}); err != nil {
return nil, err
}
if parentID != "" {
if _, err := api.GetMessage(parentID); err != nil {
return nil, err
}
}
if message.Subject == "" {
message.Subject = "(No Subject)"
}
message.LabelIDs = append(message.LabelIDs, pmapi.DraftLabel)
message.LabelIDs = append(message.LabelIDs, pmapi.AllMailLabel)
message.ID = api.controller.messageIDGenerator.next("")
api.addMessage(message)
return message, nil
}
func (api *FakePMAPI) SendMessage(messageID string, sendMessageRequest *pmapi.SendMessageReq) (sent, parent *pmapi.Message, err error) {
if err := api.checkAndRecordCall(POST, "/messages/"+messageID, sendMessageRequest); err != nil {
return nil, nil, err
}
message, err := api.GetMessage(messageID)
if err != nil {
return nil, nil, errors.Wrap(err, "draft does not exist")
}
message.Time = time.Now().Unix()
message.LabelIDs = append(message.LabelIDs, pmapi.SentLabel)
api.addEventMessage(pmapi.EventUpdate, message)
return message, nil, nil
}
func (api *FakePMAPI) Import(importMessageRequests []*pmapi.ImportMsgReq) ([]*pmapi.ImportMsgRes, error) {
msgRes := []*pmapi.ImportMsgRes{}
for _, msgReq := range importMessageRequests {
mailMessage, err := mail.ReadMessage(bytes.NewBuffer(msgReq.Body))
if err != nil {
msgRes = append(msgRes, &pmapi.ImportMsgRes{
Error: err,
})
}
messageID := api.controller.messageIDGenerator.next("")
message := &pmapi.Message{
ID: messageID,
AddressID: msgReq.AddressID,
Sender: &mail.Address{Address: mailMessage.Header.Get("From")},
ToList: []*mail.Address{{Address: mailMessage.Header.Get("To")}},
Subject: mailMessage.Header.Get("Subject"),
Unread: msgReq.Unread,
LabelIDs: msgReq.LabelIDs,
Body: string(msgReq.Body),
Flags: msgReq.Flags,
Time: msgReq.Time,
}
msgRes = append(msgRes, &pmapi.ImportMsgRes{
Error: nil,
MessageID: messageID,
})
api.addMessage(message)
}
return msgRes, nil
}
func (api *FakePMAPI) addMessage(message *pmapi.Message) {
api.messages = append(api.messages, message)
api.addEventMessage(pmapi.EventCreate, message)
}
func (api *FakePMAPI) DeleteMessages(apiIDs []string) error {
err := api.deleteMessages(PUT, "/messages/delete", &pmapi.MessagesActionReq{
IDs: apiIDs,
}, func(message *pmapi.Message) bool {
return hasItem(apiIDs, message.ID)
})
if err != nil {
return err
}
if len(apiIDs) == 0 {
return errBadRequest
}
return nil
}
func (api *FakePMAPI) EmptyFolder(labelID string, addressID string) error {
err := api.deleteMessages(DELETE, "/messages/empty?LabelID="+labelID+"&AddressID="+addressID, nil, func(message *pmapi.Message) bool {
return hasItem(message.LabelIDs, labelID) && message.AddressID == addressID
})
if err != nil {
return err
}
if labelID == "" {
return errBadRequest
}
return nil
}
func (api *FakePMAPI) deleteMessages(method method, path string, request interface{}, shouldBeDeleted func(*pmapi.Message) bool) error {
if err := api.checkAndRecordCall(method, path, request); err != nil {
return err
}
newMessages := []*pmapi.Message{}
for _, message := range api.messages {
if shouldBeDeleted(message) {
if hasItem(message.LabelIDs, pmapi.TrashLabel) {
api.addEventMessage(pmapi.EventDelete, message)
continue
}
message.LabelIDs = []string{pmapi.TrashLabel, pmapi.AllMailLabel}
api.addEventMessage(pmapi.EventUpdate, message)
}
newMessages = append(newMessages, message)
}
api.messages = newMessages
return nil
}
func (api *FakePMAPI) LabelMessages(apiIDs []string, labelID string) error {
return api.updateMessages(PUT, "/messages/label", &pmapi.LabelMessagesReq{
IDs: apiIDs,
LabelID: labelID,
}, apiIDs, func(message *pmapi.Message) error {
if labelID == "" {
return errBadRequest
}
if labelID == pmapi.TrashLabel {
message.LabelIDs = []string{pmapi.TrashLabel, pmapi.AllMailLabel}
return nil
}
if api.isLabelFolder(labelID) {
labelIDs := []string{}
for _, existingLabelID := range message.LabelIDs {
if !api.isLabelFolder(existingLabelID) {
labelIDs = append(labelIDs, existingLabelID)
}
}
message.LabelIDs = labelIDs
}
message.LabelIDs = addItem(message.LabelIDs, labelID)
return nil
})
}
func (api *FakePMAPI) UnlabelMessages(apiIDs []string, labelID string) error {
return api.updateMessages(PUT, "/messages/unlabel", &pmapi.LabelMessagesReq{
IDs: apiIDs,
LabelID: labelID,
}, apiIDs, func(message *pmapi.Message) error {
if labelID == "" {
return errBadRequest
}
// All Mail and Sent cannot be unlabeled, but API will not throw error.
if labelID == pmapi.AllMailLabel || labelID == pmapi.SentLabel {
return errWasNotUpdated
}
message.LabelIDs = removeItem(message.LabelIDs, labelID)
return nil
})
}
func (api *FakePMAPI) MarkMessagesRead(apiIDs []string) error {
return api.updateMessages(PUT, "/messages/read", &pmapi.MessagesActionReq{
IDs: apiIDs,
}, apiIDs, func(message *pmapi.Message) error {
if message.Unread == 0 {
return errWasNotUpdated
}
message.Unread = 0
return nil
})
}
func (api *FakePMAPI) MarkMessagesUnread(apiIDs []string) error {
err := api.updateMessages(PUT, "/messages/unread", &pmapi.MessagesActionReq{
IDs: apiIDs,
}, apiIDs, func(message *pmapi.Message) error {
if message.Unread == 1 {
return errWasNotUpdated
}
message.Unread = 1
return nil
})
if err != nil {
return err
}
return nil
}
func (api *FakePMAPI) updateMessages(method method, path string, request interface{}, apiIDs []string, updateCallback func(*pmapi.Message) error) error { //nolint[unparam]
if err := api.checkAndRecordCall(method, path, request); err != nil {
return err
}
// API will return error if you send request for no apiIDs
if len(apiIDs) == 0 {
return errBadRequest
}
for _, message := range api.messages {
if hasItem(apiIDs, message.ID) {
err := updateCallback(message)
if err != nil {
if err == errWasNotUpdated {
continue
} else {
return err
}
}
api.addEventMessage(pmapi.EventUpdate, message)
}
}
return nil
}

44
test/fakeapi/reports.go Normal file
View File

@ -0,0 +1,44 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
"net/url"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) ReportBugWithEmailClient(os, osVersion, title, description, username, email, emailClient string) error {
return api.checkInternetAndRecordCall(POST, "/reports/bug", &pmapi.ReportReq{
OS: os,
OSVersion: osVersion,
Title: title,
Description: description,
Username: username,
Email: email,
Browser: emailClient,
})
}
func (api *FakePMAPI) SendSimpleMetric(category, action, label string) error {
v := url.Values{}
v.Set("Category", category)
v.Set("Action", action)
v.Set("Label", label)
return api.checkInternetAndRecordCall(GET, "/metrics?"+v.Encode(), nil)
}

61
test/fakeapi/user.go Normal file
View File

@ -0,0 +1,61 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
import (
pmcrypto "github.com/ProtonMail/gopenpgp/crypto"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
func (api *FakePMAPI) GetMailSettings() (pmapi.MailSettings, error) {
if err := api.checkAndRecordCall(GET, "/settings/mail", nil); err != nil {
return pmapi.MailSettings{}, err
}
return pmapi.MailSettings{}, nil
}
func (api *FakePMAPI) Unlock(mailboxPassword string) (*pmcrypto.KeyRing, error) {
return &pmcrypto.KeyRing{
FirstKeyID: "key",
}, nil
}
func (api *FakePMAPI) UnlockAddresses(password []byte) error {
return nil
}
func (api *FakePMAPI) CurrentUser() (*pmapi.User, error) {
return api.UpdateUser()
}
func (api *FakePMAPI) UpdateUser() (*pmapi.User, error) {
if err := api.checkAndRecordCall(GET, "/users", nil); err != nil {
return nil, err
}
return api.user, nil
}
func (api *FakePMAPI) Addresses() pmapi.AddressList {
return *api.addresses
}
func (api *FakePMAPI) KeyRingForAddressID(addrID string) *pmcrypto.KeyRing {
return &pmcrypto.KeyRing{
FirstKeyID: "key",
}
}

46
test/fakeapi/utils.go Normal file
View File

@ -0,0 +1,46 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package fakeapi
func hasItem(items []string, value string) bool {
for _, item := range items {
if item == value {
return true
}
}
return false
}
func removeItem(items []string, value string) []string {
newItems := []string{}
for _, item := range items {
if item != value {
newItems = append(newItems, item)
}
}
return newItems
}
func addItem(items []string, value string) []string {
for _, item := range items {
if item == value {
return items
}
}
return append(items, value)
}

View File

@ -0,0 +1,46 @@
Feature: Address mode
Background:
Given there is connected user "userMoreAddresses"
And there is "userMoreAddresses" with mailbox "Folders/mbox"
And there are messages in mailbox "Folders/mbox" for "userMoreAddresses"
| from | to | subject |
| john.doe@mail.com | [primary] | foo |
| jane.doe@mail.com | [secondary] | bar |
Scenario: All messages in one mailbox with combined mode
Given there is "userMoreAddresses" in "combined" address mode
Then mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages
| from | to | subject |
| john.doe@mail.com | [primary] | foo |
| jane.doe@mail.com | [secondary] | bar |
Scenario: Messages separated in more mailboxes with split mode
Given there is "userMoreAddresses" in "split" address mode
Then mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages
| from | to | subject |
| john.doe@mail.com | [primary] | foo |
And mailbox "Folders/mbox" for address "secondary" of "userMoreAddresses" has messages
| from | to | subject |
| jane.doe@mail.com | [secondary] | bar |
Scenario: Switch address mode from combined to split mode
Given there is "userMoreAddresses" in "combined" address mode
When "userMoreAddresses" changes the address mode
Then bridge response is "OK"
And "userMoreAddresses" has address mode in "split" mode
And mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages
| from | to | subject |
| john.doe@mail.com | [primary] | foo |
And mailbox "Folders/mbox" for address "secondary" of "userMoreAddresses" has messages
| from | to | subject |
| jane.doe@mail.com | [secondary] | bar |
Scenario: Switch address mode from split to combined mode
Given there is "userMoreAddresses" in "split" address mode
When "userMoreAddresses" changes the address mode
Then bridge response is "OK"
And "userMoreAddresses" has address mode in "combined" mode
And mailbox "Folders/mbox" for address "primary" of "userMoreAddresses" has messages
| from | to | subject |
| john.doe@mail.com | [primary] | foo |
| jane.doe@mail.com | [secondary] | bar |

View File

@ -0,0 +1,36 @@
Feature: Delete user
Scenario: Deleting connected user
Given there is connected user "user"
When user deletes "user" from bridge
Then bridge response is "OK"
And "user" has database file
Scenario: Deleting connected user with cache
Given there is connected user "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
And "user" does not have database file
Scenario: Deleting connected user without database file
Given there is connected user "user"
And there is no database file for "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
Scenario: Deleting disconnected user
Given there is disconnected user "user"
When user deletes "user" from bridge
Then bridge response is "OK"
And "user" has database file
Scenario: Deleting disconnected user with cache
Given there is disconnected user "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"
And "user" does not have database file
Scenario: Deleting disconnected user without database file
Given there is disconnected user "user"
And there is no database file for "user"
When user deletes "user" from bridge with cache
Then bridge response is "OK"

View File

@ -0,0 +1,66 @@
Feature: Login to bridge for the first time
Scenario: Normal bridge login
Given there is user "user"
When "user" logs in to bridge
Then bridge response is "OK"
And "user" is connected
And "user" has database file
And "user" has running event loop
Scenario: Login with bad username
When "user" logs in to bridge with bad password
Then bridge response is "failed to login: Incorrect login credentials. Please try again"
Scenario: Login with bad password
Given there is user "user"
When "user" logs in to bridge with bad password
Then bridge response is "failed to login: Incorrect login credentials. Please try again"
Scenario: Login without internet connection
Given there is no internet connection
When "user" logs in to bridge
Then bridge response is "failed to login: cannot reach the server"
@ignore-live
Scenario: Login user with 2FA
Given there is user "user2fa"
When "user2fa" logs in to bridge
Then bridge response is "OK"
And "user2fa" is connected
And "user2fa" has database file
And "user2fa" has running event loop
Scenario: Login user with capital letters in address
Given there is user "userAddressWithCapitalLetter"
When "userAddressWithCapitalLetter" logs in to bridge
Then bridge response is "OK"
And "userAddressWithCapitalLetter" is connected
And "userAddressWithCapitalLetter" has database file
And "userAddressWithCapitalLetter" has running event loop
Scenario: Login user with more addresses
Given there is user "userMoreAddresses"
When "userMoreAddresses" logs in to bridge
Then bridge response is "OK"
And "userMoreAddresses" is connected
And "userMoreAddresses" has database file
And "userMoreAddresses" has running event loop
@ignore-live
Scenario: Login user with disabled primary address
Given there is user "userDisabledPrimaryAddress"
When "userDisabledPrimaryAddress" logs in to bridge
Then bridge response is "OK"
And "userDisabledPrimaryAddress" is connected
And "userDisabledPrimaryAddress" has database file
And "userDisabledPrimaryAddress" has running event loop
Scenario: Login two users
Given there is user "user"
And there is user "userMoreAddresses"
When "user" logs in to bridge
Then bridge response is "OK"
And "user" is connected
When "userMoreAddresses" logs in to bridge
Then bridge response is "OK"
And "userMoreAddresses" is connected

View File

@ -0,0 +1,35 @@
Feature: Re-login to bridge
Scenario: Re-login with connected user and database file
Given there is connected user "user"
And there is database file for "user"
When "user" logs in to bridge
Then bridge response is "failed to finish login: user is already logged in"
And "user" is connected
And "user" has running event loop
@ignore
Scenario: Re-login with connected user and no database file
Given there is connected user "user"
And there is no database file for "user"
When "user" logs in to bridge
Then bridge response is "failed to finish login: user is already logged in"
And "user" is connected
And "user" has database file
And "user" has running event loop
Scenario: Re-login with disconnected user and database file
Given there is disconnected user "user"
And there is database file for "user"
When "user" logs in to bridge
Then bridge response is "OK"
And "user" is connected
And "user" has running event loop
Scenario: Re-login with disconnected user and no database file
Given there is disconnected user "user"
And there is no database file for "user"
When "user" logs in to bridge
Then bridge response is "OK"
And "user" is connected
And "user" has database file
And "user" has running event loop

View File

@ -0,0 +1,78 @@
Feature: Start bridge
Scenario: Start with connected user, database file and internet connection
Given there is connected user "user"
And there is database file for "user"
When bridge starts
Then "user" is connected
And "user" has loaded store
And "user" has running event loop
And "user" has API auth
Scenario: Start with connected user, database file and no internet connection
Given there is connected user "user"
And there is database file for "user"
And there is no internet connection
When bridge starts
Then "user" is connected
And "user" has loaded store
And "user" has running event loop
And "user" does not have API auth
@ignore
Scenario: Start with connected user, no database file and internet connection
Given there is connected user "user"
And there is no database file for "user"
When bridge starts
Then "user" is connected
And "user" has loaded store
And "user" has running event loop
And "user" has API auth
@ignore
Scenario: Start with connected user, no database file and no internet connection
Given there is connected user "user"
And there is no database file for "user"
And there is no internet connection
When bridge starts
Then "user" is disconnected
And "user" does not have API auth
Scenario: Start with disconnected user, database file and internet connection
Given there is disconnected user "user"
And there is database file for "user"
When bridge starts
Then "user" is disconnected
And "user" has loaded store
And "user" does not have running event loop
And "user" does not have API auth
Scenario: Start with disconnected user, database file and no internet connection
Given there is disconnected user "user"
And there is database file for "user"
And there is no internet connection
When bridge starts
Then "user" is disconnected
And "user" has loaded store
And "user" does not have running event loop
And "user" does not have API auth
@ignore
Scenario: Start with disconnected user, no database file and internet connection
Given there is disconnected user "user"
And there is no database file for "user"
When bridge starts
Then "user" is disconnected
And "user" does not have loaded store
And "user" does not have running event loop
And "user" does not have API auth
@ignore
Scenario: Start with disconnected user, no database file and no internet connection
Given there is disconnected user "user"
And there is no database file for "user"
And there is no internet connection
When bridge starts
Then "user" is disconnected
And "user" does not have loaded store
And "user" does not have running event loop
And "user" does not have API auth

View File

@ -0,0 +1,60 @@
Feature: Sync bridge
Background:
Given there is connected user "userMoreAddresses"
And there is "userMoreAddresses" with mailboxes
| Folders/one |
| Folders/two |
| Labels/three |
| Labels/four |
And there are messages for "userMoreAddresses" as follows
| address | mailboxes | messages |
| primary | INBOX,Folders/one | 1 |
| primary | Archive,Labels/three | 2 |
| primary | INBOX | 1000 |
| primary | Archive | 1200 |
| primary | Folders/one | 1400 |
| primary | Folders/two | 1600 |
| primary | Labels/three | 1800 |
| primary | Labels/four | 2000 |
| secondary | INBOX | 100 |
| secondary | Archive | 120 |
| secondary | Folders/one | 140 |
| secondary | Folders/two | 160 |
| secondary | Labels/three | 180 |
| secondary | Labels/four | 200 |
# Too heavy for live.
@ignore-live
Scenario: Sync in combined mode
And there is "userMoreAddresses" in "combined" address mode
When bridge syncs "userMoreAddresses"
Then bridge response is "OK"
And "userMoreAddresses" has the following messages
| mailboxes | messages |
| INBOX | 1101 |
| Archive | 1322 |
| Folders/one | 1541 |
| Folders/two | 1760 |
| Labels/three | 1982 |
| Labels/four | 2200 |
# Too heavy for live.
@ignore-live
Scenario: Sync in split mode
And there is "userMoreAddresses" in "split" address mode
When bridge syncs "userMoreAddresses"
Then bridge response is "OK"
And "userMoreAddresses" has the following messages
| address | mailboxes | messages |
| primary | INBOX | 1001 |
| primary | Archive | 1202 |
| primary | Folders/one | 1401 |
| primary | Folders/two | 1600 |
| primary | Labels/three | 1802 |
| primary | Labels/four | 2000 |
| secondary | INBOX | 100 |
| secondary | Archive | 120 |
| secondary | Folders/one | 140 |
| secondary | Folders/two | 160 |
| secondary | Labels/three | 180 |
| secondary | Labels/four | 200 |

View File

@ -0,0 +1,88 @@
Feature: IMAP auth
Scenario: Authenticates successfully
Given there is connected user "user"
When IMAP client authenticates "user"
Then IMAP response is "OK"
Scenario: Authenticates with bad password
Given there is connected user "user"
When IMAP client authenticates "user" with bad password
Then IMAP response is "IMAP error: NO backend/credentials: incorrect password"
Scenario: Authenticates with disconnected user
Given there is disconnected user "user"
When IMAP client authenticates "user"
Then IMAP response is "IMAP error: NO bridge account is logged out, use bridge to login again"
Scenario: Authenticates with connected user that was loaded without internet
Given there is connected user "user"
And there is no internet connection
When bridge starts
And the internet connection is restored
And the event loop of "user" loops once
And IMAP client authenticates "user"
# Problems during IMAP auth could lead to the user being disconnected.
# This could take a few milliseconds because it happens async in separate goroutines.
# We wait enough time for that to happen, then check that it didn't happen (user should remain connected).
And 2 seconds pass
Then "user" is connected
Scenario: Authenticates with freshly logged-out user
Given there is connected user "user"
When "user" logs out from bridge
And IMAP client authenticates "user"
Then IMAP response is "IMAP error: NO bridge account is logged out, use bridge to login again"
Scenario: Authenticates user which was re-logged in
Given there is connected user "user"
When "user" logs out from bridge
And IMAP client authenticates "user"
Then IMAP response is "IMAP error: NO bridge account is logged out, use bridge to login again"
When "user" logs in to bridge
And IMAP client authenticates "user"
Then IMAP response is "OK"
When IMAP client selects "INBOX"
Then IMAP response is "OK"
Scenario: Authenticates with no user
When IMAP client authenticates with username "user@pm.me" and password "bridgepassword"
Then IMAP response is "IMAP error: NO user user@pm.me not found"
Scenario: Authenticates with capital letter
Given there is connected user "userAddressWithCapitalLetter"
When IMAP client authenticates "userAddressWithCapitalLetter"
Then IMAP response is "OK"
Scenario: Authenticates with more addresses - primary one
Given there is connected user "userMoreAddresses"
When IMAP client authenticates "userMoreAddresses" with address "primary"
Then IMAP response is "OK"
Scenario: Authenticates with more addresses - secondary one
Given there is connected user "userMoreAddresses"
When IMAP client authenticates "userMoreAddresses" with address "secondary"
Then IMAP response is "OK"
Scenario: Authenticates with more addresses - disabled address
Given there is connected user "userMoreAddresses"
When IMAP client authenticates "userMoreAddresses" with address "disabled"
Then IMAP response is "IMAP error: NO user .* not found"
@ignore-live
Scenario: Authenticates with disabled primary address
Given there is connected user "userDisabledPrimaryAddress"
When IMAP client authenticates "userDisabledPrimaryAddress" with address "primary"
Then IMAP response is "OK"
Scenario: Authenticates two users
Given there is connected user "user"
And there is connected user "userMoreAddresses"
When IMAP client "imap1" authenticates "user"
Then IMAP response to "imap1" is "OK"
When IMAP client "imap2" authenticates "userMoreAddresses" with address "primary"
Then IMAP response to "imap2" is "OK"
Scenario: Logs out user
Given there is connected user "user"
When IMAP client logs out
Then IMAP response is "OK"

View File

@ -0,0 +1,62 @@
Feature: IMAP IDLE
Background:
Given there is connected user "user"
And there are 10 messages in mailbox "INBOX" for "user"
@ignore
Scenario Outline: Mark as read
Given there is IMAP client "active" logged in as "user"
And there is IMAP client "active" selected in "INBOX"
And there is IMAP client "idling" logged in as "user"
And there is IMAP client "idling" selected in "INBOX"
When IMAP client "idling" starts IDLE-ing
And IMAP client "active" marks message "<message>" as read
Then IMAP client "idling" receives update marking message "<message>" as read within <seconds> seconds
Then message "<message>" in "INBOX" for "user" is marked as read
Examples:
| message | seconds |
| 1 | 2 |
| 1:5 | 2 |
| 1:10 | 5 |
@ignore
Scenario Outline: Mark as unread
Given there is IMAP client "active" logged in as "user"
And there is IMAP client "active" selected in "INBOX"
And there is IMAP client "idling" logged in as "user"
And there is IMAP client "idling" selected in "INBOX"
When IMAP client "idling" starts IDLE-ing
And IMAP client "active" marks message "<message>" as unread
Then IMAP client "idling" receives update marking message "<message>" as unread within <seconds> seconds
And message "<message>" in "INBOX" for "user" is marked as unread
Examples:
| message | seconds |
| 1 | 2 |
| 1:5 | 2 |
| 1:10 | 5 |
@ignore
Scenario Outline: Three IDLEing
Given there is IMAP client "active" logged in as "user"
And there is IMAP client "active" selected in "INBOX"
And there is IMAP client "idling1" logged in as "user"
And there is IMAP client "idling1" selected in "INBOX"
And there is IMAP client "idling2" logged in as "user"
And there is IMAP client "idling2" selected in "INBOX"
And there is IMAP client "idling3" logged in as "user"
And there is IMAP client "idling3" selected in "INBOX"
When IMAP client "idling1" starts IDLE-ing
And IMAP client "idling2" starts IDLE-ing
And IMAP client "idling3" starts IDLE-ing
And IMAP client "active" marks message "<message>" as read
Then IMAP client "idling1" receives update marking message "<message>" as read within <seconds> seconds
Then IMAP client "idling2" receives update marking message "<message>" as read within <seconds> seconds
Then IMAP client "idling3" receives update marking message "<message>" as read within <seconds> seconds
Examples:
| message | seconds |
| 1 | 2 |
| 1:5 | 2 |
| 1:10 | 5 |

View File

@ -0,0 +1,28 @@
Feature: IMAP IDLE with two users
Scenario: IDLE statements are not leaked to other account
Given there is connected user "user"
And there are 10 messages in mailbox "INBOX" for "user"
And there is connected user "userMoreAddresses"
And there is IMAP client "active" logged in as "user"
And there is IMAP client "active" selected in "INBOX"
And there is IMAP client "idling" logged in as "userMoreAddresses"
And there is IMAP client "idling" selected in "INBOX"
When IMAP client "idling" starts IDLE-ing
And IMAP client "active" marks message "1" as read
Then IMAP client "idling" does not receive update for message "1" within 5 seconds
Scenario: IDLE statements are not leaked to other alias
Given there is connected user "userMoreAddresses"
And there is "userMoreAddresses" in "combined" address mode
And there is "userMoreAddresses" with mailbox "Folders/mbox"
And there are messages in mailbox "Folders/mbox" for "userMoreAddresses"
| from | to | subject |
| john.doe@mail.com | [primary] | foo |
| jane.doe@mail.com | [secondary] | bar |
And there is IMAP client "active" logged in as "userMoreAddresses" with address "primary"
And there is IMAP client "active" selected in "INBOX"
And there is IMAP client "idling" logged in as "userMoreAddresses" with address "secondary"
And there is IMAP client "idling" selected in "INBOX"
When IMAP client "idling" starts IDLE-ing
And IMAP client "active" marks message "1" as read
Then IMAP client "idling" does not receive update for message "1" within 5 seconds

View File

@ -0,0 +1,25 @@
Feature: IMAP create mailbox
Background:
Given there is connected user "user"
And there is IMAP client logged in as "user"
Scenario: Create folder
When IMAP client creates mailbox "Folders/mbox"
Then IMAP response is "OK"
And "user" has mailbox "Folders/mbox"
And "user" does not have mailbox "Labels/mbox"
Scenario: Create label
When IMAP client creates mailbox "Labels/mbox"
Then IMAP response is "OK"
And "user" does not have mailbox "Folders/mbox"
And "user" has mailbox "Labels/mbox"
Scenario: Creating system mailbox is not possible
When IMAP client creates mailbox "INBOX"
Then IMAP response is "IMAP error: NO mailbox INBOX already exists"
Scenario: Creating mailbox without prefix is not possible
When IMAP client creates mailbox "mbox"
Then IMAP response is "OK"
And "user" does not have mailbox "mbox"

View File

@ -0,0 +1,29 @@
Feature: IMAP delete mailbox
Background:
Given there is connected user "user"
Scenario: Delete folder
Given there is "user" with mailbox "Folders/mbox"
And there is IMAP client logged in as "user"
When IMAP client deletes mailbox "Folders/mbox"
Then IMAP response is "OK"
And "user" does not have mailbox "Folders/mbox"
Scenario: Delete label
Given there is "user" with mailbox "Labels/mbox"
And there is IMAP client logged in as "user"
When IMAP client deletes mailbox "Labels/mbox"
Then IMAP response is "OK"
And "user" does not have mailbox "Labels/mbox"
Scenario: Empty Trash by deleting it
Given there are 10 messages in mailbox "Trash" for "user"
And there is IMAP client logged in as "user"
When IMAP client deletes mailbox "Trash"
Then IMAP response is "OK"
And mailbox "Trash" for "user" has 0 messages
Scenario: Deleting system mailbox is not possible
Given there is IMAP client logged in as "user"
When IMAP client deletes mailbox "INBOX"
Then IMAP response is "IMAP error: NO cannot empty mailbox 0"

View File

@ -0,0 +1,15 @@
Feature: IMAP get mailbox info
Background:
Given there is connected user "user"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | body | read | starred |
| john.doe@mail.com | user@pm.me | foo | hello | false | false |
| jane.doe@mail.com | name@pm.me | bar | world | true | true |
And there is IMAP client logged in as "user"
Scenario: Mailbox info contains mailbox name
When IMAP client gets info of "INBOX"
Then IMAP response contains "2 EXISTS"
And IMAP response contains "UNSEEN 1"
And IMAP response contains "UIDNEXT 3"
And IMAP response contains "UIDVALIDITY"

View File

@ -0,0 +1,16 @@
Feature: IMAP list mailboxes
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/mbox1"
And there is "user" with mailbox "Labels/mbox2"
And there is IMAP client logged in as "user"
Scenario: List mailboxes
When IMAP client lists mailboxes
Then IMAP response contains "INBOX"
Then IMAP response contains "Sent"
Then IMAP response contains "Archive"
Then IMAP response contains "Trash"
Then IMAP response contains "All Mail"
Then IMAP response contains "Folders/mbox1"
Then IMAP response contains "Labels/mbox2"

View File

@ -0,0 +1,30 @@
Feature: IMAP mailbox rename
Background:
Given there is connected user "user"
Scenario: Rename folder
Given there is "user" with mailbox "Folders/mbox"
And there is IMAP client logged in as "user"
When IMAP client renames mailbox "Folders/mbox" to "Folders/mbox2"
Then IMAP response is "OK"
And "user" does not have mailbox "Folders/mbox"
And "user" has mailbox "Folders/mbox2"
Scenario: Rename label
Given there is "user" with mailbox "Labels/mbox"
And there is IMAP client logged in as "user"
When IMAP client renames mailbox "Labels/mbox" to "Labels/mbox2"
Then IMAP response is "OK"
And "user" does not have mailbox "Labels/mbox"
And "user" has mailbox "Labels/mbox2"
Scenario: Renaming folder to label is not possible
Given there is "user" with mailbox "Folders/mbox"
And there is IMAP client logged in as "user"
When IMAP client renames mailbox "Folders/mbox" to "Labels/mbox"
Then IMAP response is "IMAP error: NO cannot rename folder to non-folder"
Scenario: Renaming system folder is not possible
Given there is IMAP client logged in as "user"
When IMAP client renames mailbox "INBOX" to "Folders/mbox"
Then IMAP response is "IMAP error: NO cannot rename system mailboxes"

View File

@ -0,0 +1,17 @@
Feature: IMAP select into mailbox
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/mbox"
And there is IMAP client logged in as "user"
Scenario: Select into inbox
When IMAP client selects "INBOX"
Then IMAP response is "OK"
Scenario: Select into custom mailbox
When IMAP client selects "Folders/mbox"
Then IMAP response is "OK"
Scenario: Select into non-existing mailbox
When IMAP client selects "qwerty"
Then IMAP response is "IMAP error: NO mailbox qwerty does not exist"

View File

@ -0,0 +1,21 @@
Feature: IMAP get mailbox status
Background:
Given there is connected user "user"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | body | read | starred |
| john.doe@mail.com | user@pm.me | foo | hello | false | false |
| jane.doe@mail.com | name@pm.me | bar | world | true | true |
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
Scenario: Mailbox status contains mailbox name
When IMAP client gets status of "INBOX"
Then IMAP response contains "INBOX"
Scenario: Mailbox status contains
When IMAP client gets status of "INBOX"
Then IMAP response contains "INBOX"
And IMAP response contains "MESSAGES 2"
And IMAP response contains "UNSEEN 1"
And IMAP response contains "UIDNEXT 3"
And IMAP response contains "UIDVALIDITY"

View File

@ -0,0 +1,45 @@
Feature: IMAP copy messages
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/mbox"
And there is "user" with mailbox "Labels/label"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | body |
| john.doe@mail.com | user@pm.me | foo | hello |
| jane.doe@mail.com | name@pm.me | bar | world |
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
Scenario: Copy message to label
When IMAP client copies messages "2" to "Labels/label"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has 2 messages
And mailbox "Labels/label" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
Scenario: Copy all messages to label
When IMAP client copies messages "1:*" to "Labels/label"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has 2 messages
And mailbox "Labels/label" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
| jane.doe@mail.com | name@pm.me | bar |
Scenario: Copy message to folder does move
When IMAP client copies messages "2" to "Folders/mbox"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has 1 message
And mailbox "Folders/mbox" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
Scenario: Copy all messages to folder does move
When IMAP client copies messages "1:*" to "Folders/mbox"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has 0 messages
And mailbox "Folders/mbox" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
| jane.doe@mail.com | name@pm.me | bar |

View File

@ -0,0 +1,59 @@
Feature: IMAP create messages
Background:
Given there is connected user "userMoreAddresses"
And there is IMAP client logged in as "userMoreAddresses"
Scenario: Creates message to user's primary address
Given there is IMAP client selected in "INBOX"
When IMAP client creates message "foo" from "john.doe@email.com" to address "primary" of "userMoreAddresses" with body "hello world" in "INBOX"
Then IMAP response is "OK"
And mailbox "INBOX" for "userMoreAddresses" has messages
| from | to | subject | read |
| john.doe@email.com | [primary] | foo | true |
Scenario: Creates draft
When IMAP client creates message "foo" from address "primary" of "userMoreAddresses" to "john.doe@email.com" with body "hello world" in "Drafts"
Then IMAP response is "OK"
And mailbox "Drafts" for "userMoreAddresses" has messages
| from | to | subject | read |
| [primary] | john.doe@email.com | foo | true |
@ignore
Scenario: Creates message sent from user's primary address
Given there is IMAP client selected in "Sent"
When IMAP client creates message "foo" from address "primary" of "userMoreAddresses" to "john.doe@email.com" with body "hello world" in "Sent"
Then IMAP response is "OK"
When the event loop of "userMoreAddresses" loops once
Then mailbox "Sent" for "userMoreAddresses" has messages
| from | to | subject | read |
| [primary] | john.doe@email.com | foo | true |
And mailbox "INBOX" for "userMoreAddresses" has no messages
@ignore
Scenario: Creates message sent from user's secondary address
Given there is IMAP client selected in "Sent"
When IMAP client creates message "foo" from address "secondary" of "userMoreAddresses" to "john.doe@email.com" with body "hello world" in "Sent"
Then IMAP response is "OK"
When the event loop of "userMoreAddresses" loops once
Then mailbox "Sent" for "userMoreAddresses" has messages
| from | to | subject | read |
| [secondary] | john.doe@email.com | foo | true |
And mailbox "INBOX" for "userMoreAddresses" has no messages
Scenario: Imports an unrelated message to inbox
Given there is IMAP client selected in "INBOX"
When IMAP client creates message "foo" from "john.doe@email.com" to "john.doe2@email.com" with body "hello world" in "INBOX"
Then IMAP response is "OK"
And mailbox "INBOX" for "userMoreAddresses" has messages
| from | to | subject | read |
| john.doe@email.com | john.doe2@email.com | foo | true |
Scenario: Imports an unrelated message to sent
Given there is IMAP client selected in "Sent"
When IMAP client creates message "foo" from "notuser@gmail.com" to "alsonotuser@gmail.com" with body "hello world" in "Sent"
Then IMAP response is "OK"
When the event loop of "userMoreAddresses" loops once
Then mailbox "Sent" for "userMoreAddresses" has messages
| from | to | subject | read |
| notuser@gmail.com | alsonotuser@gmail.com | foo | true |
And mailbox "INBOX" for "userMoreAddresses" has no messages

View File

@ -0,0 +1,41 @@
Feature: IMAP delete messages
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/mbox"
And there is "user" with mailbox "Labels/label"
# https://gitlab.protontech.ch/ProtonMail/Slim-API/issues/1420
@ignore-live
Scenario Outline: Delete message
Given there are 10 messages in mailbox "<mailbox>" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "<mailbox>"
When IMAP client deletes messages "1"
Then IMAP response is "OK"
And mailbox "<mailbox>" for "user" has 9 messages
Examples:
| mailbox |
| INBOX |
| Folders/mbox |
| Labels/label |
| Drafts |
| Trash |
# https://gitlab.protontech.ch/ProtonMail/Slim-API/issues/1420
@ignore-live
Scenario Outline: Delete all messages
Given there are 10 messages in mailbox "<mailbox>" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "<mailbox>"
When IMAP client deletes messages "1:*"
Then IMAP response is "OK"
And mailbox "<mailbox>" for "user" has 0 messages
Examples:
| mailbox |
| INBOX |
| Folders/mbox |
| Labels/label |
| Drafts |
| Trash |

View File

@ -0,0 +1,51 @@
Feature: IMAP fetch messages
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/mbox"
Scenario: Fetch of inbox
Given there are 10 messages in mailbox "INBOX" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
When IMAP client fetches "1:*"
Then IMAP response is "OK"
And IMAP response has 10 messages
Scenario: Fetch first few message of inbox
Given there are 10 messages in mailbox "INBOX" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
When IMAP client fetches "1:5"
Then IMAP response is "OK"
And IMAP response has 5 messages
Scenario: Fetch of custom mailbox
Given there are 10 messages in mailbox "Folders/mbox" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "Folders/mbox"
When IMAP client fetches "1:*"
Then IMAP response is "OK"
And IMAP response has 10 messages
Scenario: Fetch of emtpy mailbox
Given there is IMAP client logged in as "user"
And there is IMAP client selected in "Folders/mbox"
When IMAP client fetches "1:*"
Then IMAP response is "OK"
And IMAP response has 0 messages
Scenario: Fetch of big mailbox
Given there are 100 messages in mailbox "Folders/mbox" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "Folders/mbox"
When IMAP client fetches "1:*"
Then IMAP response is "OK"
And IMAP response has 100 messages
Scenario: Fetch returns alsways latest messages
Given there are 10 messages in mailbox "Folders/mbox" for "user"
And there is IMAP client logged in as "user"
And there is IMAP client selected in "Folders/mbox"
When IMAP client fetches by UID "11:*"
Then IMAP response is "OK"
And IMAP response has 1 message

View File

@ -0,0 +1,54 @@
Feature: IMAP move messages
Background:
Given there is connected user "user"
And there is "user" with mailbox "Folders/mbox"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | body |
| john.doe@mail.com | user@pm.me | foo | hello |
| jane.doe@mail.com | name@pm.me | bar | world |
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
@ignore
Scenario: Move message
When IMAP client moves messages "1" to "Folders/mbox"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has messages
| from | to | subject |
| jane.doe@mail.com | name@pm.me | bar |
And mailbox "Folders/mbox" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
@ignore
Scenario: Move all messages
When IMAP client moves messages "1:*" to "Folders/mbox"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has 0 messages
And mailbox "Folders/mbox" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
| jane.doe@mail.com | name@pm.me | bar |
@ignore
Scenario: Move message to All Mail
When IMAP client moves messages "1" to "All Mail"
Then IMAP response is "OK"
And mailbox "INBOX" for "user" has messages
| from | to | subject |
| jane.doe@mail.com | name@pm.me | bar |
And mailbox "All Mail" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
@ignore
Scenario: Move message from All Mail is not possible
When IMAP client moves messages "1" to "Folders/mbox"
Then IMAP response is "OK"
And mailbox "All Mail" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |
| jane.doe@mail.com | name@pm.me | bar |
And mailbox "Folders/mbox" for "user" has messages
| from | to | subject |
| john.doe@mail.com | user@pm.me | foo |

View File

@ -0,0 +1,29 @@
Feature: IMAP search messages
Background:
Given there is connected user "user"
Given there are messages in mailbox "INBOX" for "user"
| from | to | subject | body |
| john.doe@mail.com | user@pm.me | foo | hello |
| jane.doe@mail.com | name@pm.me | bar | world |
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
Scenario: Search by subject
When IMAP client searches for "SUBJECT foo"
Then IMAP response is "OK"
And IMAP response has 1 message
Scenario: Search by text
When IMAP client searches for "TEXT world"
Then IMAP response is "OK"
And IMAP response has 1 message
Scenario: Search by from
When IMAP client searches for "FROM jane.doe@email.com"
Then IMAP response is "OK"
And IMAP response has 1 message
Scenario: Search by to
When IMAP client searches for "TO user@pm.me"
Then IMAP response is "OK"
And IMAP response has 1 message

View File

@ -0,0 +1,35 @@
Feature: IMAP update messages
Background:
Given there is connected user "user"
And there are messages in mailbox "INBOX" for "user"
| from | to | subject | body | read | starred |
| john.doe@mail.com | user@pm.me | foo | hello | false | false |
| jane.doe@mail.com | name@pm.me | bar | world | true | true |
And there is IMAP client logged in as "user"
And there is IMAP client selected in "INBOX"
Scenario: Mark message as read
When IMAP client marks message "2" as read
Then IMAP response is "OK"
And message "1" in "INBOX" for "user" is marked as read
And message "1" in "INBOX" for "user" is marked as unstarred
Scenario: Mark message as unread
When IMAP client marks message "1" as unread
Then IMAP response is "OK"
And message "2" in "INBOX" for "user" is marked as unread
And message "2" in "INBOX" for "user" is marked as starred
Scenario: Mark message as starred
Then message "1" in "INBOX" for "user" is marked as unread
And message "1" in "INBOX" for "user" is marked as unstarred
When IMAP client marks message "2" as starred
Then IMAP response is "OK"
And message "1" in "INBOX" for "user" is marked as unread
And message "1" in "INBOX" for "user" is marked as starred
Scenario: Mark message as unstarred
When IMAP client marks message "1" as unstarred
Then IMAP response is "OK"
And message "2" in "INBOX" for "user" is marked as read
And message "2" in "INBOX" for "user" is marked as unstarred

View File

@ -0,0 +1,65 @@
Feature: SMTP auth
Scenario: Ask EHLO
Given there is connected user "user"
When SMTP client sends EHLO
Then SMTP response is "OK"
Scenario: Authenticates successfully and EHLO successfully
Given there is connected user "user"
When SMTP client authenticates "user"
Then SMTP response is "OK"
When SMTP client sends EHLO
Then SMTP response is "OK"
Scenario: Authenticates with bad password
Given there is connected user "user"
When SMTP client authenticates "user" with bad password
Then SMTP response is "SMTP error: 454 backend/credentials: incorrect password"
Scenario: Authenticates with disconnected user
Given there is disconnected user "user"
When SMTP client authenticates "user"
Then SMTP response is "SMTP error: 454 bridge account is logged out, use bridge to login again"
Scenario: Authenticates with no user
When SMTP client authenticates with username "user@pm.me" and password "bridgepassword"
Then SMTP response is "SMTP error: 454 user user@pm.me not found"
Scenario: Authenticates with capital letter
Given there is connected user "userAddressWithCapitalLetter"
When SMTP client authenticates "userAddressWithCapitalLetter"
Then SMTP response is "OK"
Scenario: Authenticates with more addresses - primary one
Given there is connected user "userMoreAddresses"
When SMTP client authenticates "userMoreAddresses" with address "primary"
Then SMTP response is "OK"
Scenario: Authenticates with more addresses - secondary one
Given there is connected user "userMoreAddresses"
When SMTP client authenticates "userMoreAddresses" with address "secondary"
Then SMTP response is "OK"
Scenario: Authenticates with more addresses - disabled address
Given there is connected user "userMoreAddresses"
When SMTP client authenticates "userMoreAddresses" with address "disabled"
Then SMTP response is "SMTP error: 454 user .* not found"
@ignore-live
Scenario: Authenticates with disabled primary address
Given there is connected user "userDisabledPrimaryAddress"
When SMTP client authenticates "userDisabledPrimaryAddress" with address "primary"
Then SMTP response is "OK"
Scenario: Authenticates two users
Given there is connected user "user"
And there is connected user "userMoreAddresses"
When SMTP client "smtp1" authenticates "user"
Then SMTP response to "smtp1" is "OK"
When SMTP client "smtp2" authenticates "userMoreAddresses" with address "primary"
Then SMTP response to "smtp2" is "OK"
Scenario: Logs out user
Given there is connected user "user"
When SMTP client logs out
Then SMTP response is "OK"

View File

@ -0,0 +1,69 @@
Feature: SMTP with bcc
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
Scenario: Send message to address in to and bcc
When SMTP client sends message with bcc "bridgetest2@protonmail.com"
"""
Subject: hello
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
hello
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | hello |
And message is sent with API call:
"""
{
"Message": {
"Subject": "hello",
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [
{
"Address": "bridgetest2@protonmail.com"
}
]
}
}
"""
Scenario: Send message only to bcc
When SMTP client sends message with bcc "bridgetest@protonmail.com"
"""
Subject: hello
From: Bridge Test <bridgetest@pm.test>
hello
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | | hello |
And message is sent with API call:
"""
{
"Message": {
"Subject": "hello",
"ToList": [],
"CCList": [],
"BCCList": [
{
"Address": "bridgetest@protonmail.com"
}
]
}
}
"""

View File

@ -0,0 +1,56 @@
Feature: SMTP wrong messages
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
Scenario: Message with no charset and bad character
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain text, no charset, wrong base64 external
Content-Disposition: inline
Content-Type: text/plain;
Content-Transfer-Encoding: base64
sdfsdfsd
"""
Then SMTP response is "SMTP error: 554 Error: transaction failed, blame it on the weather: non-utf8 content without charset specification"
Scenario: Message with attachment and wrong boundaries
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: With attachment (wrong boundaries)
Content-Type: multipart/related; boundary=bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
This is body of mail with attachment
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: attachment; filename=outline-light-instagram-48.png
Content-Id: <9114fe6f0adfaf7fdf7a@protonmail.com>
Content-Transfer-Encoding: base64
Content-Type: image/png
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAALVBMVEUAAAD/////////////////
//////////////////////////////////////+hSKubAAAADnRSTlMAgO8QQM+/IJ9gj1AwcIQd
OXUAAAGdSURBVDjLXJC9SgNBFIVPXDURTYhgIQghINgowyLYCAYtRFAIgtYhpAjYhC0srCRW6YIg
WNpoHVSsg/gEii+Qnfxq4DyDc3cyMfrBwl2+O+fOHTi8p7LS5RUf/9gpMKL7iT9sK47Q95ggpkzv
1cvRcsGYNMYsmP+zKN27NR2vcDyTNVdfkOuuniNPMWafvIbljt+YoMEvW8y7lt+ARwhvrgPjhA0I
BTng7S1GLPlypBvtIBPidY4YBDJFdtnkscQ5JGaGqxC9i7jSDwcwnB8qHWBaQjw1ABI8wYgtVoG6
9pFkH8iZIiJeulFt4JLvJq8I5N2GMWYbHWDWzM3JZTMdeSWla0kW86FcuI0mfStiNKQ/AhEeh8h0
YUTffFwrMTT5oSwdojIQ0UKcocgAKRH1HiqhFQmmJa5qRaYHNbRiSsOgslY0NdixItUTUWlZkedP
HXVyAgAIA1F0wP5btQZPIyTwvAqa/Fl4oacuP+e4XHAjSYpkQkxSiMX+T7FPoZJToSStzED70HCy
KE3NGCg4jJrC6Ti7AFwZLhnW0gMbzFZc0RmmeAAAAABJRU5ErkJggg==
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
"""
Then SMTP response is "SMTP error: 554 Error: transaction failed, blame it on the weather: multipart: NextPart: EOF"

View File

@ -0,0 +1,297 @@
Feature: SMTP sending of HTML messages
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
Scenario: HTML message to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: HTML text external
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=utf-8
In-Reply-To: base64hashOfSomeMessage@protonmail.internalID
References: base64hashOfSomeConversation@protonmail.internalID base64hashOfSomeConversation@protonmail.conversationID
<html><body>This is body of <b>HTML mail</b> without attachment<body></html>
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | HTML text external |
And message is sent with API call:
"""
{
"Message": {
"Subject": "HTML text external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""
Scenario: HTML message with inline image to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Html Inline External
Content-Disposition: inline
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Thunderbird/60.5.0
MIME-Version: 1.0
Content-Language: en-US
Content-Type: multipart/related; boundary="------------61FA22A41A3F46E8E90EF528"
This is a multi-part message in MIME format.
--------------61FA22A41A3F46E8E90EF528
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body text="#000000" bgcolor="#FFFFFF">
<p><br>
</p>
<p>Behold! An inline <img moz-do-not-send="false"
src="cid:part1.D96BFAE9.E2E1CAE3@protonmail.com" alt=""
width="24" height="24"><br>
</p>
</body>
</html>
--------------61FA22A41A3F46E8E90EF528
Content-Type: image/gif; name="email-action-left.gif"
Content-Transfer-Encoding: base64
Content-ID: <part1.D96BFAE9.E2E1CAE3@protonmail.com>
Content-Disposition: inline; filename="email-action-left.gif"
R0lGODlhGAAYANUAACcsKOHs4kppTH6tgYWxiIq0jTVENpG5lDI/M7bRuEaJSkqOTk2RUU+P
U16lYl+lY2iva262cXS6d3rDfYLNhWeeamKTZGSVZkNbRGqhbOPt4////+7u7qioqFZWVlNT
UyIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAGAAYAAAG
/8CNcLjRJAqVRqNSSGiI0GFgoKhar4NAdHioMhyRCYUyiTgY1cOWUH1ILgIDAGAQXCSPKgHa
XUAyGCCCg4IYGRALCmpCAVUQFgiEkiAIFhBVWhtUDxmRk5IIGXkDRQoMEoGfHpIYEmhGCg4X
nyAdHB+SFw4KRwoRArQdG7eEAhEKSAoTBoIdzs/Cw7iCBhMKSQoUAIJbQ8QgABQKStnbIN1C
3+HjFcrMtdDO6dMg1dcFvsCfwt+CxsgJYs3a10+QLl4aTKGitYpQq1eaFHDyREtQqFGMHEGq
SMkSJi4K/ACiZQiRIihsJL6JM6fOnTwK9kTpYgqMGDJm0JzsNuWKTw0FWdANMYJECRMnW4IA
ADs=
--------------61FA22A41A3F46E8E90EF528--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | Html Inline External |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Html Inline External",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""
Scenario: HTML message with alternative inline to internal account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Html Inline Alternative Internal
Content-Disposition: inline
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Thunderbird/60.5.0
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="------------5A259F4DE164B5ADA313F644"
Content-Language: en-US
This is a multi-part message in MIME format.
--------------5A259F4DE164B5ADA313F644
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Behold! An inline
--------------5A259F4DE164B5ADA313F644
Content-Type: multipart/related; boundary="------------61FA22A41A3F46E8E90EF528"
--------------61FA22A41A3F46E8E90EF528
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body text="#000000" bgcolor="#FFFFFF">
<p><br>
</p>
<p>Behold! An inline <img moz-do-not-send="false"
src="cid:part1.D96BFAE9.E2E1CAE3@protonmail.com" alt=""
width="24" height="24"><br>
</p>
</body>
</html>
--------------61FA22A41A3F46E8E90EF528
Content-Type: image/gif; name="email-action-left.gif"
Content-Transfer-Encoding: base64
Content-ID: <part1.D96BFAE9.E2E1CAE3@protonmail.com>
Content-Disposition: inline; filename="email-action-left.gif"
R0lGODlhGAAYANUAACcsKOHs4kppTH6tgYWxiIq0jTVENpG5lDI/M7bRuEaJSkqOTk2RUU+P
U16lYl+lY2iva262cXS6d3rDfYLNhWeeamKTZGSVZkNbRGqhbOPt4////+7u7qioqFZWVlNT
UyIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAGAAYAAAG
/8CNcLjRJAqVRqNSSGiI0GFgoKhar4NAdHioMhyRCYUyiTgY1cOWUH1ILgIDAGAQXCSPKgHa
XUAyGCCCg4IYGRALCmpCAVUQFgiEkiAIFhBVWhtUDxmRk5IIGXkDRQoMEoGfHpIYEmhGCg4X
nyAdHB+SFw4KRwoRArQdG7eEAhEKSAoTBoIdzs/Cw7iCBhMKSQoUAIJbQ8QgABQKStnbIN1C
3+HjFcrMtdDO6dMg1dcFvsCfwt+CxsgJYs3a10+QLl4aTKGitYpQq1eaFHDyREtQqFGMHEGq
SMkSJi4K/ACiZQiRIihsJL6JM6fOnTwK9kTpYgqMGDJm0JzsNuWKTw0FWdANMYJECRMnW4IA
ADs=
--------------61FA22A41A3F46E8E90EF528--
--------------5A259F4DE164B5ADA313F644--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | Html Inline Alternative Internal |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Html Inline Alternative Internal",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""
Scenario: HTML message with alternative inline to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Html Inline Alternative External
Content-Disposition: inline
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:60.0) Gecko/20100101 Thunderbird/60.5.0
MIME-Version: 1.0
Content-Type: multipart/alternative; boundary="------------5A259F4DE164B5ADA313F644"
Content-Language: en-US
This is a multi-part message in MIME format.
--------------5A259F4DE164B5ADA313F644
Content-Type: text/plain; charset=utf-8
Content-Transfer-Encoding: 7bit
Behold! An inline
--------------5A259F4DE164B5ADA313F644
Content-Type: multipart/related; boundary="------------61FA22A41A3F46E8E90EF528"
--------------61FA22A41A3F46E8E90EF528
Content-Type: text/html; charset=utf-8
Content-Transfer-Encoding: 7bit
<html>
<head>
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
</head>
<body text="#000000" bgcolor="#FFFFFF">
<p><br>
</p>
<p>Behold! An inline <img moz-do-not-send="false"
src="cid:part1.D96BFAE9.E2E1CAE3@protonmail.com" alt=""
width="24" height="24"><br>
</p>
</body>
</html>
--------------61FA22A41A3F46E8E90EF528
Content-Type: image/gif; name="email-action-left.gif"
Content-Transfer-Encoding: base64
Content-ID: <part1.D96BFAE9.E2E1CAE3@protonmail.com>
Content-Disposition: inline; filename="email-action-left.gif"
R0lGODlhGAAYANUAACcsKOHs4kppTH6tgYWxiIq0jTVENpG5lDI/M7bRuEaJSkqOTk2RUU+P
U16lYl+lY2iva262cXS6d3rDfYLNhWeeamKTZGSVZkNbRGqhbOPt4////+7u7qioqFZWVlNT
UyIiIgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACwAAAAAGAAYAAAG
/8CNcLjRJAqVRqNSSGiI0GFgoKhar4NAdHioMhyRCYUyiTgY1cOWUH1ILgIDAGAQXCSPKgHa
XUAyGCCCg4IYGRALCmpCAVUQFgiEkiAIFhBVWhtUDxmRk5IIGXkDRQoMEoGfHpIYEmhGCg4X
nyAdHB+SFw4KRwoRArQdG7eEAhEKSAoTBoIdzs/Cw7iCBhMKSQoUAIJbQ8QgABQKStnbIN1C
3+HjFcrMtdDO6dMg1dcFvsCfwt+CxsgJYs3a10+QLl4aTKGitYpQq1eaFHDyREtQqFGMHEGq
SMkSJi4K/ACiZQiRIihsJL6JM6fOnTwK9kTpYgqMGDJm0JzsNuWKTw0FWdANMYJECRMnW4IA
ADs=
--------------61FA22A41A3F46E8E90EF528--
--------------5A259F4DE164B5ADA313F644--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | Html Inline Alternative External |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Html Inline Alternative External",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""

View File

@ -0,0 +1,122 @@
Feature: SMTP sending of HTML messages with attachments
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
Scenario: HTML message with attachment to internal account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: HTML with attachment internal
Content-Type: multipart/related; boundary=bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=utf-8
<html><body>This is body of <b>HTML mail</b> with attachment<body></html>
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: attachment; filename=outline-light-instagram-48.png
Content-Id: <9114fe6f0adfaf7fdf7a@protonmail.com>
Content-Transfer-Encoding: base64
Content-Type: image/png
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAALVBMVEUAAAD/////////////////
//////////////////////////////////////+hSKubAAAADnRSTlMAgO8QQM+/IJ9gj1AwcIQd
OXUAAAGdSURBVDjLXJC9SgNBFIVPXDURTYhgIQghINgowyLYCAYtRFAIgtYhpAjYhC0srCRW6YIg
WNpoHVSsg/gEii+Qnfxq4DyDc3cyMfrBwl2+O+fOHTi8p7LS5RUf/9gpMKL7iT9sK47Q95ggpkzv
1cvRcsGYNMYsmP+zKN27NR2vcDyTNVdfkOuuniNPMWafvIbljt+YoMEvW8y7lt+ARwhvrgPjhA0I
BTng7S1GLPlypBvtIBPidY4YBDJFdtnkscQ5JGaGqxC9i7jSDwcwnB8qHWBaQjw1ABI8wYgtVoG6
9pFkH8iZIiJeulFt4JLvJq8I5N2GMWYbHWDWzM3JZTMdeSWla0kW86FcuI0mfStiNKQ/AhEeh8h0
YUTffFwrMTT5oSwdojIQ0UKcocgAKRH1HiqhFQmmJa5qRaYHNbRiSsOgslY0NdixItUTUWlZkedP
HXVyAgAIA1F0wP5btQZPIyTwvAqa/Fl4oacuP+e4XHAjSYpkQkxSiMX+T7FPoZJToSStzED70HCy
KE3NGCg4jJrC6Ti7AFwZLhnW0gMbzFZc0RmmeAAAAABJRU5ErkJggg==
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | HTML with attachment internal |
And message is sent with API call:
"""
{
"Message": {
"Subject": "HTML with attachment internal",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""
Scenario: HTML message with attachment to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: HTML with attachment external PGP
Content-Type: multipart/mixed; boundary=bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/html; charset=utf-8
<html><body>This is body of <b>HTML mail</b> with attachment<body></html>
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: attachment; filename=outline-light-instagram-48.png
Content-Id: <9114fe6f0adfaf7fdf7a@protonmail.com>
Content-Transfer-Encoding: base64
Content-Type: image/png
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAALVBMVEUAAAD/////////////////
//////////////////////////////////////+hSKubAAAADnRSTlMAgO8QQM+/IJ9gj1AwcIQd
OXUAAAGdSURBVDjLXJC9SgNBFIVPXDURTYhgIQghINgowyLYCAYtRFAIgtYhpAjYhC0srCRW6YIg
WNpoHVSsg/gEii+Qnfxq4DyDc3cyMfrBwl2+O+fOHTi8p7LS5RUf/9gpMKL7iT9sK47Q95ggpkzv
1cvRcsGYNMYsmP+zKN27NR2vcDyTNVdfkOuuniNPMWafvIbljt+YoMEvW8y7lt+ARwhvrgPjhA0I
BTng7S1GLPlypBvtIBPidY4YBDJFdtnkscQ5JGaGqxC9i7jSDwcwnB8qHWBaQjw1ABI8wYgtVoG6
9pFkH8iZIiJeulFt4JLvJq8I5N2GMWYbHWDWzM3JZTMdeSWla0kW86FcuI0mfStiNKQ/AhEeh8h0
YUTffFwrMTT5oSwdojIQ0UKcocgAKRH1HiqhFQmmJa5qRaYHNbRiSsOgslY0NdixItUTUWlZkedP
HXVyAgAIA1F0wP5btQZPIyTwvAqa/Fl4oacuP+e4XHAjSYpkQkxSiMX+T7FPoZJToSStzED70HCy
KE3NGCg4jJrC6Ti7AFwZLhnW0gMbzFZc0RmmeAAAAABJRU5ErkJggg==
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | HTML with attachment external PGP |
And message is sent with API call:
"""
{
"Message": {
"Subject": "HTML with attachment external PGP",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""

View File

@ -0,0 +1,218 @@
Feature: SMTP sending of plain messages
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
Scenario: Only from and to headers to internal account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
hello
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | |
And message is sent with API call:
"""
{
"Message": {
"Subject": "",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Only from and to headers to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
hello
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | |
And message is sent with API call:
"""
{
"Message": {
"Subject": "",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Basic message to internal account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Plain text internal
Content-Disposition: inline
Content-Type: text/plain; charset=utf-8
This is body of mail 👋
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | Plain text internal |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain text internal",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Basic message to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain text external
Content-Disposition: inline
Content-Type: text/plain; charset=utf-8
This is body of mail 👋
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | Plain text external |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain text external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Message without charset
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain text no charset external
Content-Disposition: inline
Content-Type: text/plain;
This is body of mail without charset. Please assume utf8
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | Plain text no charset external |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain text no charset external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Message without charset and content is detected as HTML
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain, no charset, no content, external
Content-Disposition: inline
Content-Type: text/plain;
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | Plain, no charset, no content, external |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain, no charset, no content, external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/html"
}
}
"""

View File

@ -0,0 +1,187 @@
Feature: SMTP sending of plain messages with attachments
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
Scenario: Basic message with attachment to internal account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Plain with attachment
Content-Type: multipart/related; boundary=bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
This is body of mail with attachment
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: attachment; filename=outline-light-instagram-48.png
Content-Id: <9114fe6f0adfaf7fdf7a@protonmail.com>
Content-Transfer-Encoding: base64
Content-Type: image/png
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAALVBMVEUAAAD/////////////////
//////////////////////////////////////+hSKubAAAADnRSTlMAgO8QQM+/IJ9gj1AwcIQd
OXUAAAGdSURBVDjLXJC9SgNBFIVPXDURTYhgIQghINgowyLYCAYtRFAIgtYhpAjYhC0srCRW6YIg
WNpoHVSsg/gEii+Qnfxq4DyDc3cyMfrBwl2+O+fOHTi8p7LS5RUf/9gpMKL7iT9sK47Q95ggpkzv
1cvRcsGYNMYsmP+zKN27NR2vcDyTNVdfkOuuniNPMWafvIbljt+YoMEvW8y7lt+ARwhvrgPjhA0I
BTng7S1GLPlypBvtIBPidY4YBDJFdtnkscQ5JGaGqxC9i7jSDwcwnB8qHWBaQjw1ABI8wYgtVoG6
9pFkH8iZIiJeulFt4JLvJq8I5N2GMWYbHWDWzM3JZTMdeSWla0kW86FcuI0mfStiNKQ/AhEeh8h0
YUTffFwrMTT5oSwdojIQ0UKcocgAKRH1HiqhFQmmJa5qRaYHNbRiSsOgslY0NdixItUTUWlZkedP
HXVyAgAIA1F0wP5btQZPIyTwvAqa/Fl4oacuP+e4XHAjSYpkQkxSiMX+T7FPoZJToSStzED70HCy
KE3NGCg4jJrC6Ti7AFwZLhnW0gMbzFZc0RmmeAAAAABJRU5ErkJggg==
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | Plain with attachment |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain with attachment",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "bridgetest@protonmail.com",
"Name": "Internal Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Plain message with attachment to external account
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge <pm.bridge.qa@gmail.com>
Subject: Plain with attachment external
Content-Type: multipart/related; boundary=bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
This is body of mail with attachment
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: attachment; filename=outline-light-instagram-48.png
Content-Id: <9114fe6f0adfaf7fdf7a@protonmail.com>
Content-Transfer-Encoding: base64
Content-Type: image/png
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAALVBMVEUAAAD/////////////////
//////////////////////////////////////+hSKubAAAADnRSTlMAgO8QQM+/IJ9gj1AwcIQd
OXUAAAGdSURBVDjLXJC9SgNBFIVPXDURTYhgIQghINgowyLYCAYtRFAIgtYhpAjYhC0srCRW6YIg
WNpoHVSsg/gEii+Qnfxq4DyDc3cyMfrBwl2+O+fOHTi8p7LS5RUf/9gpMKL7iT9sK47Q95ggpkzv
1cvRcsGYNMYsmP+zKN27NR2vcDyTNVdfkOuuniNPMWafvIbljt+YoMEvW8y7lt+ARwhvrgPjhA0I
BTng7S1GLPlypBvtIBPidY4YBDJFdtnkscQ5JGaGqxC9i7jSDwcwnB8qHWBaQjw1ABI8wYgtVoG6
9pFkH8iZIiJeulFt4JLvJq8I5N2GMWYbHWDWzM3JZTMdeSWla0kW86FcuI0mfStiNKQ/AhEeh8h0
YUTffFwrMTT5oSwdojIQ0UKcocgAKRH1HiqhFQmmJa5qRaYHNbRiSsOgslY0NdixItUTUWlZkedP
HXVyAgAIA1F0wP5btQZPIyTwvAqa/Fl4oacuP+e4XHAjSYpkQkxSiMX+T7FPoZJToSStzED70HCy
KE3NGCg4jJrC6Ti7AFwZLhnW0gMbzFZc0RmmeAAAAABJRU5ErkJggg==
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | Plain with attachment external |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain with attachment external",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge"
}
],
"CCList": [],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""
Scenario: Plain message with attachment to two external accounts
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: External Bridge 1 <pm.bridge.qa@gmail.com>
CC: External Bridge 2 <bridgeqa@seznam.cz>
Subject: Plain with attachment external PGP and external CC
Content-Type: multipart/mixed; boundary=bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: inline
Content-Transfer-Encoding: quoted-printable
Content-Type: text/plain; charset=utf-8
This is body of mail with attachment
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606
Content-Disposition: attachment; filename=outline-light-instagram-48.png
Content-Id: <9114fe6f0adfaf7fdf7a@protonmail.com>
Content-Transfer-Encoding: base64
Content-Type: image/png
iVBORw0KGgoAAAANSUhEUgAAADAAAAAwBAMAAAClLOS0AAAALVBMVEUAAAD/////////////////
//////////////////////////////////////+hSKubAAAADnRSTlMAgO8QQM+/IJ9gj1AwcIQd
OXUAAAGdSURBVDjLXJC9SgNBFIVPXDURTYhgIQghINgowyLYCAYtRFAIgtYhpAjYhC0srCRW6YIg
WNpoHVSsg/gEii+Qnfxq4DyDc3cyMfrBwl2+O+fOHTi8p7LS5RUf/9gpMKL7iT9sK47Q95ggpkzv
1cvRcsGYNMYsmP+zKN27NR2vcDyTNVdfkOuuniNPMWafvIbljt+YoMEvW8y7lt+ARwhvrgPjhA0I
BTng7S1GLPlypBvtIBPidY4YBDJFdtnkscQ5JGaGqxC9i7jSDwcwnB8qHWBaQjw1ABI8wYgtVoG6
9pFkH8iZIiJeulFt4JLvJq8I5N2GMWYbHWDWzM3JZTMdeSWla0kW86FcuI0mfStiNKQ/AhEeh8h0
YUTffFwrMTT5oSwdojIQ0UKcocgAKRH1HiqhFQmmJa5qRaYHNbRiSsOgslY0NdixItUTUWlZkedP
HXVyAgAIA1F0wP5btQZPIyTwvAqa/Fl4oacuP+e4XHAjSYpkQkxSiMX+T7FPoZJToSStzED70HCy
KE3NGCg4jJrC6Ti7AFwZLhnW0gMbzFZc0RmmeAAAAABJRU5ErkJggg==
--bc5bd30245232f31b6c976adcd59bb0069c9b13f986f9e40c2571bb80aa16606--
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | cc | subject |
| now | [userAddress] | pm.bridge.qa@gmail.com | bridgeqa@seznam.cz | Plain with attachment external PGP and external CC |
And message is sent with API call:
"""
{
"Message": {
"Subject": "Plain with attachment external PGP and external CC",
"Sender": {
"Name": "Bridge Test"
},
"ToList": [
{
"Address": "pm.bridge.qa@gmail.com",
"Name": "External Bridge 1"
}
],
"CCList": [
{
"Address": "bridgeqa@seznam.cz",
"Name": "External Bridge 2"
}
],
"BCCList": [],
"MIMEType": "text/plain"
}
}
"""

View File

@ -0,0 +1,45 @@
Feature: SMTP sending the same message twice
Background:
Given there is connected user "user"
And there is SMTP client logged in as "user"
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Hello
World
"""
Then SMTP response is "OK"
Scenario: The exact same message is not sent twice
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Hello
World
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | Hello |
Scenario: Slight change means different message and is sent twice
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
Subject: Hello.
World
"""
Then SMTP response is "OK"
And mailbox "Sent" for "user" has messages
| time | from | to | subject |
| now | [userAddress] | bridgetest@protonmail.com | Hello |
| now | [userAddress] | bridgetest@protonmail.com | Hello. |

View File

@ -0,0 +1,71 @@
Feature: SMTP sending two messages
Scenario: Send two messages in one connection
Given there is connected user "user"
And there is SMTP client logged in as "user"
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
hello
"""
Then SMTP response is "OK"
When SMTP client sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
world
"""
Then SMTP response is "OK"
Scenario: Send to two addresses
Given there is connected user "userMoreAddresses"
And there is "userMoreAddresses" in "split" address mode
And there is SMTP client "smtp1" logged in as "userMoreAddresses" with address "primary"
And there is SMTP client "smtp2" logged in as "userMoreAddresses" with address "secondary"
When SMTP client "smtp1" sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
hello
"""
Then SMTP response to "smtp1" is "OK"
When SMTP client "smtp2" sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
world
"""
Then SMTP response to "smtp2" is "OK"
Scenario: Send to two users
Given there is connected user "user"
And there is connected user "userMoreAddresses"
And there is SMTP client "smtp1" logged in as "user"
And there is SMTP client "smtp2" logged in as "userMoreAddresses"
When SMTP client "smtp1" sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
hello
"""
Then SMTP response to "smtp1" is "OK"
When SMTP client "smtp2" sends message
"""
From: Bridge Test <bridgetest@pm.test>
To: Internal Bridge <bridgetest@protonmail.com>
world
"""
Then SMTP response to "smtp2" is "OK"

View File

@ -0,0 +1,82 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func IMAPActionsAuthFeatureContext(s *godog.Suite) {
s.Step(`^IMAP client authenticates "([^"]*)"$`, imapClientAuthenticates)
s.Step(`^IMAP client "([^"]*)" authenticates "([^"]*)"$`, imapClientNamedAuthenticates)
s.Step(`^IMAP client authenticates "([^"]*)" with address "([^"]*)"$`, imapClientAuthenticatesWithAddress)
s.Step(`^IMAP client "([^"]*)" authenticates "([^"]*)" with address "([^"]*)"$`, imapClientNamedAuthenticatesWithAddress)
s.Step(`^IMAP client authenticates "([^"]*)" with bad password$`, imapClientAuthenticatesWithBadPassword)
s.Step(`^IMAP client authenticates with username "([^"]*)" and password "([^"]*)"$`, imapClientAuthenticatesWithUsernameAndPassword)
s.Step(`^IMAP client logs out$`, imapClientLogsOut)
}
func imapClientAuthenticates(bddUserID string) error {
return imapClientNamedAuthenticates("imap", bddUserID)
}
func imapClientNamedAuthenticates(clientID, bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
res := ctx.GetIMAPClient(clientID).Login(account.Address(), account.BridgePassword())
ctx.SetIMAPLastResponse(clientID, res)
return nil
}
func imapClientAuthenticatesWithAddress(bddUserID, bddAddressID string) error {
return imapClientNamedAuthenticatesWithAddress("imap", bddUserID, bddAddressID)
}
func imapClientNamedAuthenticatesWithAddress(clientID, bddUserID, bddAddressID string) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
res := ctx.GetIMAPClient(clientID).Login(account.Address(), account.BridgePassword())
ctx.SetIMAPLastResponse(clientID, res)
return nil
}
func imapClientAuthenticatesWithBadPassword(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
res := ctx.GetIMAPClient("imap").Login(account.Address(), "you shall not pass!")
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientAuthenticatesWithUsernameAndPassword(username, password string) error {
res := ctx.GetIMAPClient("imap").Login(username, password)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientLogsOut() error {
res := ctx.GetIMAPClient("imap").Logout()
ctx.SetIMAPLastResponse("imap", res)
return nil
}

View File

@ -0,0 +1,74 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func IMAPActionsMailboxFeatureContext(s *godog.Suite) {
s.Step(`^IMAP client creates mailbox "([^"]*)"$`, imapClientCreatesMailbox)
s.Step(`^IMAP client renames mailbox "([^"]*)" to "([^"]*)"$`, imapClientRenamesMailboxTo)
s.Step(`^IMAP client deletes mailbox "([^"]*)"$`, imapClientDeletesMailbox)
s.Step(`^IMAP client lists mailboxes$`, imapClientListsMailboxes)
s.Step(`^IMAP client selects "([^"]*)"$`, imapClientSelects)
s.Step(`^IMAP client gets info of "([^"]*)"$`, imapClientGetsInfoOf)
s.Step(`^IMAP client gets status of "([^"]*)"$`, imapClientGetsStatusOf)
}
func imapClientCreatesMailbox(mailboxName string) error {
res := ctx.GetIMAPClient("imap").CreateMailbox(mailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientRenamesMailboxTo(mailboxName, newMailboxName string) error {
res := ctx.GetIMAPClient("imap").RenameMailbox(mailboxName, newMailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientDeletesMailbox(mailboxName string) error {
res := ctx.GetIMAPClient("imap").DeleteMailbox(mailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientListsMailboxes() error {
res := ctx.GetIMAPClient("imap").ListMailboxes()
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientSelects(mailboxName string) error {
res := ctx.GetIMAPClient("imap").Select(mailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientGetsInfoOf(mailboxName string) error {
res := ctx.GetIMAPClient("imap").GetMailboxInfo(mailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientGetsStatusOf(mailboxName string) error {
res := ctx.GetIMAPClient("imap").GetMailboxStatus(mailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}

View File

@ -0,0 +1,158 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func IMAPActionsMessagesFeatureContext(s *godog.Suite) {
s.Step(`^IMAP client fetches "([^"]*)"$`, imapClientFetches)
s.Step(`^IMAP client fetches by UID "([^"]*)"$`, imapClientFetchesByUID)
s.Step(`^IMAP client searches for "([^"]*)"$`, imapClientSearchesFor)
s.Step(`^IMAP client deletes messages "([^"]*)"$`, imapClientDeletesMessages)
s.Step(`^IMAP client "([^"]*)" deletes messages "([^"]*)"$`, imapClientNamedDeletesMessages)
s.Step(`^IMAP client copies messages "([^"]*)" to "([^"]*)"$`, imapClientCopiesMessagesTo)
s.Step(`^IMAP client moves messages "([^"]*)" to "([^"]*)"$`, imapClientMovesMessagesTo)
s.Step(`^IMAP client creates message "([^"]*)" from "([^"]*)" to "([^"]*)" with body "([^"]*)" in "([^"]*)"$`, imapClientCreatesMessageFromToWithBody)
s.Step(`^IMAP client creates message "([^"]*)" from "([^"]*)" to address "([^"]*)" of "([^"]*)" with body "([^"]*)" in "([^"]*)"$`, imapClientCreatesMessageFromToAddressOfUserWithBody)
s.Step(`^IMAP client creates message "([^"]*)" from address "([^"]*)" of "([^"]*)" to "([^"]*)" with body "([^"]*)" in "([^"]*)"$`, imapClientCreatesMessageFromAddressOfUserToWithBody)
s.Step(`^IMAP client marks message "([^"]*)" as read$`, imapClientMarksMessageAsRead)
s.Step(`^IMAP client "([^"]*)" marks message "([^"]*)" as read$`, imapClientNamedMarksMessageAsRead)
s.Step(`^IMAP client marks message "([^"]*)" as unread$`, imapClientMarksMessageAsUnread)
s.Step(`^IMAP client "([^"]*)" marks message "([^"]*)" as unread$`, imapClientNamedMarksMessageAsUnread)
s.Step(`^IMAP client marks message "([^"]*)" as starred$`, imapClientMarksMessageAsStarred)
s.Step(`^IMAP client "([^"]*)" marks message "([^"]*)" as starred$`, imapClientNamedMarksMessageAsStarred)
s.Step(`^IMAP client marks message "([^"]*)" as unstarred$`, imapClientMarksMessageAsUnstarred)
s.Step(`^IMAP client "([^"]*)" marks message "([^"]*)" as unstarred$`, imapClientNamedMarksMessageAsUnstarred)
s.Step(`^IMAP client starts IDLE-ing$`, imapClientStartsIDLEing)
s.Step(`^IMAP client "([^"]*)" starts IDLE-ing$`, imapClientNamedStartsIDLEing)
}
func imapClientFetches(fetchRange string) error {
res := ctx.GetIMAPClient("imap").Fetch(fetchRange, "UID")
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientFetchesByUID(fetchRange string) error {
res := ctx.GetIMAPClient("imap").FetchUID(fetchRange, "UID")
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientSearchesFor(query string) error {
res := ctx.GetIMAPClient("imap").Search(query)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientDeletesMessages(messageRange string) error {
return imapClientNamedDeletesMessages("imap", messageRange)
}
func imapClientNamedDeletesMessages(imapClient, messageRange string) error {
res := ctx.GetIMAPClient(imapClient).Delete(messageRange)
ctx.SetIMAPLastResponse(imapClient, res)
return nil
}
func imapClientCopiesMessagesTo(messageRange, newMailboxName string) error {
res := ctx.GetIMAPClient("imap").Copy(messageRange, newMailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientMovesMessagesTo(messageRange, newMailboxName string) error {
res := ctx.GetIMAPClient("imap").Move(messageRange, newMailboxName)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientCreatesMessageFromToWithBody(subject, from, to, body, mailboxName string) error {
res := ctx.GetIMAPClient("imap").Append(mailboxName, subject, from, to, body)
ctx.SetIMAPLastResponse("imap", res)
return nil
}
func imapClientCreatesMessageFromToAddressOfUserWithBody(subject, from, bddAddressID, bddUserID, body, mailboxName string) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
return imapClientCreatesMessageFromToWithBody(subject, from, account.Address(), body, mailboxName)
}
func imapClientCreatesMessageFromAddressOfUserToWithBody(subject, bddAddressID, bddUserID, to, body, mailboxName string) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
return imapClientCreatesMessageFromToWithBody(subject, account.Address(), to, body, mailboxName)
}
func imapClientMarksMessageAsRead(messageRange string) error {
return imapClientNamedMarksMessageAsRead("imap", messageRange)
}
func imapClientNamedMarksMessageAsRead(imapClient, messageRange string) error {
res := ctx.GetIMAPClient(imapClient).MarkAsRead(messageRange)
ctx.SetIMAPLastResponse(imapClient, res)
return nil
}
func imapClientMarksMessageAsUnread(messageRange string) error {
return imapClientNamedMarksMessageAsUnread("imap", messageRange)
}
func imapClientNamedMarksMessageAsUnread(imapClient, messageRange string) error {
res := ctx.GetIMAPClient(imapClient).MarkAsUnread(messageRange)
ctx.SetIMAPLastResponse(imapClient, res)
return nil
}
func imapClientMarksMessageAsStarred(messageRange string) error {
return imapClientNamedMarksMessageAsStarred("imap", messageRange)
}
func imapClientNamedMarksMessageAsStarred(imapClient, messageRange string) error {
res := ctx.GetIMAPClient(imapClient).MarkAsStarred(messageRange)
ctx.SetIMAPLastResponse(imapClient, res)
return nil
}
func imapClientMarksMessageAsUnstarred(messageRange string) error {
return imapClientNamedMarksMessageAsUnstarred("imap", messageRange)
}
func imapClientNamedMarksMessageAsUnstarred(imapClient, messageRange string) error {
res := ctx.GetIMAPClient(imapClient).MarkAsUnstarred(messageRange)
ctx.SetIMAPLastResponse(imapClient, res)
return nil
}
func imapClientStartsIDLEing() error {
return imapClientNamedStartsIDLEing("imap")
}
func imapClientNamedStartsIDLEing(imapClient string) error {
res := ctx.GetIMAPClient(imapClient).StartIDLE()
ctx.SetIMAPLastResponse(imapClient, res)
return nil
}

122
test/imap_checks_test.go Normal file
View File

@ -0,0 +1,122 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"strconv"
"time"
"github.com/cucumber/godog"
"github.com/emersion/go-imap"
)
func IMAPChecksFeatureContext(s *godog.Suite) {
s.Step(`^IMAP response is "([^"]*)"$`, imapResponseIs)
s.Step(`^IMAP response to "([^"]*)" is "([^"]*)"$`, imapResponseNamedIs)
s.Step(`^IMAP response contains "([^"]*)"$`, imapResponseContains)
s.Step(`^IMAP response to "([^"]*)" contains "([^"]*)"$`, imapResponseNamedContains)
s.Step(`^IMAP response has (\d+) message(?:s)?$`, imapResponseHasNumberOfMessages)
s.Step(`^IMAP response to "([^"]*)" has (\d+) message(?:s)?$`, imapResponseNamedHasNumberOfMessages)
s.Step(`^IMAP client receives update marking message "([^"]*)" as read within (\d+) seconds$`, imapClientReceivesUpdateMarkingMessagesAsReadWithin)
s.Step(`^IMAP client "([^"]*)" receives update marking message "([^"]*)" as read within (\d+) seconds$`, imapClientNamedReceivesUpdateMarkingMessagesAsReadWithin)
s.Step(`^IMAP client receives update marking message "([^"]*)" as unread within (\d+) seconds$`, imapClientReceivesUpdateMarkingMessagesAsUnreadWithin)
s.Step(`^IMAP client "([^"]*)" receives update marking message "([^"]*)" as unread within (\d+) seconds$`, imapClientNamedReceivesUpdateMarkingMessagesAsUnreadWithin)
s.Step(`^IMAP client "([^"]*)" does not receive update for message "([^"]*)" within (\d+) seconds$`, imapClientDoesNotReceiveUpdateForMessageWithin)
}
func imapResponseIs(expectedResponse string) error {
return imapResponseNamedIs("imap", expectedResponse)
}
func imapResponseNamedIs(clientID, expectedResponse string) error {
res := ctx.GetIMAPLastResponse(clientID)
if expectedResponse == "OK" {
res.AssertOK()
} else {
res.AssertError(expectedResponse)
}
return ctx.GetTestingError()
}
func imapResponseContains(expectedResponse string) error {
return imapResponseNamedContains("imap", expectedResponse)
}
func imapResponseNamedContains(clientID, expectedResponse string) error {
res := ctx.GetIMAPLastResponse(clientID)
res.AssertSections(expectedResponse)
return ctx.GetTestingError()
}
func imapResponseHasNumberOfMessages(expectedCount int) error {
return imapResponseNamedHasNumberOfMessages("imap", expectedCount)
}
func imapResponseNamedHasNumberOfMessages(clientID string, expectedCount int) error {
res := ctx.GetIMAPLastResponse(clientID)
res.AssertSectionsCount(expectedCount)
return ctx.GetTestingError()
}
func imapClientReceivesUpdateMarkingMessagesAsReadWithin(messageUIDs string, seconds int) error {
return imapClientNamedReceivesUpdateMarkingMessagesAsReadWithin("imap", messageUIDs, seconds)
}
func imapClientNamedReceivesUpdateMarkingMessagesAsReadWithin(clientID, messageUIDs string, seconds int) error {
regexps := []string{}
iterateOverSeqSet(messageUIDs, func(messageUID string) {
regexps = append(regexps, `FETCH \(FLAGS \(.*\\Seen.*\) UID `+messageUID)
})
ctx.GetIMAPLastResponse(clientID).WaitForSections(time.Duration(seconds)*time.Second, regexps...)
return ctx.GetTestingError()
}
func imapClientReceivesUpdateMarkingMessagesAsUnreadWithin(messageUIDs string, seconds int) error {
return imapClientNamedReceivesUpdateMarkingMessagesAsUnreadWithin("imap", messageUIDs, seconds)
}
func imapClientNamedReceivesUpdateMarkingMessagesAsUnreadWithin(clientID, messageUIDs string, seconds int) error {
regexps := []string{}
iterateOverSeqSet(messageUIDs, func(messageUID string) {
// Golang does not support negative look ahead. Following complex regexp checks \Seen is not there.
regexps = append(regexps, `FETCH \(FLAGS \(([^S]|S[^e]|Se[^e]|See[^n])*\) UID `+messageUID)
})
ctx.GetIMAPLastResponse(clientID).WaitForSections(time.Duration(seconds)*time.Second, regexps...)
return ctx.GetTestingError()
}
func imapClientDoesNotReceiveUpdateForMessageWithin(clientID, messageUIDs string, seconds int) error {
regexps := []string{}
iterateOverSeqSet(messageUIDs, func(messageUID string) {
regexps = append(regexps, `FETCH.*UID `+messageUID)
})
ctx.GetIMAPLastResponse(clientID).WaitForNotSections(time.Duration(seconds)*time.Second, regexps...)
return ctx.GetTestingError()
}
func iterateOverSeqSet(seqSet string, callback func(string)) {
seq, err := imap.NewSeqSet(seqSet)
if err != nil {
panic(err)
}
for _, set := range seq.Set {
for i := set.Start; i <= set.Stop; i++ {
callback(strconv.Itoa(int(i)))
}
}
}

66
test/imap_setup_test.go Normal file
View File

@ -0,0 +1,66 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func IMAPSetupFeatureContext(s *godog.Suite) {
s.Step(`^there is IMAP client logged in as "([^"]*)"$`, thereIsIMAPClientLoggedInAs)
s.Step(`^there is IMAP client "([^"]*)" logged in as "([^"]*)"$`, thereIsIMAPClientNamedLoggedInAs)
s.Step(`^there is IMAP client logged in as "([^"]*)" with address "([^"]*)"$`, thereIsIMAPClientLoggedInAsWithAddress)
s.Step(`^there is IMAP client "([^"]*)" logged in as "([^"]*)" with address "([^"]*)"$`, thereIsIMAPClientNamedLoggedInAsWithAddress)
s.Step(`^there is IMAP client selected in "([^"]*)"$`, thereIsIMAPClientSelectedIn)
s.Step(`^there is IMAP client "([^"]*)" selected in "([^"]*)"$`, thereIsIMAPClientNamedSelectedIn)
}
func thereIsIMAPClientLoggedInAs(bddUserID string) error {
return thereIsIMAPClientNamedLoggedInAs("imap", bddUserID)
}
func thereIsIMAPClientNamedLoggedInAs(clientID, bddUserID string) (err error) {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.GetIMAPClient(clientID).Login(account.Address(), account.BridgePassword()).AssertOK()
return ctx.GetTestingError()
}
func thereIsIMAPClientLoggedInAsWithAddress(bddUserID, bddAddressID string) error {
return thereIsIMAPClientNamedLoggedInAsWithAddress("imap", bddUserID, bddAddressID)
}
func thereIsIMAPClientNamedLoggedInAsWithAddress(clientID, bddUserID, bddAddressID string) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
ctx.GetIMAPClient(clientID).Login(account.Address(), account.BridgePassword()).AssertOK()
return nil
}
func thereIsIMAPClientSelectedIn(mailboxName string) error {
return thereIsIMAPClientNamedSelectedIn("imap", mailboxName)
}
func thereIsIMAPClientNamedSelectedIn(clientID, mailboxName string) (err error) {
ctx.GetIMAPClient(clientID).Select(mailboxName).AssertOK()
return ctx.GetTestingError()
}

28
test/internal_error.go Normal file
View File

@ -0,0 +1,28 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import "fmt"
func internalError(err error, msg string, args ...interface{}) error {
if err == nil {
return nil
}
msg = fmt.Sprintf(msg, args...)
return fmt.Errorf("internal error: %s: %v", msg, err)
}

75
test/liveapi/calls.go Normal file
View File

@ -0,0 +1,75 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"fmt"
"github.com/nsf/jsondiff"
)
type fakeCall struct {
method string
path string
request []byte
}
func (cntrl *Controller) recordCall(method, path string, request []byte) {
cntrl.lock.Lock()
defer cntrl.lock.Unlock()
cntrl.calls = append(cntrl.calls, &fakeCall{
method: method,
path: path,
request: request,
})
}
func (cntrl *Controller) PrintCalls() {
fmt.Println("API calls:")
for idx, call := range cntrl.calls {
fmt.Printf("%02d: [%s] %s\n", idx+1, call.method, call.path)
if call.request != nil && string(call.request) != "null" {
fmt.Printf("\t%s\n", call.request)
}
}
}
func (cntrl *Controller) WasCalled(method, path string, expectedRequest []byte) bool {
for _, call := range cntrl.calls {
if call.method != method && call.path != path {
continue
}
diff, _ := jsondiff.Compare(call.request, expectedRequest, &jsondiff.Options{})
isSuperset := diff == jsondiff.FullMatch || diff == jsondiff.SupersetMatch
if isSuperset {
return true
}
}
return false
}
func (cntrl *Controller) GetCalls(method, path string) [][]byte {
requests := [][]byte{}
for _, call := range cntrl.calls {
if call.method == method && call.path == path {
requests = append(requests, call.request)
}
}
return requests
}

132
test/liveapi/cleanup.go Normal file
View File

@ -0,0 +1,132 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"time"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
func cleanup(client *pmapi.Client) error {
if err := cleanSystemFolders(client); err != nil {
return errors.Wrap(err, "failed to clean system folders")
}
if err := cleanCustomLables(client); err != nil {
return errors.Wrap(err, "failed to clean cusotm labels")
}
if err := cleanTrash(client); err != nil {
return errors.Wrap(err, "failed to clean trash")
}
return nil
}
func cleanSystemFolders(client *pmapi.Client) error {
for _, labelID := range []string{pmapi.InboxLabel, pmapi.SentLabel, pmapi.ArchiveLabel} {
for {
messages, total, err := client.ListMessages(&pmapi.MessagesFilter{
PageSize: 150,
LabelID: labelID,
})
if err != nil {
return errors.Wrap(err, "failed to list messages")
}
if total == 0 {
break
}
messageIDs := []string{}
for _, message := range messages {
messageIDs = append(messageIDs, message.ID)
}
if err := client.DeleteMessages(messageIDs); err != nil {
return errors.Wrap(err, "failed to delete messages")
}
if total == len(messages) {
break
}
}
}
return nil
}
func cleanCustomLables(client *pmapi.Client) error {
labels, err := client.ListLabels()
if err != nil {
return errors.Wrap(err, "failed to list labels")
}
for _, label := range labels {
if err := emptyFolder(client, label.ID); err != nil {
return errors.Wrap(err, "failed to empty label")
}
if err := client.DeleteLabel(label.ID); err != nil {
return errors.Wrap(err, "failed to delete label")
}
}
return nil
}
func cleanTrash(client *pmapi.Client) error {
for {
_, total, err := client.ListMessages(&pmapi.MessagesFilter{
PageSize: 1,
LabelID: pmapi.TrashLabel,
})
if err == nil && total == 0 {
break
}
err = emptyFolder(client, pmapi.TrashLabel)
if err == nil {
break
}
if err.Error() == "Folder or label is currently being emptied" {
time.Sleep(500 * time.Millisecond)
continue
}
return errors.Wrap(err, "failed to empty trash")
}
return nil
}
func emptyFolder(client *pmapi.Client, labelID string) error {
err := client.EmptyFolder(labelID, "")
if err != nil {
return err
}
for {
_, total, err := client.ListMessages(&pmapi.MessagesFilter{
PageSize: 1,
LabelID: labelID,
})
if err != nil {
return err
}
if total == 0 {
break
}
time.Sleep(1 * time.Second)
}
return nil
}

View File

@ -0,0 +1,62 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"fmt"
"net/http"
"os"
"sync"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
)
type Controller struct {
// Internal states.
lock *sync.RWMutex
calls []*fakeCall
pmapiByUsername map[string]*pmapi.Client
messageIDsByUsername map[string][]string
// State controlled by test.
noInternetConnection bool
}
func NewController() *Controller {
return &Controller{
lock: &sync.RWMutex{},
calls: []*fakeCall{},
pmapiByUsername: map[string]*pmapi.Client{},
messageIDsByUsername: map[string][]string{},
noInternetConnection: false,
}
}
func (cntrl *Controller) GetClient(userID string) *pmapi.Client {
cfg := &pmapi.ClientConfig{
AppVersion: fmt.Sprintf("Bridge_%s", os.Getenv("VERSION")),
ClientID: "bridge-test",
Transport: &fakeTransport{
cntrl: cntrl,
transport: http.DefaultTransport,
},
TokenManager: pmapi.NewTokenManager(),
}
return pmapi.NewClient(cfg, userID)
}

105
test/liveapi/labels.go Normal file
View File

@ -0,0 +1,105 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"fmt"
"strings"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
var systemLabelNameToID = map[string]string{ //nolint[gochecknoglobals]
"INBOX": pmapi.InboxLabel,
"Trash": pmapi.TrashLabel,
"Spam": pmapi.SpamLabel,
"All Mail": pmapi.AllMailLabel,
"Archive": pmapi.ArchiveLabel,
"Sent": pmapi.SentLabel,
"Drafts": pmapi.DraftLabel,
}
func (cntrl *Controller) AddUserLabel(username string, label *pmapi.Label) error {
client, ok := cntrl.pmapiByUsername[username]
if !ok {
return fmt.Errorf("user %s does not exist", username)
}
label.Exclusive = getLabelExclusive(label.Name)
label.Name = getLabelNameWithoutPrefix(label.Name)
label.Color = pmapi.LabelColors[0]
if _, err := client.CreateLabel(label); err != nil {
return errors.Wrap(err, "failed to create label")
}
return nil
}
func (cntrl *Controller) GetLabelIDs(username string, labelNames []string) ([]string, error) {
labelIDs := []string{}
for _, labelName := range labelNames {
labelID, err := cntrl.getLabelID(username, labelName)
if err != nil {
return nil, err
}
labelIDs = append(labelIDs, labelID)
}
return labelIDs, nil
}
func (cntrl *Controller) getLabelID(username, labelName string) (string, error) {
if labelID, ok := systemLabelNameToID[labelName]; ok {
return labelID, nil
}
client, ok := cntrl.pmapiByUsername[username]
if !ok {
return "", fmt.Errorf("user %s does not exist", username)
}
labels, err := client.ListLabels()
if err != nil {
return "", errors.Wrap(err, "failed to list labels")
}
exclusive := getLabelExclusive(labelName)
labelName = getLabelNameWithoutPrefix(labelName)
for _, label := range labels {
if label.Exclusive == exclusive && label.Name == labelName {
return label.ID, nil
}
}
return "", fmt.Errorf("label %s:%s does not exist", username, labelName)
}
func getLabelNameWithoutPrefix(name string) string {
if strings.HasPrefix(name, "Folders/") {
return strings.TrimPrefix(name, "Folders/")
}
if strings.HasPrefix(name, "Labels/") {
return strings.TrimPrefix(name, "Labels/")
}
return name
}
func getLabelExclusive(name string) int {
if strings.HasPrefix(name, "Folders/") {
return 1
}
return 0
}

134
test/liveapi/messages.go Normal file
View File

@ -0,0 +1,134 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"bytes"
"fmt"
"io"
"mime/multipart"
"net/http"
"strconv"
messageUtils "github.com/ProtonMail/proton-bridge/pkg/message"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/pkg/errors"
)
func (cntrl *Controller) AddUserMessage(username string, message *pmapi.Message) error {
client, ok := cntrl.pmapiByUsername[username]
if !ok {
return fmt.Errorf("user %s does not exist", username)
}
body, err := buildMessage(client, message)
if err != nil {
return errors.Wrap(err, "failed to build message")
}
req := &pmapi.ImportMsgReq{
AddressID: message.AddressID,
Body: body.Bytes(),
Unread: message.Unread,
Time: message.Time,
Flags: message.Flags,
LabelIDs: message.LabelIDs,
}
results, err := client.Import([]*pmapi.ImportMsgReq{req})
if err != nil {
return errors.Wrap(err, "failed to make an import")
}
for _, result := range results {
if result.Error != nil {
return errors.Wrap(result.Error, "failed to import message")
}
cntrl.messageIDsByUsername[username] = append(cntrl.messageIDsByUsername[username], result.MessageID)
}
return nil
}
func buildMessage(client *pmapi.Client, message *pmapi.Message) (*bytes.Buffer, error) {
if err := encryptMessage(client, message); err != nil {
return nil, errors.Wrap(err, "failed to encrypt message")
}
body := &bytes.Buffer{}
if err := buildMessageHeader(message, body); err != nil {
return nil, errors.Wrap(err, "failed to build message header")
}
if err := buildMessageBody(message, body); err != nil {
return nil, errors.Wrap(err, "failed to build message body")
}
return body, nil
}
func encryptMessage(client *pmapi.Client, message *pmapi.Message) error {
addresses, err := client.GetAddresses()
if err != nil {
return errors.Wrap(err, "failed to get address")
}
kr := addresses.ByID(message.AddressID).KeyRing()
if err = message.Encrypt(kr, nil); err != nil {
return errors.Wrap(err, "failed to encrypt message body")
}
return nil
}
func buildMessageHeader(message *pmapi.Message, body *bytes.Buffer) error {
header := messageUtils.GetHeader(message)
header.Set("Content-Type", "multipart/mixed; boundary="+messageUtils.GetBoundary(message))
header.Del("Content-Disposition")
header.Del("Content-Transfer-Encoding")
if err := http.Header(header).Write(body); err != nil {
return errors.Wrap(err, "failed to write header")
}
_, _ = body.WriteString("\r\n")
return nil
}
func buildMessageBody(message *pmapi.Message, body *bytes.Buffer) error {
mw := multipart.NewWriter(body)
if err := mw.SetBoundary(messageUtils.GetBoundary(message)); err != nil {
return errors.Wrap(err, "failed to set boundary")
}
bodyHeader := messageUtils.GetBodyHeader(message)
bodyHeader.Set("Content-Transfer-Encoding", "7bit")
part, err := mw.CreatePart(bodyHeader)
if err != nil {
return errors.Wrap(err, "failed to create message body part")
}
if _, err := io.WriteString(part, message.Body); err != nil {
return errors.Wrap(err, "failed to write message body")
}
_ = mw.Close()
return nil
}
func (cntrl *Controller) GetMessageID(username, messageIndex string) string {
idx, err := strconv.Atoi(messageIndex)
if err != nil {
panic(fmt.Sprintf("message index %s not found", messageIndex))
}
return cntrl.messageIDsByUsername[username][idx-1]
}

59
test/liveapi/transport.go Normal file
View File

@ -0,0 +1,59 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"io/ioutil"
"net/http"
"github.com/pkg/errors"
)
func (cntrl *Controller) TurnInternetConnectionOff() {
cntrl.noInternetConnection = true
}
func (cntrl *Controller) TurnInternetConnectionOn() {
cntrl.noInternetConnection = false
}
type fakeTransport struct {
cntrl *Controller
transport http.RoundTripper
}
func (t *fakeTransport) RoundTrip(req *http.Request) (*http.Response, error) {
if t.cntrl.noInternetConnection {
return nil, errors.New("no route to host")
}
body := []byte{}
if req.GetBody != nil {
bodyReader, err := req.GetBody()
if err != nil {
return nil, errors.Wrap(err, "failed to get body")
}
body, err = ioutil.ReadAll(bodyReader)
if err != nil {
return nil, errors.Wrap(err, "failed to read body")
}
}
t.cntrl.recordCall(req.Method, req.URL.Path, body)
return t.transport.RoundTrip(req)
}

66
test/liveapi/users.go Normal file
View File

@ -0,0 +1,66 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package liveapi
import (
"fmt"
"os"
"github.com/ProtonMail/proton-bridge/pkg/pmapi"
"github.com/cucumber/godog"
"github.com/pkg/errors"
)
func (cntrl *Controller) AddUser(user *pmapi.User, addresses *pmapi.AddressList, password string, twoFAEnabled bool) error {
if twoFAEnabled {
return godog.ErrPending
}
client := pmapi.NewClient(&pmapi.ClientConfig{
AppVersion: fmt.Sprintf("Bridge_%s", os.Getenv("VERSION")),
ClientID: "bridge-cntrl",
TokenManager: pmapi.NewTokenManager(),
}, user.ID)
authInfo, err := client.AuthInfo(user.Name)
if err != nil {
return errors.Wrap(err, "failed to get auth info")
}
auth, err := client.Auth(user.Name, password, authInfo)
if err != nil {
return errors.Wrap(err, "failed to auth user")
}
mailboxPassword, err := pmapi.HashMailboxPassword(password, auth.KeySalt)
if err != nil {
return errors.Wrap(err, "failed to hash mailbox password")
}
if _, err := client.Unlock(mailboxPassword); err != nil {
return errors.Wrap(err, "failed to unlock user")
}
if err := client.UnlockAddresses([]byte(mailboxPassword)); err != nil {
return errors.Wrap(err, "failed to unlock addresses")
}
if err := cleanup(client); err != nil {
return errors.Wrap(err, "failed to clean user")
}
cntrl.pmapiByUsername[user.Name] = client
return nil
}

50
test/main_test.go Normal file
View File

@ -0,0 +1,50 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"flag"
"os"
"testing"
"github.com/cucumber/godog"
"github.com/cucumber/godog/colors"
)
var opt = godog.Options{ //nolint[gochecknoglobals]
Output: colors.Colored(os.Stdout),
Format: "progress", // can define default values
}
func init() { //nolint[gochecknoinits]
godog.BindFlags("godog.", flag.CommandLine, &opt)
}
func TestMain(m *testing.M) {
flag.Parse()
opt.Paths = flag.Args()
status := godog.RunWithOptions("godogs", func(s *godog.Suite) {
FeatureContext(s)
}, opt)
if st := m.Run(); st > status {
status = st
}
os.Exit(status)
}

78
test/mocks/debug.go Normal file
View File

@ -0,0 +1,78 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
"fmt"
"os"
"strings"
"time"
"github.com/logrusorgru/aurora"
)
type debug struct {
verbosity int
reqTag string
}
func newDebug(reqTag string) *debug {
return &debug{
verbosity: verbosityLevelFromEnv(),
reqTag: reqTag,
}
}
func verbosityLevelFromEnv() int {
verbosityName := os.Getenv("VERBOSITY")
switch strings.ToLower(verbosityName) {
case "error", "fatal", "panic":
return 0
case "warning":
return 1
case "info":
return 2
case "debug", "trace":
return 3
}
return 2
}
func (d *debug) printReq(command string) {
if d.verbosity > 0 {
fmt.Println(aurora.Green(fmt.Sprintf("Req %s: %s", d.reqTag, command)))
}
}
func (d *debug) printRes(line string) {
if d.verbosity > 1 {
line = strings.ReplaceAll(line, "\n", "")
line = strings.ReplaceAll(line, "\r", "")
fmt.Println(aurora.Cyan(fmt.Sprintf("Res %s: %s", d.reqTag, line)))
}
}
func (d *debug) printErr(line string) {
fmt.Print(aurora.Bold(aurora.Red(fmt.Sprintf("Res %s: %s", d.reqTag, line))))
}
func (d *debug) printTime(diff time.Duration) {
if d.verbosity > 0 {
fmt.Println(aurora.Green(fmt.Sprintf("Time %s:%v", d.reqTag, diff)))
}
}

227
test/mocks/imap.go Normal file
View File

@ -0,0 +1,227 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
"bufio"
"fmt"
"net"
"sync"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type IMAPClient struct {
lock *sync.Mutex
debug *debug
t TestingT
reqTag string
reqIndex int // global request index for this client
conn net.Conn
response *bufio.Reader
idling bool
}
func NewIMAPClient(t TestingT, tag string, imapAddr string) *IMAPClient {
conn, err := net.Dial("tcp", imapAddr)
require.NoError(t, err)
response := bufio.NewReader(conn)
// Read first response to opening connection.
_, err = response.ReadString('\n')
assert.NoError(t, err)
return &IMAPClient{
lock: &sync.Mutex{},
debug: newDebug(tag),
t: t,
reqTag: tag,
reqIndex: 0,
conn: conn,
response: response,
}
}
func (c *IMAPClient) Close() {
c.lock.Lock()
defer c.lock.Unlock()
if c.idling {
c.StopIDLE()
}
_ = c.conn.Close()
}
func (c *IMAPClient) SendCommand(command string) *IMAPResponse {
c.lock.Lock()
defer c.lock.Unlock()
imapResponse := &IMAPResponse{t: c.t}
go imapResponse.sendCommand(c.reqTag, c.reqIndex, command, c.debug, c.conn, c.response)
c.reqIndex++
return imapResponse
}
// Auth
func (c *IMAPClient) Login(account, password string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("LOGIN %s %s", account, password))
}
func (c *IMAPClient) Logout() *IMAPResponse {
return c.SendCommand("LOGOUT")
}
// Mailboxes
func (c *IMAPClient) ListMailboxes() *IMAPResponse {
return c.SendCommand("LIST \"\" *")
}
func (c *IMAPClient) Select(mailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("SELECT \"%s\"", mailboxName)) //nolint[gosec]
}
func (c *IMAPClient) CreateMailbox(mailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("CREATE \"%s\"", mailboxName))
}
func (c *IMAPClient) DeleteMailbox(mailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("DELETE \"%s\"", mailboxName)) //nolint[gosec]
}
func (c *IMAPClient) RenameMailbox(mailboxName, newMailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("RENAME \"%s\" \"%s\"", mailboxName, newMailboxName))
}
func (c *IMAPClient) GetMailboxInfo(mailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("EXAMINE \"%s\"", mailboxName))
}
func (c *IMAPClient) GetMailboxStatus(mailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("STATUS \"%s\" (MESSAGES UNSEEN UIDNEXT UIDVALIDITY)", mailboxName))
}
// Messages
func (c *IMAPClient) FetchAllFlags() *IMAPResponse {
return c.FetchAll("flags")
}
func (c *IMAPClient) FetchAllSubjects() *IMAPResponse {
return c.FetchAll("body.peek[header.fields (subject)]")
}
func (c *IMAPClient) FetchAllHeaders() *IMAPResponse {
return c.FetchAll("body.peek[header]")
}
func (c *IMAPClient) FetchAllBodyStructures() *IMAPResponse {
return c.FetchAll("bodystructure")
}
func (c *IMAPClient) FetchAllSizes() *IMAPResponse {
return c.FetchAll("rfc822.size")
}
func (c *IMAPClient) FetchAllBodies() *IMAPResponse {
return c.FetchAll("rfc822")
}
func (c *IMAPClient) FetchAll(parts string) *IMAPResponse {
return c.Fetch("1:*", parts)
}
func (c *IMAPClient) Fetch(ids, parts string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("FETCH %s %s", ids, parts))
}
func (c *IMAPClient) FetchUID(ids, parts string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("UID FETCH %s %s", ids, parts))
}
func (c *IMAPClient) Search(query string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("SEARCH %s", query))
}
// Message
func (c *IMAPClient) Append(mailboxName, subject, from, to, body string) *IMAPResponse {
msg := fmt.Sprintf("Subject: %s\r\n", subject)
msg += fmt.Sprintf("From: %s\r\n", from)
msg += fmt.Sprintf("To: %s\r\n", to)
msg += "\r\n"
msg += body
msg += "\r\n"
cmd := fmt.Sprintf("APPEND \"%s\" (\\Seen) \"25-Mar-2021 00:30:00 +0100\" {%d}\r\n%s", mailboxName, len(msg), msg)
return c.SendCommand(cmd)
}
func (c *IMAPClient) Delete(ids string) *IMAPResponse {
return c.AddFlags(ids, "\\Deleted")
}
func (c *IMAPClient) Copy(ids, newMailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("COPY %s \"%s\"", ids, newMailboxName))
}
func (c *IMAPClient) Move(ids, newMailboxName string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("MOVE %s \"%s\"", ids, newMailboxName))
}
func (c *IMAPClient) MarkAsRead(ids string) *IMAPResponse {
return c.AddFlags(ids, "\\Seen")
}
func (c *IMAPClient) MarkAsUnread(ids string) *IMAPResponse {
return c.RemoveFlags(ids, "\\Seen")
}
func (c *IMAPClient) MarkAsStarred(ids string) *IMAPResponse {
return c.AddFlags(ids, "\\Flagged")
}
func (c *IMAPClient) MarkAsUnstarred(ids string) *IMAPResponse {
return c.RemoveFlags(ids, "\\Flagged")
}
func (c *IMAPClient) AddFlags(ids, flags string) *IMAPResponse {
return c.changeFlags(ids, flags, "+")
}
func (c *IMAPClient) RemoveFlags(ids, flags string) *IMAPResponse {
return c.changeFlags(ids, flags, "-")
}
func (c *IMAPClient) changeFlags(ids, flags, op string) *IMAPResponse {
return c.SendCommand(fmt.Sprintf("STORE %s %sflags (%s)", ids, op, flags))
}
// IDLE
func (c *IMAPClient) StartIDLE() *IMAPResponse {
c.idling = true
return c.SendCommand("IDLE")
}
func (c *IMAPClient) StopIDLE() {
c.idling = false
fmt.Fprintf(c.conn, "%s\r\n", "DONE")
}

199
test/mocks/imap_response.go Normal file
View File

@ -0,0 +1,199 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
"bufio"
"fmt"
"io"
"regexp"
"strings"
"time"
"github.com/pkg/errors"
a "github.com/stretchr/testify/assert"
)
type IMAPResponse struct {
t TestingT
err error
result string
sections []string
done bool
}
func (ir *IMAPResponse) sendCommand(reqTag string, reqIndex int, command string, debug *debug, conn io.Writer, response *bufio.Reader) {
defer func() { ir.done = true }()
tstart := time.Now()
commandID := fmt.Sprintf("%sO%0d", reqTag, reqIndex)
command = fmt.Sprintf("%s %s", commandID, command)
debug.printReq(command)
fmt.Fprintf(conn, "%s\r\n", command)
var section string
for {
line, err := response.ReadString('\n')
if err != nil {
ir.err = errors.Wrap(err, "read response failed")
debug.printErr(ir.err.Error() + "\n")
return
}
// Finishing line contains `commandID` following with status (`NO`, `BAD`, ...) and then message itself.
lineWithoutID := strings.Replace(line, commandID+" ", "", 1)
if strings.HasPrefix(line, commandID) && (strings.HasPrefix(lineWithoutID, "NO ") || strings.HasPrefix(lineWithoutID, "BAD ")) {
debug.printErr(line)
err := errors.New(strings.Trim(lineWithoutID, "\r\n"))
ir.err = errors.Wrap(err, "IMAP error")
return
} else if command != "" && len(line) == 0 {
err := errors.New("empty answer")
ir.err = errors.Wrap(err, "IMAP error")
debug.printErr(ir.err.Error() + "\n")
return
}
debug.printRes(line)
if strings.HasPrefix(line, "* ") { //nolint[gocritic]
if section != "" {
ir.sections = append(ir.sections, section)
}
section = line
} else if strings.HasPrefix(line, commandID) {
if section != "" {
ir.sections = append(ir.sections, section)
}
ir.result = line
break
} else {
section += line
}
}
debug.printTime(time.Since(tstart))
}
func (ir *IMAPResponse) wait() {
for {
if ir.done {
break
}
time.Sleep(50 * time.Millisecond)
}
}
func (ir *IMAPResponse) AssertOK() *IMAPResponse {
ir.wait()
a.NoError(ir.t, ir.err)
return ir
}
func (ir *IMAPResponse) AssertError(wantErrMsg string) *IMAPResponse {
ir.wait()
if ir.err == nil {
a.Fail(ir.t, "Expected error %s", wantErrMsg)
} else {
a.Regexp(ir.t, wantErrMsg, ir.err.Error(), "Expected error %s but got %s", wantErrMsg, ir.err)
}
return ir
}
func (ir *IMAPResponse) AssertSectionsCount(expectedCount int) *IMAPResponse {
ir.wait()
a.Equal(ir.t, expectedCount, len(ir.sections))
return ir
}
// AssertSectionsInOrder checks sections against regular expression in exact order.
// First regexp checks first section, second the second and so on. If there is
// more responses (sections) than expected regexps, that's OK.
func (ir *IMAPResponse) AssertSectionsInOrder(wantRegexps ...string) *IMAPResponse {
ir.wait()
if !a.True(ir.t,
len(ir.sections) >= len(wantRegexps),
"Wrong number of sections, want %v, got %v",
len(wantRegexps),
len(ir.sections),
) {
return ir
}
for idx, wantRegexp := range wantRegexps {
section := ir.sections[idx]
match, err := regexp.MatchString(wantRegexp, section)
if !a.NoError(ir.t, err) {
return ir
}
if !a.True(ir.t, match, "Section does not match given regex", section, wantRegexp) {
return ir
}
}
return ir
}
// AssertSections is similar to AssertSectionsInOrder but is not strict to the order.
// It means it just tries to find all "regexps" in the response.
func (ir *IMAPResponse) AssertSections(wantRegexps ...string) *IMAPResponse {
ir.wait()
for _, wantRegexp := range wantRegexps {
a.NoError(ir.t, ir.hasSectionRegexp(wantRegexp), "regexp %v not found", wantRegexp)
}
return ir
}
// WaitForSections is the same as AssertSections but waits for `timeout` before giving up.
func (ir *IMAPResponse) WaitForSections(timeout time.Duration, wantRegexps ...string) {
a.Eventually(ir.t, func() bool {
return ir.HasSections(wantRegexps...)
}, timeout, 50*time.Millisecond, "Wanted sections: %v\nSections: %v", wantRegexps, &ir.sections)
}
// WaitForNotSections is the opposite of WaitForSection: waits to not have the response.
func (ir *IMAPResponse) WaitForNotSections(timeout time.Duration, unwantedRegexps ...string) *IMAPResponse {
time.Sleep(timeout)
match := ir.HasSections(unwantedRegexps...)
a.False(ir.t, match, "Unwanted sections: %v\nSections: %v", unwantedRegexps, &ir.sections)
return ir
}
// HasSections is the same as AssertSections but only returns bool (do not uses testingT).
func (ir *IMAPResponse) HasSections(wantRegexps ...string) bool {
for _, wantRegexp := range wantRegexps {
if err := ir.hasSectionRegexp(wantRegexp); err != nil {
return false
}
}
return true
}
func (ir *IMAPResponse) hasSectionRegexp(wantRegexp string) error {
for _, section := range ir.sections {
match, err := regexp.MatchString(wantRegexp, section)
if err != nil {
return err
}
if match {
return nil
}
}
return errors.New("Section matching given regex not found")
}

182
test/mocks/smtp.go Normal file
View File

@ -0,0 +1,182 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
"bufio"
"bytes"
"fmt"
"io"
"net"
"net/mail"
"os"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type SMTPClient struct {
lock *sync.Mutex
debug *debug
t TestingT
conn net.Conn
response *bufio.Reader
address string
}
func NewSMTPClient(t TestingT, tag, smtpAddr string) *SMTPClient {
conn, err := net.Dial("tcp", smtpAddr)
require.NoError(t, err)
response := bufio.NewReader(conn)
// Read first response to opening connection.
_, err = response.ReadString('\n')
assert.NoError(t, err)
return &SMTPClient{
lock: &sync.Mutex{},
debug: newDebug(tag),
t: t,
conn: conn,
response: response,
}
}
func (c *SMTPClient) Close() {
c.lock.Lock()
defer c.lock.Unlock()
_ = c.conn.Close()
}
func (c *SMTPClient) SendCommands(commands ...string) *SMTPResponse {
c.lock.Lock()
defer c.lock.Unlock()
smtpResponse := &SMTPResponse{t: c.t}
for _, command := range commands {
tstart := time.Now()
c.debug.printReq(command)
fmt.Fprintf(c.conn, "%s\r\n", command)
message, err := c.response.ReadString('\n')
if err != nil {
smtpResponse.err = fmt.Errorf("read response failed: %v", err)
return smtpResponse
}
// Message contains code and message. Codes 4xx and 5xx are bad ones, except "500 Speak up".
if strings.HasPrefix(message, "4") || strings.HasPrefix(message, "5") {
c.debug.printErr(message)
err := errors.New(strings.Trim(message, "\r\n"))
smtpResponse.err = errors.Wrap(err, "SMTP error")
return smtpResponse
} else if command != "" && len(message) == 0 {
err := errors.New("empty answer")
smtpResponse.err = errors.Wrap(err, "SMTP error")
return smtpResponse
}
c.debug.printRes(message)
smtpResponse.result = message
c.debug.printTime(time.Since(tstart))
}
return smtpResponse
}
// Auth
func (c *SMTPClient) Login(account, password string) *SMTPResponse {
c.address = account
return c.SendCommands(
"HELO ATEIST.TEST",
"AUTH LOGIN",
base64(account),
base64(password),
)
}
func (c *SMTPClient) Logout() *SMTPResponse {
return c.SendCommands("QUIT")
}
// Sending
func (c *SMTPClient) EML(fileName, bcc string) *SMTPResponse {
f, err := os.Open(fileName) //nolint[gosec]
if err != nil {
panic(fmt.Errorf("smtp eml open: %s", err))
}
defer f.Close() //nolint[errcheck]
return c.SendMail(f, bcc)
}
func (c *SMTPClient) SendMail(r io.Reader, bcc string) *SMTPResponse {
var message, from string
var tos []string
if bcc != "" {
tos = append(tos, bcc)
}
scanner := bufio.NewScanner(r)
for scanner.Scan() {
line := string(bytes.Trim(scanner.Bytes(), "\r\n")) // Make sure no line ending is there.
message += line + "\r\n"
from = c.address
if from == "" && strings.HasPrefix(line, "From: ") {
if addr, err := mail.ParseAddress(line[6:]); err == nil {
from = addr.Address
}
}
if strings.HasPrefix(line, "To: ") || strings.HasPrefix(line, "CC: ") {
if addrs, err := mail.ParseAddressList(line[4:]); err == nil {
for _, addr := range addrs {
tos = append(tos, addr.Address)
}
}
}
}
if err := scanner.Err(); err != nil {
panic(fmt.Errorf("smtp eml scan: %s", err))
}
if from == "" {
panic(fmt.Errorf("smtp eml no from"))
}
if len(tos) == 0 {
panic(fmt.Errorf("smtp eml no to"))
}
commands := []string{
fmt.Sprintf("MAIL FROM:<%s>", from),
}
for _, to := range tos {
commands = append(commands, fmt.Sprintf("RCPT TO:<%s>", to))
}
commands = append(commands, "DATA", message+"\r\n.") // Message ending.
return c.SendCommands(commands...)
}

View File

@ -0,0 +1,42 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
a "github.com/stretchr/testify/assert"
)
type SMTPResponse struct {
t TestingT
err error
result string
}
func (sr *SMTPResponse) AssertOK() *SMTPResponse {
a.NoError(sr.t, sr.err)
return sr
}
func (sr *SMTPResponse) AssertError(wantErrMsg string) *SMTPResponse {
if sr.err == nil {
a.Fail(sr.t, "Expected error %s", wantErrMsg)
} else {
a.Regexp(sr.t, wantErrMsg, sr.err.Error(), "Expected error %s but got %s", wantErrMsg, sr.err)
}
return sr
}

23
test/mocks/testingt.go Normal file
View File

@ -0,0 +1,23 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
type TestingT interface {
Errorf(string, ...interface{})
FailNow()
}

26
test/mocks/utils.go Normal file
View File

@ -0,0 +1,26 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package mocks
import (
b64 "encoding/base64"
)
func base64(data string) (encoded string) {
return b64.StdEncoding.EncodeToString([]byte(data))
}

114
test/smtp_actions_test.go Normal file
View File

@ -0,0 +1,114 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"strings"
"github.com/cucumber/godog"
"github.com/cucumber/godog/gherkin"
)
func SMTPActionsAuthFeatureContext(s *godog.Suite) {
s.Step(`^SMTP client authenticates "([^"]*)"$`, smtpClientAuthenticates)
s.Step(`^SMTP client "([^"]*)" authenticates "([^"]*)"$`, smtpClientNamedAuthenticates)
s.Step(`^SMTP client authenticates "([^"]*)" with address "([^"]*)"$`, smtpClientAuthenticatesWithAddress)
s.Step(`^SMTP client "([^"]*)" authenticates "([^"]*)" with address "([^"]*)"$`, smtpClientNamedAuthenticatesWithAddress)
s.Step(`^SMTP client authenticates "([^"]*)" with bad password$`, smtpClientAuthenticatesWithBadPassword)
s.Step(`^SMTP client authenticates with username "([^"]*)" and password "([^"]*)"$`, smtpClientAuthenticatesWithUsernameAndPassword)
s.Step(`^SMTP client logs out$`, smtpClientLogsOut)
s.Step(`^SMTP client sends message$`, smtpClientSendsMessage)
s.Step(`^SMTP client sends EHLO$`, smtpClientSendsEHLO)
s.Step(`^SMTP client "([^"]*)" sends message$`, smtpClientNamedSendsMessage)
s.Step(`^SMTP client sends message with bcc "([^"]*)"$`, smtpClientSendsMessageWithBCC)
s.Step(`^SMTP client "([^"]*)" sends message with bcc "([^"]*)"$`, smtpClientNamedSendsMessageWithBCC)
}
func smtpClientAuthenticates(bddUserID string) error {
return smtpClientNamedAuthenticates("smtp", bddUserID)
}
func smtpClientNamedAuthenticates(clientID, bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
res := ctx.GetSMTPClient(clientID).Login(account.Address(), account.BridgePassword())
ctx.SetSMTPLastResponse(clientID, res)
return nil
}
func smtpClientAuthenticatesWithAddress(bddUserID, bddAddressID string) error {
return smtpClientNamedAuthenticatesWithAddress("smtp", bddUserID, bddAddressID)
}
func smtpClientNamedAuthenticatesWithAddress(clientID, bddUserID, bddAddressID string) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
res := ctx.GetSMTPClient(clientID).Login(account.Address(), account.BridgePassword())
ctx.SetSMTPLastResponse(clientID, res)
return nil
}
func smtpClientAuthenticatesWithBadPassword(bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
res := ctx.GetSMTPClient("smtp").Login(account.Address(), "you shall not pass!")
ctx.SetSMTPLastResponse("smtp", res)
return nil
}
func smtpClientAuthenticatesWithUsernameAndPassword(bddUserID, password string) error {
res := ctx.GetSMTPClient("smtp").Login(bddUserID, password)
ctx.SetSMTPLastResponse("smtp", res)
return nil
}
func smtpClientLogsOut() error {
res := ctx.GetSMTPClient("smtp").Logout()
ctx.SetSMTPLastResponse("smtp", res)
return nil
}
func smtpClientSendsMessage(message *gherkin.DocString) error {
return smtpClientNamedSendsMessage("smtp", message)
}
func smtpClientSendsEHLO() error {
res := ctx.GetSMTPClient("smtp").SendCommands("EHLO ateist.test")
ctx.SetSMTPLastResponse("smtp", res)
return nil
}
func smtpClientNamedSendsMessage(clientID string, message *gherkin.DocString) error {
return smtpClientNamedSendsMessageWithBCC(clientID, "", message)
}
func smtpClientSendsMessageWithBCC(bcc string, message *gherkin.DocString) error {
return smtpClientNamedSendsMessageWithBCC("smtp", bcc, message)
}
func smtpClientNamedSendsMessageWithBCC(clientID, bcc string, message *gherkin.DocString) error {
res := ctx.GetSMTPClient(clientID).SendMail(strings.NewReader(message.Content), bcc)
ctx.SetSMTPLastResponse(clientID, res)
return nil
}

41
test/smtp_checks_test.go Normal file
View File

@ -0,0 +1,41 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func SMTPChecksFeatureContext(s *godog.Suite) {
s.Step(`^SMTP response is "([^"]*)"$`, smtpResponseIs)
s.Step(`^SMTP response to "([^"]*)" is "([^"]*)"$`, smtpResponseNamedIs)
}
func smtpResponseIs(expectedResponse string) error {
return smtpResponseNamedIs("smtp", expectedResponse)
}
func smtpResponseNamedIs(clientID, expectedResponse string) error {
res := ctx.GetSMTPLastResponse(clientID)
if expectedResponse == "OK" {
res.AssertOK()
} else {
res.AssertError(expectedResponse)
}
return ctx.GetTestingError()
}

55
test/smtp_setup_test.go Normal file
View File

@ -0,0 +1,55 @@
// Copyright (c) 2020 Proton Technologies AG
//
// This file is part of ProtonMail Bridge.Bridge.
//
// ProtonMail 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.
//
// ProtonMail 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 ProtonMail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"github.com/cucumber/godog"
)
func SMTPSetupFeatureContext(s *godog.Suite) {
s.Step(`^there is SMTP client logged in as "([^"]*)"$`, thereIsSMTPClientLoggedInAs)
s.Step(`^there is SMTP client "([^"]*)" logged in as "([^"]*)"$`, thereIsSMTPClientNamedLoggedInAs)
s.Step(`^there is SMTP client logged in as "([^"]*)" with address "([^"]*)"$`, thereIsSMTPClientLoggedInAsWithAddress)
s.Step(`^there is SMTP client "([^"]*)" logged in as "([^"]*)" with address "([^"]*)"$`, thereIsSMTPClientNamedLoggedInAsWithAddress)
}
func thereIsSMTPClientLoggedInAs(bddUserID string) error {
return thereIsSMTPClientNamedLoggedInAs("smtp", bddUserID)
}
func thereIsSMTPClientNamedLoggedInAs(clientID, bddUserID string) error {
account := ctx.GetTestAccount(bddUserID)
if account == nil {
return godog.ErrPending
}
ctx.GetSMTPClient(clientID).Login(account.Address(), account.BridgePassword()).AssertOK()
return ctx.GetTestingError()
}
func thereIsSMTPClientLoggedInAsWithAddress(bddUserID, bddAddressID string) error {
return thereIsSMTPClientNamedLoggedInAsWithAddress("smtp", bddUserID, bddAddressID)
}
func thereIsSMTPClientNamedLoggedInAsWithAddress(clientID, bddUserID, bddAddressID string) error {
account := ctx.GetTestAccountWithAddress(bddUserID, bddAddressID)
if account == nil {
return godog.ErrPending
}
ctx.GetSMTPClient(clientID).Login(account.Address(), account.BridgePassword()).AssertOK()
return ctx.GetTestingError()
}

Some files were not shown because too many files have changed in this diff Show More