Files
probo/e2e/internal/testutil/graphql.go
Bryan Frimin 43ce3a7c53 Harden compliance portal auth and TLS
Align console references and OAuth branding with the
compliance-page model, and fix certificate cache eviction,
portal OAuth handlers, and magic-link edge cases left after
the trust-center rename.

Signed-off-by: Bryan Frimin <bryan@probo.com>
2026-07-21 15:44:22 +02:00

530 lines
14 KiB
Go

// Copyright (c) 2025-2026 Probo Inc <hello@probo.com>.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
package testutil
import (
"bytes"
"context"
"crypto/tls"
"encoding/json"
"fmt"
"io"
"mime/multipart"
"net"
"net/http"
"net/textproto"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// trustCenterHTTPSAddr is the loopback address of the dedicated trust-center
// HTTPS listener started by the e2e probod (see generateConfig). Compliance
// pages are served here exclusively, routed by TLS SNI / Host header. Uses a
// non-privileged port so the e2e suite doesn't require root/CAP_NET_BIND_SERVICE.
const trustCenterHTTPSAddr = "127.0.0.1:8443"
type GraphQLRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
type GraphQLResponse struct {
Data json.RawMessage `json:"data"`
Errors []GraphQLError `json:"errors,omitempty"`
}
// DataString returns the GraphQL data payload as JSON text. Absent and JSON null
// responses both normalize to an empty string for assert.Empty checks.
func (r *GraphQLResponse) DataString() string {
if len(r.Data) == 0 || string(r.Data) == "null" {
return ""
}
return string(r.Data)
}
type GraphQLError struct {
Message string `json:"message"`
Path []any `json:"path,omitempty"`
Extensions map[string]any `json:"extensions,omitempty"`
}
func (e GraphQLError) Error() string {
return e.Message
}
func (e GraphQLError) Code() string {
if e.Extensions == nil {
return ""
}
if code, ok := e.Extensions["code"].(string); ok {
return code
}
return ""
}
type GraphQLErrors []GraphQLError
func (e GraphQLErrors) Error() string {
if len(e) == 0 {
return ""
}
if len(e) == 1 {
return e[0].Message
}
return fmt.Sprintf("%s (and %d more errors)", e[0].Message, len(e)-1)
}
func (c *Client) doWithEndpoint(endpoint string, query string, variables map[string]any) (*GraphQLResponse, error) {
reqBody := GraphQLRequest{
Query: query,
Variables: variables,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("cannot marshal request: %w", err)
}
req, err := http.NewRequest("POST", c.baseURL+endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
var gqlResp GraphQLResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, fmt.Errorf("cannot decode response: %w", err)
}
if len(gqlResp.Errors) > 0 {
return &gqlResp, GraphQLErrors(gqlResp.Errors)
}
return &gqlResp, nil
}
func (c *Client) Do(query string, variables map[string]any) (*GraphQLResponse, error) {
return c.doWithEndpoint("/api/console/v1/graphql", query, variables)
}
func (c *Client) DoConnect(query string, variables map[string]any) (*GraphQLResponse, error) {
return c.doWithEndpoint("/api/connect/v1/graphql", query, variables)
}
// ConsoleGraphQLWithAccessToken posts to the console GraphQL endpoint using a
// bearer access token and no session cookies.
func ConsoleGraphQLWithAccessToken(
t testing.TB,
accessToken string,
query string,
variables map[string]any,
) (*GraphQLResponse, error) {
t.Helper()
reqBody := GraphQLRequest{
Query: query,
Variables: variables,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("cannot marshal request: %w", err)
}
req, err := http.NewRequest(
"POST",
GetBaseURL()+"/api/console/v1/graphql",
bytes.NewReader(body),
)
if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+accessToken)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
var gqlResp GraphQLResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, fmt.Errorf("cannot decode response: %w", err)
}
if len(gqlResp.Errors) > 0 {
return &gqlResp, GraphQLErrors(gqlResp.Errors)
}
return &gqlResp, nil
}
// trustHTTPClient builds an HTTP client that always dials the dedicated
// trust-center HTTPS listener on loopback while presenting the compliance
// page's host as TLS SNI. Certificates are step-ca-issued for e2e, so
// verification is skipped when the root is not installed in the test runner.
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}
return &http.Client{
Jar: jar,
Timeout: 30 * time.Second,
Transport: &http.Transport{
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
return dialer.DialContext(ctx, "tcp", trustCenterHTTPSAddr)
},
TLSClientConfig: &tls.Config{
ServerName: serverName,
InsecureSkipVerify: true, //nolint:gosec // e2e talks to step-ca-issued certs on loopback.
},
},
}
}
// 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
// listener. host is the page's serving domain (a customer custom domain or a
// managed {slug}.probopage.localhost subdomain).
func (c *Client) DoTrust(host string, query string, variables map[string]any) (*GraphQLResponse, error) {
reqBody := GraphQLRequest{
Query: query,
Variables: variables,
}
body, err := json.Marshal(reqBody)
if err != nil {
return nil, fmt.Errorf("cannot marshal request: %w", err)
}
endpoint := fmt.Sprintf("https://%s/graphql", host)
req, err := http.NewRequest("POST", endpoint, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("cannot create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
client := trustHTTPClient(host)
if c.trustClient != nil && host == c.trustHost {
client = c.trustClient
}
resp, err := client.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("cannot read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
var gqlResp GraphQLResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return nil, fmt.Errorf("cannot decode response: %w", err)
}
if len(gqlResp.Errors) > 0 {
return &gqlResp, GraphQLErrors(gqlResp.Errors)
}
return &gqlResp, nil
}
func (c *Client) Execute(query string, variables map[string]any, result any) error {
resp, err := c.Do(query, variables)
if err != nil {
return err
}
if result != nil && resp.DataString() != "" {
if err := json.Unmarshal(resp.Data, result); err != nil {
return fmt.Errorf("cannot unmarshal data: %w", err)
}
}
return nil
}
func (c *Client) ExecuteConnect(query string, variables map[string]any, result any) error {
resp, err := c.DoConnect(query, variables)
if err != nil {
return err
}
if result != nil && resp.DataString() != "" {
if err := json.Unmarshal(resp.Data, result); err != nil {
return fmt.Errorf("cannot unmarshal data: %w", err)
}
}
return nil
}
func (c *Client) ExecuteTrust(host string, query string, variables map[string]any, result any) error {
resp, err := c.DoTrust(host, query, variables)
if err != nil {
return err
}
if result != nil && resp.DataString() != "" {
if err := json.Unmarshal(resp.Data, result); err != nil {
return fmt.Errorf("cannot unmarshal data: %w", err)
}
}
return nil
}
func (c *Client) MustExecute(query string, variables map[string]any, result any) {
c.T.Helper()
err := c.Execute(query, variables, result)
require.NoError(c.T, err, "GraphQL request failed")
}
func (c *Client) ExecuteShouldFail(query string, variables map[string]any) error {
c.T.Helper()
_, err := c.Do(query, variables)
require.Error(c.T, err, "expected GraphQL request to fail but it succeeded")
return err
}
func (c *Client) HTTPClient() *http.Client {
return c.httpClient
}
func (c *Client) BaseURL() string {
return c.baseURL
}
func TrustHTTPClient(trustHost string) *http.Client {
return trustHTTPClient(trustHost)
}
type UploadFile struct {
Filename string
ContentType string
Content []byte
}
func (c *Client) ExecuteWithFile(query string, variables map[string]any, variablePath string, file UploadFile, result any) error {
return c.executeMultipart("/api/console/v1/graphql", query, variables, map[string]UploadFile{variablePath: file}, result)
}
func (c *Client) ExecuteConnectWithFile(query string, variables map[string]any, variablePath string, file UploadFile, result any) error {
return c.executeMultipart("/api/connect/v1/graphql", query, variables, map[string]UploadFile{variablePath: file}, result)
}
func (c *Client) ExecuteWithFiles(query string, variables map[string]any, files map[string]UploadFile, result any) error {
return c.executeMultipart("/api/console/v1/graphql", query, variables, files, result)
}
func (c *Client) executeMultipart(endpoint string, query string, variables map[string]any, files map[string]UploadFile, result any) error {
// Create multipart writer using standard library
var buf bytes.Buffer
writer := multipart.NewWriter(&buf)
// Build the operations JSON
operations := map[string]any{
"query": query,
"variables": variables,
}
operationsJSON, err := json.Marshal(operations)
if err != nil {
return fmt.Errorf("cannot marshal operations: %w", err)
}
// Add operations part
if err := writer.WriteField("operations", string(operationsJSON)); err != nil {
return fmt.Errorf("cannot write operations field: %w", err)
}
// Build the map for file variables (sorted for deterministic order)
fileMap := make(map[string][]string)
fileOrder := make([]string, 0, len(files))
for path := range files {
fileOrder = append(fileOrder, path)
}
// Sort for deterministic ordering
for i, path := range fileOrder {
fileMap[fmt.Sprintf("%d", i)] = []string{"variables." + path}
}
mapJSON, err := json.Marshal(fileMap)
if err != nil {
return fmt.Errorf("cannot marshal map: %w", err)
}
// Add map part
if err := writer.WriteField("map", string(mapJSON)); err != nil {
return fmt.Errorf("cannot write map field: %w", err)
}
// Add file parts
for i, path := range fileOrder {
file := files[path]
fieldName := fmt.Sprintf("%d", i)
// Create form file part with proper headers
h := make(textproto.MIMEHeader)
h.Set("Content-Disposition", fmt.Sprintf(`form-data; name="%s"; filename="%s"`, fieldName, file.Filename))
h.Set("Content-Type", file.ContentType)
part, err := writer.CreatePart(h)
if err != nil {
return fmt.Errorf("cannot create file part %s: %w", path, err)
}
if _, err := part.Write(file.Content); err != nil {
return fmt.Errorf("cannot write file content %s: %w", path, err)
}
}
if err := writer.Close(); err != nil {
return fmt.Errorf("cannot close multipart writer: %w", err)
}
// Create request
req, err := http.NewRequest("POST", c.baseURL+endpoint, &buf)
if err != nil {
return fmt.Errorf("cannot create request: %w", err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
// Execute request
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("cannot read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
var gqlResp GraphQLResponse
if err := json.Unmarshal(respBody, &gqlResp); err != nil {
return fmt.Errorf("cannot decode response: %w", err)
}
if len(gqlResp.Errors) > 0 {
return GraphQLErrors(gqlResp.Errors)
}
if result != nil && gqlResp.DataString() != "" {
if err := json.Unmarshal(gqlResp.Data, result); err != nil {
return fmt.Errorf("cannot unmarshal data: %w", err)
}
}
return nil
}