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
11 changes: 11 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Agent Guidelines

## Tests

Do not write policy assertion tests.

It is acceptable to set configuration to a particular value and test whether the resulting behavior works or fails.

It is not acceptable to test how configuration itself is set, including shipped defaults, environment-specific values, or whether a feature is enabled or disabled by default.

Test behavior under configuration, not configuration policy.
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.s
Pin a version or choose an install directory:

```bash
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.2.5
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.0
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --install-dir "$HOME/bin" --force
```

Expand All @@ -37,7 +37,7 @@ Pin a version or skip `PATH` changes (useful in CI):

```powershell
irm "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.ps1" | iex
Install-Tollbit -Version v0.2.5 -Force
Install-Tollbit -Version v0.3.0 -Force
Install-Tollbit -NoModifyPath -PrintPathInstructions
```

Expand Down Expand Up @@ -167,7 +167,7 @@ Installer channel updates:
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash

# Pinned
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.2.5 --force
curl -fsSL "https://raw.githubusercontent.com/tollbit/cli/main/scripts/install.sh" | bash -s -- --version v0.3.0 --force
```

```powershell
Expand Down
19 changes: 19 additions & 0 deletions internal/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"github.com/tollbit/cli/internal/agentauth/agentconfirmsicons"
"github.com/tollbit/cli/internal/agentauth/browserselecticon"
"github.com/tollbit/cli/internal/agentauth/redirect"
"github.com/tollbit/cli/internal/client/analytics"
"github.com/tollbit/cli/internal/client/auth"
"github.com/tollbit/cli/internal/client/tollbit"
"github.com/tollbit/cli/internal/cliruntime"
Expand All @@ -17,6 +18,7 @@ import (
)

