test(BRIDGE-131): Integration tests for messages from Proton <-> Gmail

This commit is contained in:
Gordana Zafirova
2024-10-09 12:29:42 +00:00
committed by Atanas Janeshliev
parent 040d887aae
commit b481ce2203
15 changed files with 7323 additions and 38 deletions

View File

@ -129,13 +129,15 @@ func getFeatureTags() string {
switch arguments := os.Args; arguments[len(arguments)-1] {
case "nightly":
tags = ""
tags = "~@gmail-integration"
case "smoke": // Currently this is just a placeholder, as there are no scenarios tagged with @smoke
tags = "@smoke"
case "black": // Currently this is just a placeholder, as there are no scenarios tagged with @smoke
tags = "~@skip-black"
case "gmail-integration":
tags = "@gmail-integration"
default:
tags = "~@regression && ~@smoke" // To exclude more add `&& ~@tag`
tags = "~@regression && ~@smoke && ~@gmail-integration" // To exclude more add `&& ~@tag`
}
return tags

118
tests/external_test.go Normal file
View File

@ -0,0 +1,118 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.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 (
"encoding/json"
"fmt"
"mime"
"net/mail"
"strings"
"github.com/ProtonMail/gluon/rfc822"
"github.com/ProtonMail/proton-bridge/v3/pkg/message"
"github.com/ProtonMail/proton-bridge/v3/pkg/message/parser"
GmailService "github.com/ProtonMail/proton-bridge/v3/tests/utils/gmail"
"github.com/cucumber/godog"
)
func (s *scenario) externalClientSendsTheFollowingMessageFromTo(from, to string, message *godog.DocString) error {
return GmailService.ExternalSendEmail(from, to, message)
}
func (s *scenario) externalClientFetchesTheFollowingMessage(subject, sender, state string) error {
err := eventually(func() error {
_, err := GmailService.FetchMessageBySubjectAndSender(subject, sender, state)
return err
})
return err
}
func (s *scenario) externalClientSeesMessageWithStructure(subject, sender, state string, message *godog.DocString) error {
err := eventually(func() error {
gmailMessage, err := GmailService.FetchMessageBySubjectAndSender(subject, sender, state)
if err != nil {
return err
}
var msgStruct MessageStruct
if err := json.Unmarshal([]byte(message.Content), &msgStruct); err != nil {
return err
}
parsedMessage, err := GmailService.GetRawMessage(gmailMessage)
if err != nil {
return err
}
var structs []MessageStruct
messageStruct := parseGmail(parsedMessage)
structs = append(structs, messageStruct)
return matchStructureRecursive(structs, msgStruct)
})
return err
}
func (s *scenario) externalClientDeletesAllMessages() {
GmailService.DeleteAllMessages()
}
func parseGmail(rawMsg string) MessageStruct {
msg, err := mail.ReadMessage(strings.NewReader(rawMsg))
if err != nil {
panic(err)
}
var dec mime.WordDecoder
decodedSubject, err := dec.DecodeHeader(msg.Header.Get("Subject"))
if err != nil {
decodedSubject = msg.Header.Get("Subject")
}
parser, err := parser.New(strings.NewReader(rawMsg))
if err != nil {
panic(fmt.Errorf("parser error: %e", err))
}
m, err := message.ParseWithParser(parser, true)
if err != nil {
panic(fmt.Errorf("parser with parser: %e", err))
}
var body string
switch {
case m.MIMEType == rfc822.TextPlain:
body = strings.TrimSpace(string(m.PlainBody))
case m.MIMEType == rfc822.MultipartMixed:
_, body, _ = strings.Cut(string(m.MIMEBody), "\r\n\r\n")
default:
body = strings.TrimSpace(string(m.RichBody))
}
// There might be an issue with the dates if we end up using them
return MessageStruct{
From: msg.Header.Get("From"),
To: msg.Header.Get("To"),
CC: msg.Header.Get("CC"),
BCC: msg.Header.Get("BCC"),
Subject: decodedSubject,
Date: msg.Header.Get("Date"),
Content: parseMessageSection([]byte(strings.TrimSpace(rawMsg)), strings.TrimSpace(body)),
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,283 @@
@gmail-integration
Feature: External sender to Proton recipient sending a plain text message
Background:
Given there exists an account with username "[user:user]" and password "password"
Then it succeeds
When bridge starts
And the user logs in with username "[user:user]" and password "password"
Then it succeeds
Scenario: Plain text message sent from External to Internal
Given external client sends the following message from "auto.bridge.qa@gmail.com" to "[user:user]@[domain]":
"""
From: <auto.bridge.qa@gmail.com>
To: <[user:user]@[domain]>
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 8bit
Subject: Hello World!
hello
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Inbox":
| from | to | subject | body |
| auto.bridge.qa@gmail.com | [user:user]@[domain] | Hello World! | hello |
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "auto.bridge.qa@gmail.com",
"to": "[user:user]@[domain]",
"subject": "Hello World!",
"content": {
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "hello"
}
}
"""
Scenario: Plain message with Foreign/Nonascii chars in Subject and Body from External to Internal
Given external client sends the following message from "auto.bridge.qa@gmail.com" to "[user:user]@[domain]":
"""
To: <[user:user]@[domain]>
From: Bridge Automation <auto.bridge.qa@gmail.com>
Subject: =?UTF-8?B?U3Vias61zq3Pgs+EIMK2IMOEIMOI?=
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 8bit
Subjεέςτ Ä È
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Inbox":
| from | to | subject | body |
| auto.bridge.qa@gmail.com | [user:user]@[domain] | Subjεέςτ Ä È | Subjεέςτ Ä È |
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "auto.bridge.qa@gmail.com",
"to": "[user:user]@[domain]",
"subject": "Subjεέςτ Ä È",
"content": {
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "Subjεέςτ Ä È"
}
}
"""
Scenario: Plain message with numbering/ordering in Body from External to Internal
Given external client sends the following message from "auto.bridge.qa@gmail.com" to "[user:user]@[domain]":
"""
To: <[user:user]@[domain]>
From: Bridge Automation <auto.bridge.qa@gmail.com>
Subject: Message with Numbering/Ordering in Body
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
**Ordering
* *Bullet*1
o Bullet 1.1
* Bullet 2
o Bullet 2.1
o *Bullet 2.2*
+ /Bullet 2.2.1/
o Bullet 2.3
* */Bullet 3/*
Numbering
1. *Number 1*
1. */Number/**1.1*
2. Number 2
1. */Number 2.1/*
2. Number 2.2
1. Number 2.2.1
3. Number 2.3
3. /Number 3/
End
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Inbox":
| from | to | subject |
| auto.bridge.qa@gmail.com | [user:user]@[domain] | Message with Numbering/Ordering in Body |
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "auto.bridge.qa@gmail.com",
"to": "[user:user]@[domain]",
"subject": "Message with Numbering/Ordering in Body",
"content": {
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "**Ordering\r\n\r\n* *Bullet*1\r\n o Bullet 1.1\r\n* Bullet 2\r\n o Bullet 2.1\r\n o *Bullet 2.2*\r\n + /Bullet 2.2.1/\r\n o Bullet 2.3\r\n* */Bullet 3/*\r\n\r\nNumbering\r\n\r\n1. *Number 1*\r\n 1. */Number/**1.1*\r\n2. Number 2\r\n\r\n 1. */Number 2.1/*\r\n 2. Number 2.2\r\n 1. Number 2.2.1\r\n 3. Number 2.3\r\n\r\n3. /Number 3/\r\n\r\nEnd"
}
}
"""
Scenario: Plain text message with multiple attachments from External to Internal
Given external client sends the following message from "auto.bridge.qa@gmail.com" to "[user:user]@[domain]":
"""
Content-Type: multipart/mixed; boundary="------------WI90RPIYF20K6dGXjs7dm2mi"
Subject: Plain message with different attachments
This is a multi-part message in MIME format.
--------------WI90RPIYF20K6dGXjs7dm2mi
Content-Type: text/plain; charset=UTF-8;
Content-Transfer-Encoding: 7bit
Hello, this is a Plain message with different attachments.
--------------WI90RPIYF20K6dGXjs7dm2mi
Content-Type: text/html; charset=UTF-8; name="index.html"
Content-Disposition: attachment; filename="index.html"
Content-Transfer-Encoding: base64
PCFET0NUWVBFIGh0bWw+
--------------WI90RPIYF20K6dGXjs7dm2mi
Content-Type: text/xml; charset=UTF-8; name="testxml.xml"
Content-Disposition: attachment; filename="testxml.xml"
Content-Transfer-Encoding: base64
PD94bWwgdmVyc2lvbj0iMS4xIj8+PCFET0NUWVBFIF9bPCFFTEVNRU5UIF8gRU1QVFk+XT48
Xy8+
--------------WI90RPIYF20K6dGXjs7dm2mi
Content-Type: text/plain; charset=UTF-8; name="text file.txt"
Content-Disposition: attachment; filename="text file.txt"
Content-Transfer-Encoding: base64
dGV4dCBmaWxl
--------------WI90RPIYF20K6dGXjs7dm2mi
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Inbox":
| from | to | subject |
| auto.bridge.qa@gmail.com | [user:user]@[domain] | Plain message with different attachments |
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "auto.bridge.qa@gmail.com",
"to": "[user:user]@[domain]",
"subject": "Plain message with different attachments",
"content": {
"content-type": "multipart/mixed",
"sections": [
{
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "Hello, this is a Plain message with different attachments."
},
{
"content-type": "text/plain",
"content-type-name": "text file.txt",
"content-disposition": "attachment",
"content-disposition-filename": "text file.txt",
"transfer-encoding": "base64",
"body-is": "dGV4dCBmaWxl"
},
{
"content-type": "text/html",
"content-type-name": "index.html",
"content-disposition": "attachment",
"content-disposition-filename": "index.html",
"transfer-encoding": "base64",
"body-is": "PCFET0NUWVBFIGh0bWw+"
},
{
"content-type": "text/xml",
"content-type-name": "testxml.xml",
"content-disposition": "attachment",
"content-disposition-filename": "testxml.xml",
"transfer-encoding": "base64",
"body-is": "PD94bWwgdmVyc2lvbj0iMS4xIj8+PCFET0NUWVBFIF9bPCFFTEVNRU5UIF8gRU1QVFk+XT48Xy8+"
}
]
}
}
"""
Scenario: Plain message with multiple inline images from External to Internal
Given external client sends the following message from "auto.bridge.qa@gmail.com" to "[user:user]@[domain]":
"""
To: <[user:user]@[domain]>
From: <auto.bridge.qa@gmail.com>
Subject: Plain message with multiple inline images to Internal
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Plain message with image 1 multiple image 2 inline image 3 images.
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Inbox":
| from | to | subject |
| auto.bridge.qa@gmail.com | [user:user]@[domain] | Plain message with multiple inline images to Internal |
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "auto.bridge.qa@gmail.com",
"to": "[user:user]@[domain]",
"subject": "Plain message with multiple inline images to Internal",
"content": {
"content-type": "text/plain",
"content-type-charset": "utf-8",
"transfer-encoding": "quoted-printable",
"body-is": "Plain message with image 1 multiple image 2 inline image 3 images.",
"body-contains": "",
"sections": []
}
}
"""
Scenario: Plain text message with a large attachment from External to Internal
Given external client sends the following message from "auto.bridge.qa@gmail.com" to "[user:user]@[domain]":
"""
Content-Type: multipart/mixed; boundary="------------k0Z3FJiZsGaSFqdJGsr0Oml6"
To: <[user:user]@[domain]>
From: Bridge Automation <auto.bridge.qa@gmail.com>
Subject: Plain message with a large attachment
This is a multi-part message in MIME format.
--------------k0Z3FJiZsGaSFqdJGsr0Oml6
Content-Type: text/plain; charset=UTF-8; format=flowed
Content-Transfer-Encoding: 7bit
Hello, this is a plain message with a large attachment.
--------------k0Z3FJiZsGaSFqdJGsr0Oml6
Content-Type: application/msword; name="testDoc.doc"
Content-Disposition: attachment; filename="testDoc.doc"
Content-Transfer-Encoding: base64
--------------k0Z3FJiZsGaSFqdJGsr0Oml6--
"""
Then it succeeds
When user "[user:user]" connects and authenticates IMAP client "1"
Then IMAP client "1" eventually sees the following messages in "Inbox":
| from | to | subject |
| auto.bridge.qa@gmail.com | [user:user]@[domain] | Plain message with a large attachment |
And IMAP client "1" eventually sees the following message in "Inbox" with this structure:
"""
{
"from": "auto.bridge.qa@gmail.com",
"to": "[user:user]@[domain]",
"subject": "Plain message with a large attachment",
"content": {
"content-type": "text/plain",
"content-type-charset": "utf-8"
}
}
"""

File diff suppressed because one or more lines are too long

View File

@ -190,6 +190,12 @@ func (s *scenario) steps(ctx *godog.ScenarioContext) {
ctx.Step(`^SMTP client "([^"]*)" sends the following EML "([^"]*)" from "([^"]*)" to "([^"]*)"$`, s.smtpClientSendsTheFollowingEmlFromTo)
ctx.Step(`^SMTP client "([^"]*)" logs out$`, s.smtpClientLogsOut)
// ==== EXTERNAL ====
ctx.Step(`^external client deletes all messages`, s.externalClientDeletesAllMessages)
ctx.Step(`^external client sends the following message from "([^"]*)" to "([^"]*)":$`, s.externalClientSendsTheFollowingMessageFromTo)
ctx.Step(`^external client fetches the following message with subject "([^"]*)" and sender "([^"]*)" and state "([^"]*)"$`, s.externalClientFetchesTheFollowingMessage)
ctx.Step(`^external client fetches the following message with subject "([^"]*)" and sender "([^"]*)" and state "([^"]*)" with this structure:$`, s.externalClientSeesMessageWithStructure)
// ==== TELEMETRY ====
ctx.Step(`^bridge eventually sends the following heartbeat:$`, s.bridgeEventuallySendsTheFollowingHeartbeat)
ctx.Step(`^bridge needs to send heartbeat`, s.bridgeNeedsToSendHeartbeat)

View File

@ -411,6 +411,89 @@ func matchContent(have MessageSection, want MessageSection) (bool, string) {
return true, ""
}
func matchStructureRecursive(have []MessageStruct, want MessageStruct) error {
mismatches := make([]string, 0)
for _, msg := range have {
if want.From != "" && msg.From != want.From {
mismatches = append(mismatches, "From")
continue
}
if want.To != "" && msg.To != want.To {
mismatches = append(mismatches, "To")
continue
}
if want.BCC != "" && msg.BCC != want.BCC {
mismatches = append(mismatches, "BCC")
continue
}
if want.CC != "" && msg.CC != want.CC {
mismatches = append(mismatches, "CC")
continue
}
if want.Subject != "" && msg.Subject != want.Subject {
mismatches = append(mismatches, "Subject")
continue
}
if want.Date != "" && want.Date != msg.Date {
mismatches = append(mismatches, "Date")
continue
}
if ok, mismatch := matchContentRecursive(msg.Content, want.Content); !ok {
mismatches = append(mismatches, "Content: "+mismatch)
continue
}
return nil
}
return fmt.Errorf("missing messages: have %#v, want %#v with mismatch list %#v", have, want, mismatches)
}
func matchContentRecursive(have MessageSection, want MessageSection) (bool, string) {
if want.ContentType != "" && !strings.EqualFold(want.ContentType, have.ContentType) {
return false, "ContentType"
}
if want.ContentTypeBoundary != "" && !strings.EqualFold(want.ContentTypeBoundary, have.ContentTypeBoundary) {
return false, "ContentTypeBoundary"
}
if want.ContentTypeCharset != "" && !strings.EqualFold(want.ContentTypeCharset, have.ContentTypeCharset) {
return false, "ContentTypeCharset"
}
if want.ContentTypeName != "" && !strings.EqualFold(want.ContentTypeName, have.ContentTypeName) {
return false, "ContentTypeName"
}
if want.ContentDisposition != "" && !strings.EqualFold(want.ContentDisposition, have.ContentDisposition) {
return false, "ContentDisposition"
}
if want.ContentDispositionFilename != "" && !strings.EqualFold(want.ContentDispositionFilename, have.ContentDispositionFilename) {
return false, "ContentDispositionFilename"
}
if want.TransferEncoding != "" && !strings.EqualFold(want.TransferEncoding, have.TransferEncoding) {
return false, "TransferEncoding"
}
if want.BodyContains != "" && !strings.Contains(strings.TrimSpace(have.BodyContains), strings.TrimSpace(want.BodyContains)) {
return false, "BodyContains"
}
if want.BodyIs != "" && strings.TrimSpace(have.BodyIs) != strings.TrimSpace(want.BodyIs) {
return false, "BodyIs"
}
for _, wantSection := range want.Sections {
didPass := false
for _, haveSection := range have.Sections {
ok, _ := matchContentRecursive(haveSection, wantSection)
if ok {
didPass = true
break
}
}
if !didPass {
return false, "recursive mismatch found"
}
}
return true, ""
}
type Mailbox struct {
Name string `bdd:"name"`
Total int `bdd:"total"`

View File

@ -0,0 +1,149 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.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"
"encoding/base64"
"fmt"
"log"
"github.com/ProtonMail/proton-bridge/v3/tests/utils/gmail/tokenservice"
"github.com/cucumber/godog"
"google.golang.org/api/gmail/v1"
"google.golang.org/api/option"
)
const DEBUG = false
const GmailUserID = "me"
func getGmailService() *gmail.Service {
ctx := context.Background()
gmailClient, err := tokenservice.LoadGmailClient(ctx)
if err != nil {
log.Fatalf("unable to retrieve gmail http client: %v", err)
}
gmailService, err := gmail.NewService(ctx, option.WithHTTPClient(gmailClient))
if err != nil {
log.Fatalf("Unable to retrieve Gmail client: %v", err)
}
return gmailService
}
func ExternalSendEmail(from, to string, message *godog.DocString) error {
srv := getGmailService()
var msg gmail.Message
msgStr := []byte(
"From: " + from + " \n" +
"To: " + to + " \n" +
message.Content)
msg.Raw = base64.URLEncoding.EncodeToString(msgStr)
_, err := srv.Users.Messages.Send(GmailUserID, &msg).Do()
if err != nil {
return err
}
return nil
}
func FetchMessageBySubjectAndSender(subject, sender, state string) (*gmail.Message, error) {
srv := getGmailService()
var q string
switch state {
case "read":
q = fmt.Sprintf("(is:read in:inbox OR in:spam) subject:%q from:%q newer:1", subject, sender)
case "unread":
q = fmt.Sprintf("(is:unread in:inbox OR in:spam) subject:%q from:%q newer:1", subject, sender)
default:
return nil, fmt.Errorf("invalid state argument, must be 'read' or 'unread'")
}
if DEBUG {
fmt.Println("Gmail API Query:", q)
}
r, err := srv.Users.Messages.List(GmailUserID).Q(q).Do()
if err != nil {
return nil, fmt.Errorf("unable to retrieve %s messages with subject: %q and sender: %q with error: %v", state, subject, sender, err)
}
if len(r.Messages) == 0 {
return nil, fmt.Errorf("no %s messages found with subject: %q and sender: %q", state, subject, sender)
}
newestMessageID := r.Messages[0].Id
newestMessage, err := srv.Users.Messages.Get(GmailUserID, newestMessageID).Do()
if err != nil {
return nil, fmt.Errorf("unable to retrieve details of the newest message: %v", err)
}
if DEBUG {
fmt.Println("Email Subject:", getEmailHeader(newestMessage, "Subject"))
fmt.Println("Email Sender:", getEmailHeader(newestMessage, "From"))
}
return newestMessage, nil
}
func getEmailHeader(message *gmail.Message, headerName string) string {
if message != nil && message.Payload != nil && message.Payload.Headers != nil {
for _, header := range message.Payload.Headers {
if header.Name == headerName {
return header.Value
}
}
}
return "Header not found"
}
func GetRawMessage(message *gmail.Message) (string, error) {
srv := getGmailService()
msg, err := srv.Users.Messages.Get(GmailUserID, message.Id).Format("raw").Do()
if err != nil {
return "", err
}
decodedMsg, err := base64.URLEncoding.DecodeString(msg.Raw)
return string(decodedMsg), err
}
func DeleteAllMessages() {
srv := getGmailService()
labels := []string{"INBOX", "SENT", "DRAFT", "SPAM", "TRASH"}
for _, label := range labels {
msgs, err := srv.Users.Messages.List(GmailUserID).LabelIds(label).Do()
if err != nil {
continue
}
for _, m := range msgs.Messages {
_ = srv.Users.Messages.Delete(GmailUserID, m.Id).Do()
}
}
}

View File

@ -0,0 +1,183 @@
// Copyright (c) 2024 Proton AG
//
// This file is part of Proton Mail Bridge.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 tokenservice
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"time"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"google.golang.org/api/gmail/v1"
)
const (
NexusTestingPwd = "NEXUS_TESTING_PWD"
NexusTestingUser = "NEXUS_TESTING_USER"
)
var nexusAccessVerificationURL = os.Getenv("NEXUS_ACCESS_VERIFICATION_URL")
var nexusCredentialsURL = os.Getenv("NEXUS_GMAIL_CREDENTIALS_URL")
var nexusTokenURL = os.Getenv("NEXUS_GMAIL_TOKEN_URL")
var gmailScopes = []string{gmail.GmailComposeScope, gmail.GmailInsertScope, gmail.GmailLabelsScope, gmail.MailGoogleComScope, gmail.GmailMetadataScope, gmail.GmailModifyScope, gmail.GmailSendScope}
func fetchBytes(url string) ([]byte, error) {
res, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("unable to fetch data from url %v: %v", url, err)
}
defer func() { _ = res.Body.Close() }()
body, err := io.ReadAll(res.Body)
if err != nil {
return nil, fmt.Errorf("unable to read response body for url %v: %v", url, err)
}
return body, nil
}
func fetchConfig() (*oauth2.Config, error) {
data, err := fetchBytes(nexusCredentialsURL)
if err != nil {
return nil, fmt.Errorf("unable to fetch credentials: %v", err)
}
config, err := google.ConfigFromJSON(data, gmailScopes...)
if err != nil {
return nil, fmt.Errorf("unable to parse credentials to gmail config: %v", err)
}
return config, err
}
func fetchToken() (*oauth2.Token, error) {
data, err := fetchBytes(nexusTokenURL)
if err != nil {
return nil, fmt.Errorf("unable to fetch token: %v", err)
}
token := &oauth2.Token{}
if err = json.Unmarshal(data, token); err != nil {
return nil, fmt.Errorf("error when unmarshaling token: %v", err)
}
return token, nil
}
func refreshToken(config *oauth2.Config, token *oauth2.Token) (*oauth2.Token, error) {
ctx := context.Background()
tokenSource := config.TokenSource(ctx, token)
newToken, err := tokenSource.Token()
if err != nil {
return nil, fmt.Errorf("error when refreshing access token: %v", err)
}
return newToken, nil
}
func getNexusCreds() string {
credentials := os.Getenv(NexusTestingUser) + ":" + os.Getenv(NexusTestingPwd)
return base64.StdEncoding.EncodeToString([]byte(credentials))
}
func pushToNexus(url string, data []byte) error {
req, err := http.NewRequest("PUT", url, bytes.NewBuffer(data))
if err != nil {
return fmt.Errorf("error creating put request to nexus: %v", err)
}
req.Header.Set("Content-Type", "application/json")
encodedCredentials := getNexusCreds()
req.Header.Set("Authorization", "Basic "+encodedCredentials)
client := http.Client{}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("error making put request to nexus: %v", err)
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return fmt.Errorf("unexpected status code when making put request to nexus: %v", resp.StatusCode)
}
return nil
}
func uploadTokenToNexus(token *oauth2.Token) error {
jsonData, err := json.Marshal(token)
if err != nil {
return fmt.Errorf("error when encoding access token to json: %v", err)
}
return pushToNexus(nexusTokenURL, jsonData)
}
func verifyNexusAccess() error {
return pushToNexus(nexusAccessVerificationURL, nil)
}
func checkTokenValidityAndRefresh(config *oauth2.Config, token *oauth2.Token) (*oauth2.Token, error) {
// Validate token (check if it has expired, or is 5 minutes from expiring) and refresh
timeTillExpiry := time.Until(token.Expiry)
if !token.Valid() || timeTillExpiry < 5*time.Minute {
token, err := refreshToken(config, token)
if err != nil {
return nil, err
}
if err = uploadTokenToNexus(token); err != nil {
return nil, fmt.Errorf("unable to upload token to nexus: %v", err)
}
}
return token, nil
}
func LoadGmailClient(ctx context.Context) (*http.Client, error) {
err := verifyNexusAccess()
if err != nil {
log.Fatalf("error occurred when verifying nexus access, check your credentials: %v", err)
}
config, err := fetchConfig()
if err != nil {
return nil, fmt.Errorf("issue obtaining oauth config: %v", err)
}
token, err := fetchToken()
if err != nil {
return nil, fmt.Errorf("issue obtaining oauth token: %v", err)
}
token, err = checkTokenValidityAndRefresh(config, token)
if err != nil {
return nil, fmt.Errorf("error checking token validity: %v", err)
}
client := config.Client(ctx, token)
return client, nil
}