Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 79 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Fern JUnit Client

A CLI that can read JUnit test reports and send them to a Fern Reporter instance in a format it understands
A CLI that can read JUnit test reports and send them to a Fern Platform instance in a format it understands

## Introduction

If you don't know what Fern is, [check it out here!](https://github.com/guidewire-oss/fern-reporter)
If you don't know what Fern is, [check it out here!](https://github.com/guidewire-oss/fern-platform)

## Install

Expand All @@ -15,15 +15,15 @@ go install github.com/guidewire-oss/fern-junit-client@latest
```


## Registering Application with Fern-Reporter
## Registering Application with Fern Platform
```bash
curl -L -X POST http://localhost:8080/api/project \
-H "Content-Type: application/json" \
-d '{
"name": "First Projects",
"team_name": "my team",
"comment": "This is the test project"
}'
}'
```

Sample Response:
Expand Down Expand Up @@ -54,10 +54,84 @@ fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "tests/*.xml"
```

### Configuration

The fern-junit-client can be configured using environment variables:

#### API Endpoint Configuration

| Environment Variable | Description | Default |
|---------------------|-------------|---------|
| `FERN_API_ENDPOINT_PATH` | Override the API endpoint path | `api/v1/test-runs` |

Example:
```sh
# Use a custom API endpoint path
export FERN_API_ENDPOINT_PATH="api/v2/test-results"
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
```

### OAuth Authentication

The fern-junit-client supports OAuth 2.0 authentication using the client credentials grant type. This allows the client to authenticate with the Fern backend using OAuth tokens.

#### OAuth Configuration

OAuth authentication is configured via environment variables:

| Environment Variable | Description | Required |
|---------------------|-------------|----------|
| `AUTH_URL` | The OAuth 2.0 token endpoint URL | Yes (to enable OAuth) |
| `FERN_AUTH_CLIENT_ID` | The OAuth client ID | Yes (if OAuth enabled) |
| `FERN_AUTH_CLIENT_SECRET` | The OAuth client secret/password | Yes (if OAuth enabled) |
| `FERN_CLIENT_SCOPE` | Space-separated list of OAuth scopes to request | No (optional) |

#### Behavior

- **OAuth Disabled**: If `AUTH_URL` is not set, the client will operate without authentication (backward compatible behavior).
- **OAuth Enabled**: If `AUTH_URL` is set, the client will:
1. Validate that `FERN_AUTH_CLIENT_ID` and `FERN_AUTH_CLIENT_SECRET` are also provided
2. Request an access token from the OAuth server using client credentials grant
3. Include requested scopes in the token request if `FERN_CLIENT_SCOPE` is set
4. Include the Bearer token in the Authorization header for all API calls to the Fern backend
5. Automatically refresh the token when it expires

#### Examples with OAuth

##### Without OAuth (default)
```sh
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
```

##### With OAuth
```sh
export AUTH_URL="https://oauth.example.com/token"
export FERN_AUTH_CLIENT_ID="your-client-id"
export FERN_AUTH_CLIENT_SECRET="your-client-secret"

fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
```

##### With OAuth and Scopes
```sh
export AUTH_URL="https://oauth.example.com/token"
export FERN_AUTH_CLIENT_ID="your-client-id"
export FERN_AUTH_CLIENT_SECRET="your-client-secret"
export FERN_CLIENT_SCOPE="read write admin"

fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml"
```

##### Verbose Mode
Use the `--verbose` flag to see OAuth authentication status:
```sh
fern-junit-client send -u "http://localhost:8080" -p "77b34e74-5631-5a71-b8ce-97b9d6bab10a" -f "report.xml" --verbose
```

## See Also

