Trust GraphQL and MCP still exposed presigned URL strings for trust-center logos while console and connect already serve stable File.downloadUrl paths. Phase 1 migrates the seven public logo fields on trust GraphQL and the trust-center file references on MCP to the shared File type; trust GraphQL NDA stays on fileUrl for a follow-up. Trust resolvers load public files through filemanager and map them with types.NewFile. The trust app Relay queries and components now read logo.downloadUrl. MCP specification, resolvers, and helpers are updated in sync, including NDA on MCP where callers already have file access. filemanager is split into focused files and its URL surface is narrowed to GenerateFileURL(file) for stable app URLs and GeneratePresignedURL for S3 redirects. GetPublicFile remains the DB entry point when only a file ID is known. Add trust and MCP e2e coverage for public logo download URLs. Signed-off-by: Ludovic Vielle <ludovic@probo.com>
329 lines
8.8 KiB
Go
329 lines
8.8 KiB
Go
// Copyright (c) 2025-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 testutil
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/textproto"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
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"`
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
func (c *Client) DoTrust(trustCenterID string, query string, variables map[string]any) (*GraphQLResponse, error) {
|
|
return c.doWithEndpoint(fmt.Sprintf("/trust/%s/api/trust/v1/graphql", trustCenterID), query, variables)
|
|
}
|
|
|
|
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.Data != nil {
|
|
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.Data != nil {
|
|
if err := json.Unmarshal(resp.Data, result); err != nil {
|
|
return fmt.Errorf("cannot unmarshal data: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (c *Client) ExecuteTrust(trustCenterID string, query string, variables map[string]any, result any) error {
|
|
resp, err := c.DoTrust(trustCenterID, query, variables)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if result != nil && resp.Data != nil {
|
|
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
|
|
}
|
|
|
|
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.Data != nil {
|
|
if err := json.Unmarshal(gqlResp.Data, result); err != nil {
|
|
return fmt.Errorf("cannot unmarshal data: %w", err)
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|