type Dependencies struct {
Analytics analytics.Client
Auth *auth.Client
Tollbit tollbit.Client
OBOAuthorizer agentauth.OBOAuthorizer
Expand All @@ -30,6 +32,7 @@ type App struct {
deps Dependencies

auth func() (*auth.Client, error)
analytics func() (analytics.Client, error)
tollbit func() (tollbit.Client, error)
oboAuthorizer func() (agentauth.OBOAuthorizer, error)
credentials func() (*agenttoken.CredentialManager, error)
Expand All @@ -46,6 +49,7 @@ func New(config configuration.Config, opts ...Option) (*App, error) {
deps: cfg.dependencies,
}
a.auth = sync.OnceValues(a.buildAuth)
a.analytics = sync.OnceValues(a.buildAnalytics)
a.tollbit = sync.OnceValues(a.buildTollbit)
a.oboAuthorizer = sync.OnceValues(a.buildOBOAuthorizer)
a.credentials = sync.OnceValues(a.buildCredentials)
Expand All @@ -61,6 +65,21 @@ func (a *App) Auth() (*auth.Client, error) {
return a.auth()
}

func (a *App) Analytics() (analytics.Client, error) {
return a.analytics()
}

func (a *App) buildAnalytics() (analytics.Client, error) {
if a.deps.Analytics != nil {
return a.deps.Analytics, nil
}
client, err := analytics.NewClient(analytics.Config{BaseURL: a.config.Analytics.BaseURL})
if err != nil {
return nil, fmt.Errorf("build analytics client: %w", err)
}
return client, nil
}

func (a *App) buildAuth() (*auth.Client, error) {
if a.deps.Auth != nil {
return a.deps.Auth, nil
Expand Down
31 changes: 30 additions & 1 deletion internal/app/app_test.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package app

import (
"context"
"testing"

"github.com/tollbit/cli/internal/agentauth"
"github.com/tollbit/cli/internal/client/analytics"
"github.com/tollbit/cli/internal/configuration"
"github.com/tollbit/cli/internal/tokens/agent"
)

func TestNewExposesConfigAndBuildsClients(t *testing.T) {
Expand All @@ -17,6 +20,9 @@ func TestNewExposesConfigAndBuildsClients(t *testing.T) {
if _, err := app.Auth(); err != nil {
t.Fatalf("expected auth client: %v", err)
}
if _, err := app.Analytics(); err != nil {
t.Fatalf("expected analytics client: %v", err)
}
if _, err := app.Tollbit(); err != nil {
t.Fatalf("expected tollbit client: %v", err)
}
Expand All @@ -31,6 +37,22 @@ func TestNewExposesConfigAndBuildsClients(t *testing.T) {
}
}

func TestNewUsesInjectedAnalytics(t *testing.T) {
fake := &fakeAnalytics{}
application, err := New(testConfig(t), OverrideDependencies(Dependencies{Analytics: fake}))
if err != nil {
t.Fatal(err)
}

analyticsClient, err := application.Analytics()
if err != nil {
t.Fatal(err)
}
if analyticsClient != fake {
t.Fatal("expected injected analytics client")
}
}

func TestNewBuildsBrowserSelectIconAuthorizer(t *testing.T) {
config := testConfig(t)
config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote
Expand Down Expand Up @@ -112,7 +134,8 @@ func testConfig(t *testing.T) configuration.Config {
App: configuration.AppConfig{
Name: "test-cli",
},
Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: t.TempDir()},
Analytics: configuration.AnalyticsConfig{BaseURL: "https://analytics.example"},
Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: t.TempDir()},
Auth: configuration.AuthConfig{
BaseURL: "https://auth.example",
Consent: configuration.ConsentConfig{
Expand All @@ -133,6 +156,12 @@ func testConfig(t *testing.T) configuration.Config {
}
}

type fakeAnalytics struct{}

func (f *fakeAnalytics) Query(context.Context, analytics.QueryRequest, agent.Token) (analytics.QueryResponse, error) {
return analytics.QueryResponse{}, nil
}

func TestBuildConsentStrategyAgentConfirmsIcons(t *testing.T) {
config := testConfig(t)
config.Runtime.EndUserProximity = configuration.RuntimeEndUserProximityRemote
Expand Down
77 changes: 77 additions & 0 deletions internal/cli/analytics.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package cli

import (
"fmt"

"github.com/spf13/cobra"
"github.com/tollbit/cli/internal/app"
analyticsclient "github.com/tollbit/cli/internal/client/analytics"
"github.com/tollbit/cli/internal/credentials/agenttoken"
)

func NewAnalyticsCommand(factory app.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "analytics",
Short: "Query TollBit analytics",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return UsageError("analytics requires a subcommand")
}
return UsageError("unknown analytics command %q", args[0])
},
}
cmd.AddCommand(NewAnalyticsQueryCommand(factory))
return cmd
}

func NewAnalyticsQueryCommand(factory app.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "query <SQL>",
Short: "Execute an analytics SQL query",
Example: " tollbit analytics query 'SELECT * FROM logs LIMIT 10'",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) != 1 {
return UsageError("analytics query requires <SQL>")
}
return nil
},
RunE: func(cmd *cobra.Command, args []string) error {
return runAnalyticsQuery(cmd, factory, args[0])
},
}
cmd.Flags().String("user-agent", "", "user agent for request")
return cmd
}

func runAnalyticsQuery(cmd *cobra.Command, factory app.Factory, sql string) error {
application, err := appForCommand(factory, cmd)
if err != nil {
return RuntimeError(err)
}
credentials, err := application.Credentials()
if err != nil {
return RuntimeError(err)
}
analyticsClient, err := application.Analytics()
if err != nil {
return RuntimeError(err)
}
identity, err := credentials.ResolveIdentity(cmd.Context(), agenttoken.ResolveIdentityOptions{
UserAgent: flagChangedStr(cmd, "user-agent"),
})
if err != nil {
return RuntimeError(fmt.Errorf("error resolving identity: %w", err))
}
token, err := credentials.GetAgentToken(cmd, identity, agenttoken.WithOBO())
if err != nil {
return RuntimeError(fmt.Errorf("error fetching agent token: %w", err))
}
result, err := analyticsClient.Query(cmd.Context(), analyticsclient.QueryRequest{SQL: sql}, token)
if err != nil {
return RuntimeError(fmt.Errorf("error querying analytics: %w", err))
}
if err := writeJSON(cmd.OutOrStdout(), result); err != nil {
return RuntimeError(fmt.Errorf("error writing analytics response: %w", err))
}
return nil
}
98 changes: 98 additions & 0 deletions internal/cli/analytics_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package cli

import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)

