Add trust center OAuth connect e2e coverage

Extend test helpers for portal OAuth flows and cover connect,
callback, and NDA signing against the compliance portal API.

Signed-off-by: Bryan Frimin <bryan@probo.com>
This commit is contained in:
Bryan Frimin
2026-07-15 10:59:43 +02:00
parent 27e012e47a
commit de203325d2
5 changed files with 393 additions and 54 deletions

View File

@@ -24,9 +24,11 @@ import (
"crypto/rand" "crypto/rand"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"io"
"net/http" "net/http"
"net/http/cookiejar" "net/http/cookiejar"
"net/url" "net/url"
"strings"
"testing" "testing"
"time" "time"
@@ -53,16 +55,19 @@ const (
) )
type Client struct { type Client struct {
T testing.TB T testing.TB
httpClient *http.Client httpClient *http.Client
baseURL string proboHTTPClient *http.Client
mailpitBaseURL string trustClient *http.Client
role TestRole trustHost string
userID gid.GID baseURL string
profileID gid.GID mailpitBaseURL string
organizationID gid.GID role TestRole
email string userID gid.GID
password string profileID gid.GID
organizationID gid.GID
email string
password string
} }
func NewClient(t testing.TB, role TestRole) *Client { func NewClient(t testing.TB, role TestRole) *Client {
@@ -544,11 +549,7 @@ func NewClientWithNewSession(t testing.TB, from *Client) *Client {
return client return client
} }
// SelfProvisionTrustCenterVisitor signs a brand-new email up as a trust func SelfProvisionTrustCenterVisitor(t testing.TB, trustHost string) *Client {
// center visitor through the public magic-link flow (send + verify), the
// same path a real visitor takes. It returns a Client bound to that
// visitor's own session and cookie jar, scoped to no organization.
func SelfProvisionTrustCenterVisitor(t testing.TB, trustCenterID string) *Client {
t.Helper() t.Helper()
jar, err := cookiejar.New(nil) jar, err := cookiejar.New(nil)
@@ -561,47 +562,170 @@ func SelfProvisionTrustCenterVisitor(t testing.TB, trustCenterID string) *Client
baseURL: GetBaseURL(), baseURL: GetBaseURL(),
mailpitBaseURL: GetMailpitBaseURL(), mailpitBaseURL: GetMailpitBaseURL(),
email: email, email: email,
trustHost: trustHost,
httpClient: &http.Client{ httpClient: &http.Client{
Jar: jar, Jar: jar,
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
}, },
trustClient: trustHTTPClientWithJar(trustHost, jar),
proboHTTPClient: &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
},
} }
visitor.sendMagicLink(trustCenterID, email) visitor.connectViaCIMD(email)
token := visitor.pollForLinkToken(fmt.Sprintf("to:%s", email))
visitor.verifyMagicLink(trustCenterID, token)
return visitor return visitor
} }
func (c *Client) sendMagicLink(trustCenterID, email string) { func (c *Client) connectViaCIMD(email string) {
const query = ` c.T.Helper()
mutation($input: SendMagicLinkInput!) {
sendMagicLink(input: $input) {
success
}
}
`
err := c.ExecuteTrust(trustCenterID, query, map[string]any{ WaitForTrustCenterHTTPS(c.T, c.trustHost)
"input": map[string]any{"email": email},
}, nil) initiateURL := fmt.Sprintf(
require.NoError(c.T, err, "sendMagicLink mutation failed") "https://%s/initiate?continue=/overview",
c.trustHost,
)
authorizeURL := c.redirectLocation(c.trustClient, initiateURL)
require.NotEmpty(c.T, authorizeURL, "oauth initiate must redirect to authorize")
portalLoginURL := c.redirectLocation(c.proboHTTPClient, authorizeURL)
require.Contains(c.T, portalLoginURL, "/auth/portal-login", "unauthenticated authorize must redirect to portal login")
authorizeParam := extractAuthorizeQueryParam(portalLoginURL)
require.NotEmpty(c.T, authorizeParam)
c.postConnectMagicLink(email, authorizeParam)
token := c.pollForLinkToken(fmt.Sprintf("to:%s", email))
verifyURL := c.baseURL + "/api/connect/v1/magic-link/verify?token=" + url.QueryEscape(token)
resumeAuthorizeURL := c.redirectLocation(c.proboHTTPClient, verifyURL)
require.Contains(c.T, resumeAuthorizeURL, "/api/connect/v1/oauth2/authorize")
authorizeResp := c.redirectHTTPResponse(c.proboHTTPClient, resumeAuthorizeURL)
require.False(
c.T,
IsConsentRedirect(authorizeResp),
"compliance portal CIMD must skip oauth consent screen",
)
require.Equal(c.T, http.StatusFound, authorizeResp.StatusCode)
_, err := OAuth2AuthorizeCodeFromRedirect(authorizeResp)
require.NoError(c.T, err, "compliance portal CIMD must issue authorization code without consent")
callbackURL := resolveRedirectURL(resumeAuthorizeURL, authorizeResp.Header.Get("Location"))
require.Contains(c.T, callbackURL, "/callback")
finalURL := c.redirectLocation(c.trustClient, callbackURL)
require.True(
c.T,
strings.HasSuffix(finalURL, "/overview") || strings.Contains(finalURL, "/overview"),
"oauth callback must redirect to continue URL, got %q",
finalURL,
)
} }
func (c *Client) verifyMagicLink(trustCenterID, token string) { func (c *Client) postConnectMagicLink(email, authorizeParam string) {
const query = ` c.T.Helper()
mutation($input: VerifyMagicLinkInput!) {
verifyMagicLink(input: $input) {
continue
}
}
`
err := c.ExecuteTrust(trustCenterID, query, map[string]any{ body := url.Values{}
"input": map[string]any{"token": token}, body.Set("email", email)
}, nil) body.Set("authorize", authorizeParam)
require.NoError(c.T, err, "verifyMagicLink mutation failed")
req, err := http.NewRequest(
"POST",
c.baseURL+"/api/connect/v1/magic-link/send",
strings.NewReader(body.Encode()),
)
require.NoError(c.T, err)
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := c.proboHTTPClient.Do(req)
require.NoError(c.T, err, "magic-link send request failed")
defer func() { _ = resp.Body.Close() }()
require.Equal(c.T, http.StatusNoContent, resp.StatusCode, "magic-link send must return 204")
}
func (c *Client) redirectLocation(client *http.Client, rawURL string) string {
c.T.Helper()
resp := c.redirectHTTPResponse(client, rawURL)
require.True(
c.T,
resp.StatusCode >= http.StatusMultipleChoices && resp.StatusCode < http.StatusBadRequest,
"expected redirect from %s, got %d",
rawURL,
resp.StatusCode,
)
location := resp.Header.Get("Location")
require.NotEmpty(c.T, location, "redirect from %s missing Location header", rawURL)
return resolveRedirectURL(rawURL, location)
}
func (c *Client) redirectHTTPResponse(client *http.Client, rawURL string) *OAuth2HTTPResponse {
c.T.Helper()
noRedirectClient := &http.Client{
Jar: client.Jar,
Timeout: client.Timeout,
Transport: client.Transport,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return http.ErrUseLastResponse
},
}
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
require.NoError(c.T, err)
resp, err := noRedirectClient.Do(req)
require.NoError(c.T, err, "request to %s failed", rawURL)
defer func() { _ = resp.Body.Close() }()
body, err := io.ReadAll(resp.Body)
require.NoError(c.T, err)
return &OAuth2HTTPResponse{
StatusCode: resp.StatusCode,
Header: resp.Header,
Body: body,
}
}
func resolveRedirectURL(baseURL, location string) string {
locURL, err := url.Parse(location)
if err != nil {
return location
}
if locURL.IsAbs() {
return locURL.String()
}
base, err := url.Parse(baseURL)
if err != nil {
return location
}
return base.ResolveReference(locURL).String()
}
func extractAuthorizeQueryParam(portalLoginURL string) string {
parsed, err := url.Parse(portalLoginURL)
if err != nil {
return ""
}
return parsed.Query().Get("authorize")
} }
func (c *Client) GetEmail() string { func (c *Client) GetEmail() string {

View File

@@ -219,9 +219,14 @@ func ConsoleGraphQLWithAccessToken(
// page's host as TLS SNI. Certificates are Pebble-issued for e2e, so // page's host as TLS SNI. Certificates are Pebble-issued for e2e, so
// verification is skipped. // verification is skipped.
func trustHTTPClient(serverName string) *http.Client { func trustHTTPClient(serverName string) *http.Client {
return trustHTTPClientWithJar(serverName, nil)
}
func trustHTTPClientWithJar(serverName string, jar http.CookieJar) *http.Client {
dialer := &net.Dialer{Timeout: 5 * time.Second} dialer := &net.Dialer{Timeout: 5 * time.Second}
return &http.Client{ return &http.Client{
Jar: jar,
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: &http.Transport{ Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
@@ -235,6 +240,36 @@ func trustHTTPClient(serverName string) *http.Client {
} }
} }
// WaitForTrustCenterHTTPS blocks until the dedicated trust-center listener
// serves the page over TLS. Managed domains provision certificates
// asynchronously after activation.
func WaitForTrustCenterHTTPS(t testing.TB, host string) {
t.Helper()
client := TrustHTTPClient(host)
require.Eventually(
t,
func() bool {
resp, err := client.Get("https://" + host + complianceportalOAuthMetadataPath())
if err != nil {
return false
}
defer func() { _ = resp.Body.Close() }()
return resp.StatusCode == http.StatusOK
},
30*time.Second,
500*time.Millisecond,
"trust center did not become servable on the dedicated listener",
)
}
func complianceportalOAuthMetadataPath() string {
return "/.well-known/oauth-client-metadata"
}
// DoTrust posts a GraphQL query to a compliance page served on the dedicated // DoTrust posts a GraphQL query to a compliance page served on the dedicated
// listener. host is the page's serving domain (a customer custom domain or a // listener. host is the page's serving domain (a customer custom domain or a
// managed {slug}.probopage.localhost subdomain). // managed {slug}.probopage.localhost subdomain).
@@ -249,7 +284,7 @@ func (c *Client) DoTrust(host string, query string, variables map[string]any) (*
return nil, fmt.Errorf("cannot marshal request: %w", err) return nil, fmt.Errorf("cannot marshal request: %w", err)
} }
endpoint := fmt.Sprintf("https://%s/api/trust/v1/graphql", host) endpoint := fmt.Sprintf("https://%s/graphql", host)
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body)) req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil { if err != nil {
@@ -258,7 +293,12 @@ func (c *Client) DoTrust(host string, query string, variables map[string]any) (*
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")
resp, err := trustHTTPClient(host).Do(req) client := trustHTTPClient(host)
if c.trustClient != nil && host == c.trustHost {
client = c.trustClient
}
resp, err := client.Do(req)
if err != nil { if err != nil {
return nil, fmt.Errorf("request failed: %w", err) return nil, fmt.Errorf("request failed: %w", err)
} }
@@ -353,6 +393,10 @@ func (c *Client) BaseURL() string {
return c.baseURL return c.baseURL
} }
func TrustHTTPClient(trustHost string) *http.Client {
return trustHTTPClient(trustHost)
}
type UploadFile struct { type UploadFile struct {
Filename string Filename string
ContentType string ContentType string

View File

@@ -21,6 +21,7 @@
package trust_test package trust_test
import ( import (
"net/url"
"testing" "testing"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
@@ -58,6 +59,42 @@ func lookupTrustCenterID(t *testing.T, owner *testutil.Client) string {
return result.Node.TrustCenter.ID return result.Node.TrustCenter.ID
} }
func lookupTrustHost(t *testing.T, owner *testutil.Client, trustCenterID string) string {
t.Helper()
activateTrustCenter(t, owner, trustCenterID)
const query = `
query($organizationId: ID!) {
node(id: $organizationId) {
... on Organization {
trustCenter { publicUrl }
}
}
}
`
var result struct {
Node struct {
TrustCenter struct {
PublicURL string `json:"publicUrl"`
} `json:"trustCenter"`
} `json:"node"`
}
err := owner.Execute(query, map[string]any{
"organizationId": owner.GetOrganizationID().String(),
}, &result)
require.NoError(t, err)
require.NotEmpty(t, result.Node.TrustCenter.PublicURL)
publicURL, err := url.Parse(result.Node.TrustCenter.PublicURL)
require.NoError(t, err)
require.NotEmpty(t, publicURL.Host)
return publicURL.Host
}
// activateTrustCenter flips the trust center to active so its public surface // activateTrustCenter flips the trust center to active so its public surface
// (NDA, subprocessors, reports, branding) becomes reachable by visitors. // (NDA, subprocessors, reports, branding) becomes reachable by visitors.
func activateTrustCenter(t *testing.T, owner *testutil.Client, trustCenterID string) { func activateTrustCenter(t *testing.T, owner *testutil.Client, trustCenterID string) {

View File

@@ -60,12 +60,12 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin
trustCenterID := lookupTrustCenterID(t, owner) trustCenterID := lookupTrustCenterID(t, owner)
uploadTrustCenterNDA(t, owner, trustCenterID) uploadTrustCenterNDA(t, owner, trustCenterID)
activateTrustCenter(t, owner, trustCenterID) trustHost := lookupTrustHost(t, owner, trustCenterID)
victim := testutil.SelfProvisionTrustCenterVisitor(t, trustCenterID) victim := testutil.SelfProvisionTrustCenterVisitor(t, trustHost)
attacker := testutil.SelfProvisionTrustCenterVisitor(t, trustCenterID) attacker := testutil.SelfProvisionTrustCenterVisitor(t, trustHost)
victimSignatureID, victimSignatureStatus := viewerSignature(t, victim, trustCenterID) victimSignatureID, victimSignatureStatus := viewerSignature(t, victim, trustHost)
require.NotEmpty(t, victimSignatureID) require.NotEmpty(t, victimSignatureID)
require.Equal(t, "PENDING", victimSignatureStatus) require.Equal(t, "PENDING", victimSignatureStatus)
@@ -77,7 +77,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin
} }
` `
err := attacker.ExecuteTrust(trustCenterID, acceptMutation, map[string]any{ err := attacker.ExecuteTrust(trustHost, acceptMutation, map[string]any{
"input": map[string]any{"signatureId": victimSignatureID}, "input": map[string]any{"signatureId": victimSignatureID},
}, nil) }, nil)
require.Error(t, err, "attacker must not be able to accept another visitor's signature") require.Error(t, err, "attacker must not be able to accept another visitor's signature")
@@ -91,7 +91,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin
} }
` `
err = attacker.ExecuteTrust(trustCenterID, recordEventMutation, map[string]any{ err = attacker.ExecuteTrust(trustHost, recordEventMutation, map[string]any{
"input": map[string]any{ "input": map[string]any{
"signatureId": victimSignatureID, "signatureId": victimSignatureID,
"eventType": "DOCUMENT_VIEWED", "eventType": "DOCUMENT_VIEWED",
@@ -100,7 +100,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin
require.Error(t, err, "attacker must not be able to inject events into another visitor's signature") require.Error(t, err, "attacker must not be able to inject events into another visitor's signature")
assertForbidden(t, err) assertForbidden(t, err)
_, statusAfterAttack := viewerSignature(t, victim, trustCenterID) _, statusAfterAttack := viewerSignature(t, victim, trustHost)
assert.Equal(t, "PENDING", statusAfterAttack, "attack attempts must not have mutated the victim's signature") assert.Equal(t, "PENDING", statusAfterAttack, "attack attempts must not have mutated the victim's signature")
var acceptResult struct { var acceptResult struct {
@@ -112,7 +112,7 @@ func TestTrustCenter_AcceptElectronicSignature_RejectsForeignSignature(t *testin
} `json:"acceptElectronicSignature"` } `json:"acceptElectronicSignature"`
} }
err = victim.ExecuteTrust(trustCenterID, acceptMutation, map[string]any{ err = victim.ExecuteTrust(trustHost, acceptMutation, map[string]any{
"input": map[string]any{"signatureId": victimSignatureID}, "input": map[string]any{"signatureId": victimSignatureID},
}, &acceptResult) }, &acceptResult)
require.NoError(t, err, "the legitimate signer must still be able to accept their own signature") require.NoError(t, err, "the legitimate signer must still be able to accept their own signature")
@@ -147,7 +147,7 @@ func uploadTrustCenterNDA(t *testing.T, owner *testutil.Client, trustCenterID st
require.NoError(t, err) require.NoError(t, err)
} }
func viewerSignature(t *testing.T, visitor *testutil.Client, trustCenterID string) (string, string) { func viewerSignature(t *testing.T, visitor *testutil.Client, trustHost string) (string, string) {
t.Helper() t.Helper()
const query = ` const query = `
@@ -171,7 +171,7 @@ func viewerSignature(t *testing.T, visitor *testutil.Client, trustCenterID strin
} `json:"currentTrustCenter"` } `json:"currentTrustCenter"`
} }
err := visitor.ExecuteTrust(trustCenterID, query, nil, &result) err := visitor.ExecuteTrust(trustHost, query, nil, &result)
require.NoError(t, err) require.NoError(t, err)
sig := result.CurrentTrustCenter.NonDisclosureAgreement.ViewerSignature sig := result.CurrentTrustCenter.NonDisclosureAgreement.ViewerSignature

View File

@@ -0,0 +1,134 @@
// Copyright (c) 2026 Probo Inc <hello@probo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package trust_test
import (
"encoding/json"
"io"
"net/http"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/testutil"
)
func TestTrustCenter_CIMDMetadataDocument(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
trustCenterID := lookupTrustCenterID(t, owner)
trustHost := lookupTrustHost(t, owner, trustCenterID)
testutil.WaitForTrustCenterHTTPS(t, trustHost)
client := testutil.TrustHTTPClient(trustHost)
resp, err := client.Get("https://" + trustHost + "/.well-known/oauth-client-metadata")
require.NoError(t, err)
defer func() { _ = resp.Body.Close() }()
require.Equal(t, http.StatusOK, resp.StatusCode)
body, err := io.ReadAll(resp.Body)
require.NoError(t, err)
var doc struct {
ClientID string `json:"client_id"`
ClientName string `json:"client_name"`
RedirectURIs []string `json:"redirect_uris"`
TokenEndpointAuthMethod string `json:"token_endpoint_auth_method"`
GrantTypes []string `json:"grant_types"`
ResponseTypes []string `json:"response_types"`
}
require.NoError(t, json.Unmarshal(body, &doc))
assert.Equal(
t,
"https://"+trustHost+"/.well-known/oauth-client-metadata",
doc.ClientID,
)
assert.Equal(
t,
[]string{"https://" + trustHost + "/callback"},
doc.RedirectURIs,
)
assert.Equal(t, "none", doc.TokenEndpointAuthMethod)
assert.Contains(t, doc.GrantTypes, "authorization_code")
assert.Equal(t, []string{"code"}, doc.ResponseTypes)
assert.NotEmpty(t, doc.ClientName)
}
func TestTrustCenter_VisitorConnectViaCIMD(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
trustCenterID := lookupTrustCenterID(t, owner)
trustHost := lookupTrustHost(t, owner, trustCenterID)
visitor := testutil.SelfProvisionTrustCenterVisitor(t, trustHost)
const query = `
query {
currentTrustCenter {
title
}
}
`
var result struct {
CurrentTrustCenter struct {
Title string `json:"title"`
} `json:"currentTrustCenter"`
}
err := visitor.ExecuteTrust(trustHost, query, nil, &result)
require.NoError(t, err, "visitor session must authenticate trust GraphQL after CIMD connect")
assert.NotEmpty(t, result.CurrentTrustCenter.Title)
}
func TestTrustCenter_UnknownCIMDClientRejected(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
_, challenge := testutil.GeneratePKCE()
clientID := "https://unknown-cimd.example.com/.well-known/oauth-client-metadata"
resp, err := testutil.OAuth2Authorize(owner, url.Values{
"client_id": {clientID},
"redirect_uri": {"https://unknown-cimd.example.com/callback"},
"response_type": {"code"},
"scope": {"openid profile email"},
"state": {"rejected"},
"code_challenge": {challenge},
"code_challenge_method": {"S256"},
"nonce": {"test-nonce"},
})
require.NoError(t, err)
require.False(
t,
testutil.IsConsentRedirect(resp),
"unknown CIMD client must not reach consent screen",
)
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
var oauthErr struct {
Code string `json:"error"`
}
require.NoError(t, json.Unmarshal(resp.Body, &oauthErr))
assert.Equal(t, "invalid_client", oauthErr.Code)
}