* [Fern UI](https://github.com/guidewire-oss/fern-ui)
* [Fern Reporter](https://github.com/guidewire-oss/fern-reporter)
* [Fern Platform](https://github.com/guidewire-oss/fern-platform)
* [Fern Ginkgo Client](https://github.com/guidewire-oss/fern-ginkgo-client)

## Development
Expand Down
4 changes: 2 additions & 2 deletions cmd/send.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,8 @@ var sendCmd = &cobra.Command{
}

func init() {
sendCmd.PersistentFlags().StringVarP(&fernUrl, "fern-url", "u", "", "base URL of the Fern Reporter instance to send test reports to (required)")
sendCmd.PersistentFlags().StringVarP(&projectId, "project-id", "p", "", "Id of the project to associate test reports with (required). You must register the application first in fern-reporter")
sendCmd.PersistentFlags().StringVarP(&fernUrl, "fern-url", "u", "", "base URL of the Fern Platform instance to send test reports to (required)")
sendCmd.PersistentFlags().StringVarP(&projectId, "project-id", "p", "", "Id of the project to associate test reports with (required). You must register the application first in Fern Platform")
sendCmd.PersistentFlags().StringVarP(&filePattern, "file-pattern", "f", "", "file name pattern of test reports to send to Fern (required)")
sendCmd.PersistentFlags().StringVarP(&tags, "tags", "t", "", "comma-separated tags to be included on runs")
if err := sendCmd.MarkPersistentFlagRequired("fern-url"); err != nil {
Expand Down
191 changes: 191 additions & 0 deletions pkg/auth/oauth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
package auth

import (
"encoding/json"
"fmt"
"net/http"
"net/url"
"os"
"strings"
"time"
)

// OAuthConfig holds OAuth configuration
type OAuthConfig struct {
TokenURL string
ClientID string
ClientPassword string
Scopes string
}

// TokenResponse represents the OAuth token response
type TokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope,omitempty"`
}

// OAuthClient handles OAuth authentication
type OAuthClient struct {
config *OAuthConfig
token *TokenResponse
tokenExpiry time.Time
}

// NewOAuthClient creates a new OAuth client
// Returns nil if OAuth is not configured (no AUTH_URL set)
// Returns an error if OAuth is partially configured (missing required parameters)
func NewOAuthClient() (*OAuthClient, error) {
config := &OAuthConfig{
TokenURL: os.Getenv("AUTH_URL"),
ClientID: os.Getenv("FERN_AUTH_CLIENT_ID"),
ClientPassword: os.Getenv("FERN_AUTH_CLIENT_SECRET"),
Scopes: os.Getenv("FERN_CLIENT_SCOPE"),
}

// If token URL is not set, OAuth is disabled - this is OK
if config.TokenURL == "" {
return nil, nil
}

// If AUTH_URL is set, validate that we have all required OAuth parameters
var missingParams []string
if config.ClientID == "" {
missingParams = append(missingParams, "FERN_AUTH_CLIENT_ID")
}
if config.ClientPassword == "" {
missingParams = append(missingParams, "FERN_AUTH_CLIENT_SECRET")
}

if len(missingParams) > 0 {
return nil, fmt.Errorf("OAuth configuration error: AUTH_URL is set but missing required parameters: %s", strings.Join(missingParams, ", "))
}

return &OAuthClient{
config: config,
}, nil
}

// GetToken fetches a new OAuth token or returns the cached one if still valid
func (c *OAuthClient) GetToken() (string, error) {
if c == nil {
return "", nil
}

// Check if we have a valid cached token
if c.token != nil && time.Now().Before(c.tokenExpiry) {
return c.token.AccessToken, nil
}

// Fetch new token
if err := c.fetchToken(); err != nil {
return "", fmt.Errorf("failed to fetch OAuth token: %w", err)
}

return c.token.AccessToken, nil
}

// fetchToken fetches a new OAuth token from the authorization server
func (c *OAuthClient) fetchToken() error {
// Prepare the token request
data := url.Values{}
data.Set("grant_type", "client_credentials")
data.Set("client_id", c.config.ClientID)
data.Set("client_secret", c.config.ClientPassword)

// Add scopes if provided
if c.config.Scopes != "" {
data.Set("scope", c.config.Scopes)
}

req, err := http.NewRequest("POST", c.config.TokenURL, strings.NewReader(data.Encode()))
if err != nil {
return fmt.Errorf("failed to create token request: %w", err)
}

req.Header.Set("Content-Type", "application/x-www-form-urlencoded")

// Send the request
client := &http.Client{
Timeout: 30 * time.Second,
}
resp, err := client.Do(req)
if err != nil {
return fmt.Errorf("failed to send token request: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return fmt.Errorf("token request failed with status: %d", resp.StatusCode)
}

// Parse the response
var tokenResp TokenResponse
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
return fmt.Errorf("failed to decode token response: %w", err)
}

// Store the token and calculate expiry
c.token = &tokenResp
Copy link

@cubic-dev-ai cubic-dev-ai bot Oct 30, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchToken updates c.token without synchronization, so sharing this OAuthClient through HTTPClient leads to data races when the client is used concurrently.

Prompt for AI agents
Address the following comment on pkg/auth/oauth.go at line 120:

<comment>fetchToken updates c.token without synchronization, so sharing this OAuthClient through HTTPClient leads to data races when the client is used concurrently.</comment>

<file context>
@@ -0,0 +1,181 @@
+	}
+
+	// Store the token and calculate expiry
+	c.token = &amp;tokenResp
+	// Subtract 30 seconds from expiry to ensure we refresh before it actually expires
+	expiryDuration := time.Duration(tokenResp.ExpiresIn-30) * time.Second
</file context>
Fix with Cubic

// Subtract 30 seconds from expiry to ensure we refresh before it actually expires
expiryDuration := time.Duration(tokenResp.ExpiresIn-30) * time.Second
c.tokenExpiry = time.Now().Add(expiryDuration)

return nil
}

// AddAuthHeader adds the OAuth bearer token to the request header if OAuth is enabled
func (c *OAuthClient) AddAuthHeader(req *http.Request) error {
if c == nil {
// OAuth is not enabled
return nil
}

token, err := c.GetToken()
if err != nil {
return err
}

if token != "" {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token))
}

return nil
}

// IsEnabled returns whether OAuth is enabled
func (c *OAuthClient) IsEnabled() bool {
return c != nil
}

// HTTPClient returns an http.Client with OAuth authentication if enabled
func (c *OAuthClient) HTTPClient() *http.Client {
return &http.Client{
Timeout: 30 * time.Second,
Transport: &OAuthTransport{
Base: http.DefaultTransport,
OAuthClient: c,
},
}
}

// OAuthTransport is an http.RoundTripper that adds OAuth authentication
type OAuthTransport struct {
Base http.RoundTripper
OAuthClient *OAuthClient
}

// RoundTrip implements the http.RoundTripper interface
func (t *OAuthTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// Clone the request to avoid modifying the original
reqCopy := req.Clone(req.Context())

// Add OAuth header
if err := t.OAuthClient.AddAuthHeader(reqCopy); err != nil {
return nil, err
}

// Use the base transport to send the request
return t.Base.RoundTrip(reqCopy)
}
Loading