func TestAnalyticsCommandDisabled(t *testing.T) {
config := testConfig()
config.Analytics.Enabled = false

var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, []string{"analytics", "query", "SELECT 1"}, nil, &stdout, &stderr)
if code != 2 {
t.Fatalf("expected usage exit code, got %d (stderr=%q)", code, stderr.String())
}
if !strings.Contains(stderr.String(), `unknown command "analytics"`) {
t.Fatalf("expected disabled command to be unknown, got %q", stderr.String())
}
}

func TestAnalyticsQueryUsesOBOAgentTokenAndWritesJSON(t *testing.T) {
token := testAgentJWTWithOBO(t)
storageDir := t.TempDir()
if err := os.WriteFile(filepath.Join(storageDir, "agent-token.jwt"), []byte(token), 0o600); err != nil {
t.Fatal(err)
}
analyticsSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.URL.Path != "/analytics/agent/v1/query" {
t.Fatalf("unexpected analytics request: %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer "+token {
t.Fatal("unexpected authorization header")
}
var request struct {
SQL string `json:"sql"`
}
if err := json.NewDecoder(r.Body).Decode(&request); err != nil {
t.Fatal(err)
}
if request.SQL != "SELECT * FROM logs" {
t.Fatalf("unexpected SQL: %q", request.SQL)
}
_, _ = w.Write([]byte(`{"columns":[{"name":"requests","type":"INTEGER"},{"name":"optional","type":"STRING"}],"rows":[[42,null]]}`))
}))
defer analyticsSrv.Close()

config := testConfig()
config.Analytics.Enabled = true
config.Analytics.BaseURL = analyticsSrv.URL
config.Credentials.StorageDir = storageDir
config.Runtime.StateDir = storageDir

var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, []string{"analytics", "query", "SELECT * FROM logs"}, nil, &stdout, &stderr)
if code != 0 {
t.Fatalf("expected success, got %d (stderr=%q)", code, stderr.String())
}
var output struct {
Columns []struct {
Name string `json:"name"`
Type string `json:"type"`
} `json:"columns"`
Rows [][]any `json:"rows"`
}
if err := json.Unmarshal(stdout.Bytes(), &output); err != nil {
t.Fatalf("invalid JSON output %q: %v", stdout.String(), err)
}
if len(output.Columns) != 2 || output.Columns[0].Name != "requests" {
t.Fatalf("unexpected columns: %#v", output.Columns)
}
if len(output.Rows) != 1 || output.Rows[0][0] != float64(42) || output.Rows[0][1] != nil {
t.Fatalf("unexpected rows: %#v", output.Rows)
}
}

func TestAnalyticsQueryRequiresOneSQLArgument(t *testing.T) {
config := testConfig()
config.Analytics.Enabled = true
config.Analytics.BaseURL = "https://analytics.example"

for _, args := range [][]string{
{"analytics", "query"},
{"analytics", "query", "SELECT", "1"},
} {
var stdout, stderr bytes.Buffer
code := executeTestCommandWithConfig(config, args, nil, &stdout, &stderr)
if code != 2 || !strings.Contains(stderr.String(), "analytics query requires <SQL>") {
t.Fatalf("expected SQL usage error for %#v, got code=%d stderr=%q", args, code, stderr.String())
}
}
}
3 changes: 2 additions & 1 deletion internal/cli/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@ func testConfig() configuration.Config {
App: configuration.AppConfig{
Name: "tollbit",
},
Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: storageDir},
Analytics: configuration.AnalyticsConfig{BaseURL: "https://gateway.tollbit.com"},
Runtime: configuration.RuntimeConfig{EndUserProximity: configuration.RuntimeEndUserProximityLocal, StateDir: storageDir},
Auth: configuration.AuthConfig{
BaseURL: authBaseURL,
UseRefreshTokens: true,
Expand Down
3 changes: 3 additions & 0 deletions internal/cli/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,5 +16,8 @@ func NewCommandTree(factory app.Factory) *cobra.Command {
NewGuideCommand(factory),
NewVersionCommand(),
)
if factory.Config.Analytics.Enabled {
rootCmd.AddCommand(NewAnalyticsCommand(factory))
}
return rootCmd
}
Loading