Files
proton-bridge/tests/user_test.go
Leander Beernaert 4b5edd62d0 feat(GODT-2585): Only Start IMAP/SMTP once one user is loaded
Update ServerManager to follow the new expected behavior. The servers
will only be started when one user is active.

If all users are logged out or removed from the system, the servers will
stop.

If the network goes down, the servers will stop and resume once network
has been restored.
2023-05-11 16:27:54 +02:00

575 lines
16 KiB
Go

// Copyright (c) 2023 Proton AG
//
// This file is part of Proton Mail Bridge.
//
// Proton Mail Bridge is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Proton Mail Bridge is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Proton Mail Bridge. If not, see <https://www.gnu.org/licenses/>.
package tests
import (
"context"
"errors"
"fmt"
"net/mail"
"strings"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/go-proton-api"
"github.com/ProtonMail/gopenpgp/v2/crypto"
"github.com/ProtonMail/proton-bridge/v3/internal/bridge"
"github.com/ProtonMail/proton-bridge/v3/internal/events"
"github.com/ProtonMail/proton-bridge/v3/internal/vault"
"github.com/ProtonMail/proton-bridge/v3/pkg/algo"
"github.com/bradenaw/juniper/iterator"
"github.com/bradenaw/juniper/xslices"
"github.com/cucumber/godog"
"github.com/google/uuid"
)
func (s *scenario) thereExistsAnAccountWithUsernameAndPassword(username, password string) error {
return s.createUserAccount(username, password, false)
}
func (s *scenario) thereExistsAnAccountWithUsernameAndPasswordWithDisablePrimary(username, password string) error {
return s.createUserAccount(username, password, true)
}
func (s *scenario) theAccountHasAdditionalAddress(username, address string) error {
return s.addAdditionalAddressToAccount(username, address, false)
}
func (s *scenario) theAccountHasAdditionalDisabledAddress(username, address string) error {
return s.addAdditionalAddressToAccount(username, address, true)
}
func (s *scenario) theAccountHasAdditionalAddressWithoutKeys(username, address string) error {
userID := s.t.getUserByName(username).getUserID()
// Decrypt the user's encrypted ID for use with quark.
userDecID, err := s.t.runQuarkCmd(context.Background(), "encryption:id", "--decrypt", userID)
if err != nil {
return err
}
// Create the user's additional address.
if _, err := s.t.runQuarkCmd(
context.Background(),
"user:create:address",
string(userDecID),
s.t.getUserByID(userID).getUserPass(),
address,
); err != nil {
return err
}
return s.t.withClient(context.Background(), username, func(ctx context.Context, c *proton.Client) error {
addr, err := c.GetAddresses(ctx)
if err != nil {
return err
}
// Set the new address of the user.
s.t.getUserByID(userID).addAddress(addr[len(addr)-1].ID, address)
return nil
})
}
func (s *scenario) theAccountNoLongerHasAdditionalAddress(username, address string) error {
userID := s.t.getUserByName(username).getUserID()
addrID := s.t.getUserByName(username).getAddrID(address)
if err := s.t.withClient(context.Background(), username, func(ctx context.Context, c *proton.Client) error {
if err := c.DisableAddress(ctx, addrID); err != nil {
return err
}
return c.DeleteAddress(ctx, addrID)
}); err != nil {
return err
}
s.t.getUserByID(userID).remAddress(addrID)
return nil
}
func (s *scenario) theAccountHasCustomFolders(username string, count int) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return s.t.withClient(ctx, username, func(ctx context.Context, client *proton.Client) error {
for idx := 0; idx < count; idx++ {
if _, err := client.CreateLabel(ctx, proton.CreateLabelReq{
Name: uuid.NewString(),
Type: proton.LabelTypeFolder,
Color: "#f66",
}); err != nil {
return err
}
}
return nil
})
}
func (s *scenario) theAccountHasCustomLabels(username string, count int) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return s.t.withClient(ctx, username, func(ctx context.Context, client *proton.Client) error {
for idx := 0; idx < count; idx++ {
if _, err := client.CreateLabel(ctx, proton.CreateLabelReq{
Name: uuid.NewString(),
Type: proton.LabelTypeLabel,
Color: "#f66",
}); err != nil {
return err
}
}
return nil
})
}
func (s *scenario) theAccountHasTheFollowingCustomMailboxes(username string, table *godog.Table) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
type CustomMailbox struct {
Name string `bdd:"name"`
Type string `bdd:"type"`
}
wantMailboxes, err := unmarshalTable[CustomMailbox](table)
if err != nil {
return err
}
return s.t.withClient(ctx, username, func(ctx context.Context, client *proton.Client) error {
for _, wantMailbox := range wantMailboxes {
var labelType proton.LabelType
switch wantMailbox.Type {
case "folder":
labelType = proton.LabelTypeFolder
case "label":
labelType = proton.LabelTypeLabel
}
if _, err := client.CreateLabel(ctx, proton.CreateLabelReq{
Name: wantMailbox.Name,
Type: labelType,
Color: "#f66",
}); err != nil {
return err
}
}
return nil
})
}
func (s *scenario) theAddressOfAccountHasTheFollowingMessagesInMailbox(address, username, mailbox string, table *godog.Table) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
userID := s.t.getUserByName(username).getUserID()
addrID := s.t.getUserByName(username).getAddrID(address)
mboxID := s.t.getMBoxID(userID, mailbox)
wantMessages, err := unmarshalTable[Message](table)
if err != nil {
return err
}
return s.t.createMessages(ctx, username, addrID, xslices.Map(wantMessages, func(message Message) proton.ImportReq {
return proton.ImportReq{
Metadata: proton.ImportMetadata{
AddressID: addrID,
LabelIDs: []string{mboxID},
Unread: proton.Bool(message.Unread),
Flags: flagsForMailbox(mailbox),
},
Message: message.Build(),
}
}))
}
func (s *scenario) theAddressOfAccountHasMessagesInMailbox(address, username string, count int, mailbox string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
userID := s.t.getUserByName(username).getUserID()
addrID := s.t.getUserByName(username).getAddrID(address)
mboxID := s.t.getMBoxID(userID, mailbox)
return s.t.createMessages(ctx, username, addrID, iterator.Collect(iterator.Map(iterator.Counter(count), func(idx int) proton.ImportReq {
return proton.ImportReq{
Metadata: proton.ImportMetadata{
AddressID: addrID,
LabelIDs: []string{mboxID},
Flags: flagsForMailbox(mailbox),
},
Message: Message{
Subject: fmt.Sprintf("%d", idx),
To: fmt.Sprintf("%d@pm.me", idx),
From: fmt.Sprintf("%d@pm.me", idx),
Body: fmt.Sprintf("body %d", idx),
}.Build(),
}
})))
}
func flagsForMailbox(mailboxName string) proton.MessageFlag {
if strings.EqualFold(mailboxName, "Sent") {
return proton.MessageFlagSent
}
if strings.EqualFold(mailboxName, "Scheduled") {
return proton.MessageFlagScheduledSend
}
return proton.MessageFlagReceived
}
// accountDraftChanged changes the draft attributes, where draftIndex is
// similar to sequential ID i.e. 1 represents the first message of draft folder
// sorted by API creation time.
func (s *scenario) theFollowingFieldsWereChangedInDraftForAddressOfAccount(draftIndex int, address, username string, table *godog.Table) error {
wantMessages, err := unmarshalTable[Message](table)
if err != nil {
return err
}
if len(wantMessages) != 1 {
return fmt.Errorf("expected to have one row in table but got %d instead", len(wantMessages))
}
draftID, err := s.t.getDraftID(username, draftIndex)
if err != nil {
return fmt.Errorf("failed to get draft ID: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return s.t.withClient(ctx, username, func(ctx context.Context, c *proton.Client) error {
return s.t.withAddrKR(ctx, c, username, s.t.getUserByName(username).getAddrID(address), func(_ context.Context, addrKR *crypto.KeyRing) error {
var changes proton.DraftTemplate
if wantMessages[0].From != "" {
return fmt.Errorf("changing From address is not supported")
}
changes.Sender = &mail.Address{Address: address}
changes.MIMEType = rfc822.TextPlain
if wantMessages[0].To != "" {
changes.ToList = []*mail.Address{{Address: wantMessages[0].To}}
}
if wantMessages[0].CC != "" {
changes.CCList = []*mail.Address{{Address: wantMessages[0].CC}}
}
if wantMessages[0].BCC != "" {
changes.BCCList = []*mail.Address{{Address: wantMessages[0].BCC}}
}
if wantMessages[0].Subject != "" {
changes.Subject = wantMessages[0].Subject
}
if wantMessages[0].Body != "" {
changes.Body = wantMessages[0].Body
}
if _, err := c.UpdateDraft(ctx, draftID, addrKR, proton.UpdateDraftReq{Message: changes}); err != nil {
return fmt.Errorf("failed to update draft: %w", err)
}
return nil
})
})
}
func (s *scenario) drafAtIndexWasMovedToTrashForAddressOfAccount(draftIndex int, address, username string) error {
draftID, err := s.t.getDraftID(username, draftIndex)
if err != nil {
return fmt.Errorf("failed to get draft ID: %w", err)
}
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
return s.t.withClient(ctx, username, func(ctx context.Context, c *proton.Client) error {
return s.t.withAddrKR(ctx, c, username, s.t.getUserByName(username).getAddrID(address), func(_ context.Context, addrKR *crypto.KeyRing) error {
if err := c.UnlabelMessages(ctx, []string{draftID}, proton.DraftsLabel); err != nil {
return fmt.Errorf("failed to unlabel draft")
}
if err := c.LabelMessages(ctx, []string{draftID}, proton.TrashLabel); err != nil {
return fmt.Errorf("failed to label draft to trah")
}
return nil
})
})
}
func (s *scenario) userLogsInWithUsernameAndPassword(username, password string) error {
evtCh, cancel := s.t.bridge.GetEvents(events.SMTPServerReady{})
defer cancel()
userID, err := s.t.bridge.LoginFull(context.Background(), username, []byte(password), nil, nil)
if err != nil {
s.t.pushError(err)
} else {
// We need to wait for server to be up or we won't be able to connect. It should only happen once to avoid
// blocking on multiple Logins.
if !s.t.imapServerStarted {
<-evtCh
s.t.imapServerStarted = true
}
if userID != s.t.getUserByName(username).getUserID() {
return errors.New("user ID mismatch")
}
info, err := s.t.bridge.GetUserInfo(userID)
if err != nil {
return err
}
s.t.getUserByID(userID).setBridgePass(string(info.BridgePass))
}
return nil
}
func (s *scenario) userLogsOut(username string) error {
return s.t.bridge.LogoutUser(context.Background(), s.t.getUserByName(username).getUserID())
}
func (s *scenario) userIsDeleted(username string) error {
return s.t.bridge.DeleteUser(context.Background(), s.t.getUserByName(username).getUserID())
}
func (s *scenario) theAuthOfUserIsRevoked(username string) error {
return s.t.withClient(context.Background(), username, func(ctx context.Context, client *proton.Client) error {
return client.AuthRevokeAll(ctx)
})
}
func (s *scenario) userIsListedAndConnected(username string) error {
user, err := s.t.bridge.GetUserInfo(s.t.getUserByName(username).getUserID())
if err != nil {
return err
}
if user.Username != username {
return errors.New("user not listed")
}
if user.State != bridge.Connected {
return errors.New("user not connected")
}
return nil
}
func (s *scenario) userIsEventuallyListedAndConnected(username string) error {
return eventually(func() error {
return s.userIsListedAndConnected(username)
})
}
func (s *scenario) userIsListedButNotConnected(username string) error {
user, err := s.t.bridge.GetUserInfo(s.t.getUserByName(username).getUserID())
if err != nil {
return err
}
if user.Username != username {
return errors.New("user not listed")
}
if user.State == bridge.Connected {
return errors.New("user connected")
}
return nil
}
func (s *scenario) userIsNotListed(username string) error {
if _, err := s.t.bridge.QueryUserInfo(username); !errors.Is(err, bridge.ErrNoSuchUser) {
return errors.New("user listed")
}
return nil
}
func (s *scenario) userFinishesSyncing(username string) error {
return s.bridgeSendsSyncStartedAndFinishedEventsForUser(username)
}
func (s *scenario) userHasTelemetrySetTo(username string, telemetry int) error {
return s.t.withClientPass(context.Background(), username, s.t.getUserByName(username).userPass, func(ctx context.Context, c *proton.Client) error {
var req proton.SetTelemetryReq
req.Telemetry = proton.SettingsBool(telemetry)
_, err := c.SetUserSettingsTelemetry(ctx, req)
if err != nil {
return err
}
return nil
})
}
func (s *scenario) bridgePasswordOfUserIsChangedTo(username, bridgePassword string) error {
b, err := algo.B64RawDecode([]byte(bridgePassword))
if err != nil {
return errors.New("the password is not base64 encoded")
}
var setErr error
if err := s.t.vault.GetUser(
s.t.getUserByName(username).getUserID(),
func(user *vault.User) { setErr = user.SetBridgePass(b) },
); err != nil {
return err
}
return setErr
}
func (s *scenario) bridgePasswordOfUserIsEqualTo(username, bridgePassword string) error {
userInfo, err := s.t.bridge.QueryUserInfo(username)
if err != nil {
return err
}
readPassword := string(userInfo.BridgePass)
if readPassword != bridgePassword {
return fmt.Errorf("bridge password mismatch, expected '%v', got '%v'", bridgePassword, readPassword)
}
return nil
}
func (s *scenario) addAdditionalAddressToAccount(username, address string, disabled bool) error {
userID := s.t.getUserByName(username).getUserID()
// Decrypt the user's encrypted ID for use with quark.
userDecID, err := s.t.runQuarkCmd(context.Background(), "encryption:id", "--decrypt", userID)
if err != nil {
return err
}
args := []string{
"--gen-keys", "RSA2048",
}
if disabled {
args = append(args, "--status", "1")
}
args = append(args,
string(userDecID),
s.t.getUserByID(userID).getUserPass(),
address,
)
// Create the user's additional address.
if _, err := s.t.runQuarkCmd(
context.Background(),
"user:create:address",
args...,
); err != nil {
return err
}
return s.t.withClient(context.Background(), username, func(ctx context.Context, c *proton.Client) error {
addr, err := c.GetAddresses(ctx)
if err != nil {
return err
}
// Set the new address of the user.
s.t.getUserByID(userID).addAddress(addr[len(addr)-1].ID, address)
return nil
})
}
func (s *scenario) createUserAccount(username, password string, disabled bool) error {
// Create the user and generate its default address (with keys).
args := []string{
"--name", username,
"--password", password,
"--gen-keys", "RSA2048",
}
if disabled {
args = append(args, "--status", "1")
}
if _, err := s.t.runQuarkCmd(
context.Background(),
"user:create",
args...,
); err != nil {
return err
}
return s.t.withClientPass(context.Background(), username, password, func(ctx context.Context, c *proton.Client) error {
user, err := c.GetUser(ctx)
if err != nil {
return err
}
// Decrypt the user's encrypted ID for use with quark.
userDecID, err := s.t.runQuarkCmd(context.Background(), "encryption:id", "--decrypt", user.ID)
if err != nil {
return err
}
// Upgrade the user to a paid account.
if _, err := s.t.runQuarkCmd(
context.Background(),
"user:create:subscription",
"--planID", "plus",
string(userDecID),
); err != nil {
return err
}
addr, err := c.GetAddresses(ctx)
if err != nil {
return err
}
// Add the test user.
s.t.addUser(user.ID, username, password)
// Set the address of the user.
s.t.getUserByID(user.ID).addAddress(addr[0].ID, addr[0].Email)
return nil
})
}