diff --git a/sdk/go/go.mod b/sdk/go/go.mod index 26298aebc8..dec3ef3fc9 100644 --- a/sdk/go/go.mod +++ b/sdk/go/go.mod @@ -1,21 +1,21 @@ module github.com/NVIDIA/OpenShell/sdk/go -go 1.25.0 +go 1.24.0 require ( github.com/coder/websocket v1.8.15 github.com/stretchr/testify v1.11.1 - golang.org/x/oauth2 v0.36.0 - google.golang.org/grpc v1.81.1 + golang.org/x/oauth2 v0.35.0 + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.51.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.34.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.33.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sdk/go/go.sum b/sdk/go/go.sum index dc14bb3d80..0a382a0291 100644 --- a/sdk/go/go.sum +++ b/sdk/go/go.sum @@ -20,30 +20,30 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= -go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= -go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= -go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= -go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= -go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= -go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= -go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= -go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= -go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= -golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= -golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= -golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= -golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= -golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= +go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= +go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= +go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= +go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= +go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= +go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= +go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= +go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= +go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= +go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= -google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= -google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/sdk/go/openshell/v1/edge/cloudflare.go b/sdk/go/openshell/v1/edge/cloudflare.go new file mode 100644 index 0000000000..5363fe07f4 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "errors" + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// CloudflareAccess returns an AuthProvider that adds Cloudflare Access +// headers to every RPC. It sets: +// - cf-access-jwt-assertion: the edge JWT token +// - cookie: CF_Authorization= +// +// The edgeToken authenticates with the Cloudflare Access edge proxy. +// Returns an error if baseAuth is nil or edgeToken is empty. +func CloudflareAccess(baseAuth v1.AuthProvider, edgeToken string) (v1.AuthProvider, error) { + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + return v1.WithExtraHeaders(baseAuth, map[string]string{ + "cf-access-jwt-assertion": edgeToken, + "cookie": fmt.Sprintf("CF_Authorization=%s", edgeToken), + }) +} diff --git a/sdk/go/openshell/v1/edge/cloudflare_test.go b/sdk/go/openshell/v1/edge/cloudflare_test.go new file mode 100644 index 0000000000..902e599d69 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloudflareAccess_ValidToken(t *testing.T) { + base := v1.StaticToken("my-token") + auth, err := CloudflareAccess(base, "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Base auth header preserved. + assert.Equal(t, "Bearer my-token", md["authorization"]) + + // Cloudflare-specific headers present. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_EmptyToken(t *testing.T) { + base := v1.StaticToken("my-token") + _, err := CloudflareAccess(base, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestCloudflareAccess_NilBase(t *testing.T) { + _, err := CloudflareAccess(nil, "cf-edge-jwt-xxx") + require.Error(t, err) + assert.Contains(t, err.Error(), "base") +} + +func TestCloudflareAccess_WithNoAuth(t *testing.T) { + auth, err := CloudflareAccess(v1.NoAuth(), "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // NoAuth provides no base metadata; only CF headers should appear. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_RequireTransportSecurity_Delegates(t *testing.T) { + tests := []struct { + name string + base v1.AuthProvider + expected bool + }{ + { + name: "delegates to NoAuth (false)", + base: v1.NoAuth(), + expected: false, + }, + { + name: "delegates to StaticToken (true)", + base: v1.StaticToken("tok"), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := CloudflareAccess(tt.base, "cf-edge-jwt-xxx") + require.NoError(t, err) + assert.Equal(t, tt.expected, auth.RequireTransportSecurity()) + }) + } +} + +func TestCloudflareAccess_TokenNotInError(t *testing.T) { + // Verify the error for empty token does not leak actual token values. + _, err := CloudflareAccess(v1.StaticToken("s3cr3t-val"), "") + require.Error(t, err) + // The error should mention the parameter name, not any token value. + assert.NotContains(t, err.Error(), "s3cr3t-val") +} diff --git a/sdk/go/openshell/v1/edge/doc.go b/sdk/go/openshell/v1/edge/doc.go new file mode 100644 index 0000000000..92fd9b1f9f --- /dev/null +++ b/sdk/go/openshell/v1/edge/doc.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package edge provides utilities for connecting to OpenShell gateways +// through edge proxies such as Cloudflare Access. It includes convenience +// constructors for common edge auth patterns and a WebSocket tunnel proxy +// for gRPC transport through HTTP/1.1-only proxies. +// +// # Cloudflare Access +// +// CloudflareAccess wraps any AuthProvider with the headers required by +// Cloudflare Access (cf-access-jwt-assertion and CF_Authorization cookie). +// The edge token is typically a service token or application token obtained +// from Cloudflare: +// +// base := v1.StaticToken("my-gateway-token") +// auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN")) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// CloudflareAccess composes with any auth provider, including RefreshableToken +// for automatic token refresh: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// refreshAuth, err := v1.RefreshableToken(tokenSource) +// if err != nil { +// log.Fatal(err) +// } +// auth, err := edge.CloudflareAccess(refreshAuth, cfToken) +// if err != nil { +// log.Fatal(err) +// } +// +// # WebSocket Tunnel +// +// TunnelProxy bridges gRPC connections over a WebSocket tunnel for edge +// proxies that reject standard HTTP/2 POST requests. The tunnel carries +// its own edge token for proxy authentication, independent of the +// application-level auth provider. +// +// Create a tunnel proxy pointed at the gateway, then dial the proxy's +// local address from the gRPC client: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// os.Getenv("CF_ACCESS_TOKEN"), +// ) +// if err != nil { +// log.Fatal(err) +// } +// defer tunnel.Close() +// +// auth := v1.StaticToken("my-gateway-token") +// client, err := v1.NewClient(v1.Config{ +// Address: tunnel.Addr(), +// Auth: auth, +// TLS: &v1.TLSConfig{Insecure: true}, // local tunnel +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Use functional options to configure TLS, logging, and close timeout: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// cfToken, +// edge.WithTunnelTLS(&tls.Config{RootCAs: customCertPool}), +// edge.WithTunnelLogger(myLogger), +// edge.WithCloseTimeout(10*time.Second), +// ) +// +// Close drains in-flight connections gracefully. If draining exceeds the +// configured timeout (default 5 seconds), remaining connections are +// force-closed: +// +// err := tunnel.Close() // safe to call multiple times +package edge diff --git a/sdk/go/openshell/v1/edge/tunnel.go b/sdk/go/openshell/v1/edge/tunnel.go new file mode 100644 index 0000000000..2e50cd1570 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel.go @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const defaultCloseTimeout = 5 * time.Second + +// tunnelConfig holds configuration set by TunnelOption functions. +type tunnelConfig struct { + logger types.Logger + tlsConfig *tls.Config + closeTimeout time.Duration +} + +// TunnelOption configures TunnelProxy behavior. +type TunnelOption func(*tunnelConfig) + +// WithTunnelLogger sets the structured logger for tunnel events. +func WithTunnelLogger(l types.Logger) TunnelOption { + return func(c *tunnelConfig) { + c.logger = l + } +} + +// WithTunnelTLS sets TLS configuration for the WebSocket connection (wss://). +func WithTunnelTLS(cfg *tls.Config) TunnelOption { + return func(c *tunnelConfig) { + c.tlsConfig = cfg + } +} + +// WithCloseTimeout sets the maximum time Close waits for in-flight +// connections to drain before force-closing. Default is 5 seconds. +func WithCloseTimeout(d time.Duration) TunnelOption { + return func(c *tunnelConfig) { + c.closeTimeout = d + } +} + +// TunnelProxy bridges gRPC connections over a WebSocket tunnel. +// The gRPC client dials TunnelProxy.Addr() instead of the remote gateway. +// Each accepted connection spawns a goroutine that dials the gateway over +// WebSocket and copies data bidirectionally. +type TunnelProxy struct { + listener net.Listener + gatewayURL string + edgeToken string + logger types.Logger + closeTimeout time.Duration + httpClient *http.Client + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +// NewTunnelProxy creates a tunnel proxy that forwards TCP connections +// through a WebSocket connection to gatewayURL. The edgeToken authenticates +// with the edge proxy via Cloudflare Access headers on the WebSocket +// handshake. +// +// Returns error if gatewayURL is empty or invalid, or if edgeToken is empty. +func NewTunnelProxy(gatewayURL, edgeToken string, opts ...TunnelOption) (*TunnelProxy, error) { + if gatewayURL == "" { + return nil, errors.New("gateway URL must not be empty") + } + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + // Validate the URL parses correctly. + u, err := url.Parse(gatewayURL) + if err != nil { + return nil, fmt.Errorf("invalid gateway URL: %w", err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, fmt.Errorf("gateway URL must use ws:// or wss:// scheme, got %q", u.Scheme) + } + if u.Host == "" { + return nil, errors.New("gateway URL must include a host") + } + + cfg := tunnelConfig{ + closeTimeout: defaultCloseTimeout, + } + for _, o := range opts { + o(&cfg) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + var httpClient *http.Client + if cfg.tlsConfig != nil { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: cfg.tlsConfig, + }, + } + } + + tp := &TunnelProxy{ + listener: listener, + gatewayURL: gatewayURL, + edgeToken: edgeToken, + logger: cfg.logger, + closeTimeout: cfg.closeTimeout, + httpClient: httpClient, + ctx: ctx, + cancel: cancel, + } + + // Start the accept loop. + tp.wg.Add(1) + go tp.acceptLoop() + + return tp, nil +} + +// Addr returns the local address the gRPC client should dial. +func (tp *TunnelProxy) Addr() string { + return tp.listener.Addr().String() +} + +// Close drains in-flight connections (up to the configured timeout, +// default 5s) then force-closes any remaining connections. All goroutines +// are cleaned up. Safe to call multiple times; the second and subsequent +// calls return immediately. +func (tp *TunnelProxy) Close() error { + tp.closeOnce.Do(func() { + tp.mu.Lock() + tp.closing = true + tp.mu.Unlock() + + // Stop accepting new connections. + tp.closeErr = tp.listener.Close() + + // Wait for in-flight connections to drain, with a timeout. + done := make(chan struct{}) + go func() { + tp.wg.Wait() + close(done) + }() + + select { + case <-done: + // All goroutines drained cleanly. + case <-time.After(tp.closeTimeout): + // Timeout reached; cancel all bridge contexts to force-close. + if tp.logger != nil { + tp.logger.Info("tunnel close timeout reached, force-closing") + } + tp.cancel() + <-done + } + // Always cancel to release the context tree. + tp.cancel() + }) + return tp.closeErr +} + +// acceptLoop runs in a goroutine. It accepts local TCP connections and +// spawns a bridge goroutine for each one. +func (tp *TunnelProxy) acceptLoop() { + defer tp.wg.Done() + + for { + conn, err := tp.listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + if tp.logger != nil { + tp.logger.Error(err, "tunnel accept error") + } + time.Sleep(10 * time.Millisecond) + continue + } + + tp.mu.Lock() + if tp.closing { + tp.mu.Unlock() + _ = conn.Close() + return + } + tp.wg.Add(1) + tp.mu.Unlock() + + if tp.logger != nil { + tp.logger.Debug("tunnel connection accepted", "remote", conn.RemoteAddr().String()) + } + + go tp.bridge(conn) + } +} + +// bridge dials the gateway over WebSocket and copies data bidirectionally +// between the local TCP connection and the WebSocket connection. +func (tp *TunnelProxy) bridge(local net.Conn) { + defer tp.wg.Done() + defer func() { _ = local.Close() }() + + ctx, cancel := context.WithCancel(tp.ctx) + defer cancel() + + // Build WebSocket dial options with edge auth headers. + dialOpts := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "cf-access-jwt-assertion": []string{tp.edgeToken}, + "cookie": []string{fmt.Sprintf("CF_Authorization=%s", tp.edgeToken)}, + }, + } + if tp.httpClient != nil { + dialOpts.HTTPClient = tp.httpClient + } + + dialCtx, dialCancel := context.WithTimeout(ctx, 10*time.Second) + defer dialCancel() + + wsConn, _, err := websocket.Dial(dialCtx, tp.gatewayURL, dialOpts) + if err != nil { + if tp.logger != nil { + tp.logger.Error(err, "tunnel websocket dial failed") + } + return + } + defer func() { _ = wsConn.CloseNow() }() + + // Set a generous read limit for gRPC frames. + wsConn.SetReadLimit(64 * 1024 * 1024) // 64 MiB + + // Convert the WebSocket connection to a net.Conn for bidirectional I/O. + remote := websocket.NetConn(ctx, wsConn, websocket.MessageBinary) + + // Bidirectional copy. + done := make(chan struct{}, 2) + + // Local -> Remote (WebSocket) + go func() { + _, _ = io.Copy(remote, local) + done <- struct{}{} + }() + + // Remote (WebSocket) -> Local + go func() { + _, _ = io.Copy(local, remote) + done <- struct{}{} + }() + + // Wait for one direction to finish, then tear down both. + <-done + cancel() + _ = local.Close() + <-done + + if tp.logger != nil { + tp.logger.Debug("tunnel bridge closed") + } +} diff --git a/sdk/go/openshell/v1/edge/tunnel_test.go b/sdk/go/openshell/v1/edge/tunnel_test.go new file mode 100644 index 0000000000..79daa062f7 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel_test.go @@ -0,0 +1,494 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "slices" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// echoWSHandler accepts a WebSocket connection and echoes every binary +// message back to the sender until the client disconnects. +func echoWSHandler(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = conn.CloseNow() }() + + ctx := r.Context() + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return + } + if err := conn.Write(ctx, typ, data); err != nil { + return + } + } +} + +// startEchoServer starts an HTTP test server that upgrades connections to +// WebSocket and echoes binary messages. Returns the server and its ws:// URL. +func startEchoServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + // Convert http://host:port to ws://host:port. + wsURL := "ws" + srv.URL[len("http"):] + return srv, wsURL +} + +// startTLSEchoServer starts a TLS HTTP test server. Returns the server, +// its wss:// URL, and a tls.Config that trusts the server's certificate. +func startTLSEchoServer(t *testing.T) (*httptest.Server, string, *tls.Config) { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + wssURL := "wss" + srv.URL[len("https"):] + + certPool := x509.NewCertPool() + certPool.AddCert(srv.Certificate()) + tlsCfg := &tls.Config{ + RootCAs: certPool, + } + return srv, wssURL, tlsCfg +} + +// testLogger captures log messages for assertions. +type testLogger struct { + mu sync.Mutex + messages []string +} + +func (l *testLogger) Debug(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "DEBUG: "+msg) +} + +func (l *testLogger) Info(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "INFO: "+msg) +} + +func (l *testLogger) Error(_ error, msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "ERROR: "+msg) +} + +func (l *testLogger) Messages() []string { + l.mu.Lock() + defer l.mu.Unlock() + cp := make([]string, len(l.messages)) + copy(cp, l.messages) + return cp +} + +// --- Test: NewTunnelProxy creation --- + +func TestNewTunnelProxy_ValidURL(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "edge-token-123") + require.NoError(t, err) + require.NotNil(t, tp) + defer func() { _ = tp.Close() }() + + // Addr() must return a non-empty, dialable address. + addr := tp.Addr() + assert.NotEmpty(t, addr) + + // Verify the address is dialable. + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestNewTunnelProxy_EmptyURL(t *testing.T) { + _, err := NewTunnelProxy("", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway URL") +} + +func TestNewTunnelProxy_InvalidURL(t *testing.T) { + _, err := NewTunnelProxy("://bad-url", "edge-token-123") + require.Error(t, err) +} + +func TestNewTunnelProxy_WrongScheme(t *testing.T) { + _, err := NewTunnelProxy("http://gateway.example.com", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "ws:// or wss://") +} + +func TestNewTunnelProxy_EmptyHost(t *testing.T) { + _, err := NewTunnelProxy("ws://", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "must include a host") +} + +func TestNewTunnelProxy_EmptyEdgeToken(t *testing.T) { + _, wsURL := startEchoServer(t) + + _, err := NewTunnelProxy(wsURL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestNewTunnelProxy_TokenNotInError(t *testing.T) { + // Verify error messages do not leak the edge token. + _, err := NewTunnelProxy("", "super-secret-token-abc") + require.Error(t, err) + assert.NotContains(t, err.Error(), "super-secret-token-abc") +} + +// --- Test: Addr --- + +func TestTunnelProxy_Addr_Dialable(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + addr := tp.Addr() + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + assert.NotEmpty(t, host) + assert.NotEmpty(t, port) +} + +// --- Test: Close on unused proxy --- + +func TestTunnelProxy_Close_Unused(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Close immediately without any connections should return nil. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close drains in-flight connections --- + +func TestTunnelProxy_Close_DrainsInFlight(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(5*time.Second)) + require.NoError(t, err) + + // Establish a connection through the tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + // Send data through the tunnel and verify echo. + testData := []byte("hello tunnel") + _, err = conn.Write(testData) + require.NoError(t, err) + + // Give the tunnel time to relay. + time.Sleep(100 * time.Millisecond) + + // Close the client connection first so the bridge goroutine can drain. + _ = conn.Close() + + // Now close the tunnel; should drain cleanly. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close force-closes after timeout --- + +func TestTunnelProxy_Close_ForceClosesAfterTimeout(t *testing.T) { + // Use a slow handler that holds connections open. + slowHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + return + } + defer func() { _ = wsConn.CloseNow() }() + // Hold the connection open for a long time. + ctx := r.Context() + select { + case <-ctx.Done(): + case <-time.After(30 * time.Second): + } + }) + srv := httptest.NewServer(slowHandler) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + // Use a very short close timeout and a logger to verify timeout logging. + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(200*time.Millisecond), WithTunnelLogger(logger)) + require.NoError(t, err) + + // Establish a connection that will be held open by the slow handler. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write something to trigger the WebSocket dial. + _, _ = conn.Write([]byte("trigger")) + + // Give the tunnel time to establish the bridge. + time.Sleep(100 * time.Millisecond) + + // Close should force-close after the short timeout, not hang. + start := time.Now() + err = tp.Close() + elapsed := time.Since(start) + + // Should complete within a reasonable time (timeout + margin). + assert.Less(t, elapsed, 2*time.Second, "Close should not hang beyond timeout") + // err may or may not be nil depending on force-close; we don't assert on it. + _ = err + + // Verify the timeout was logged. + msgs := logger.Messages() + assert.True(t, slices.Contains(msgs, "INFO: tunnel close timeout reached, force-closing"), + "expected timeout log message, got: %v", msgs) +} + +// --- Test: Concurrent Close is safe --- + +func TestTunnelProxy_Close_ConcurrentSafe(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Call Close concurrently from multiple goroutines. + var wg sync.WaitGroup + errs := make([]error, 10) + for i := range errs { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = tp.Close() + }(i) + } + wg.Wait() + + // All calls should succeed without panic. + for _, e := range errs { + assert.NoError(t, e) + } +} + +// --- Test: Goroutine cleanup --- + +func TestTunnelProxy_GoroutineCleanup(t *testing.T) { + _, wsURL := startEchoServer(t) + + // Record baseline goroutine count. + runtime.GC() + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Open several connections. + conns := make([]net.Conn, 5) + for i := range conns { + c, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + _, _ = fmt.Fprintf(c, "msg-%d", i) + conns[i] = c + } + + // Let bridges establish. + time.Sleep(100 * time.Millisecond) + + // Close all client connections. + for _, c := range conns { + _ = c.Close() + } + + // Close the tunnel. + err = tp.Close() + require.NoError(t, err) + + // Wait for goroutines to wind down. + time.Sleep(200 * time.Millisecond) + runtime.GC() + + // Goroutine count should return to near baseline. + // Allow a small margin for runtime goroutines. + final := runtime.NumGoroutine() + assert.LessOrEqual(t, final, baseline+3, + "goroutine leak: baseline=%d, final=%d", baseline, final) +} + +// --- Test: Concurrent streams --- + +func TestTunnelProxy_ConcurrentStreams(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + const streamCount = 10 + var wg sync.WaitGroup + errs := make(chan error, streamCount) + + for i := range streamCount { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + if err != nil { + errs <- fmt.Errorf("stream %d dial: %w", idx, err) + return + } + defer func() { _ = conn.Close() }() + + msg := fmt.Sprintf("stream-%d-data", idx) + _, err = conn.Write([]byte(msg)) + if err != nil { + errs <- fmt.Errorf("stream %d write: %w", idx, err) + return + } + + // Read echo response. + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, err := io.ReadFull(conn, buf) + if err != nil { + errs <- fmt.Errorf("stream %d read: %w (got %d bytes)", idx, err, n) + return + } + + if string(buf) != msg { + errs <- fmt.Errorf("stream %d: expected %q, got %q", idx, msg, string(buf)) + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } +} + +// --- Test: TLS option --- + +func TestTunnelProxy_TLSOption(t *testing.T) { + _, wssURL, tlsCfg := startTLSEchoServer(t) + + tp, err := NewTunnelProxy(wssURL, "tok", WithTunnelTLS(tlsCfg)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // Verify we can communicate through the TLS tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + msg := []byte("tls-echo-test") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + assert.Equal(t, msg, buf) +} + +// --- Test: Logger option --- + +func TestTunnelProxy_LoggerOption(t *testing.T) { + _, wsURL := startEchoServer(t) + + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithTunnelLogger(logger)) + require.NoError(t, err) + + // Open a connection to trigger log events. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + _, _ = conn.Write([]byte("log-test")) + + // Give the tunnel time to process. + time.Sleep(200 * time.Millisecond) + + _ = conn.Close() + + err = tp.Close() + require.NoError(t, err) + + // Logger should have received at least one message. + msgs := logger.Messages() + assert.NotEmpty(t, msgs, "logger should receive log events") +} + +// --- Test: WithCloseTimeout option --- + +func TestWithCloseTimeout(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(10*time.Second)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // We can't directly inspect the config, but creation should succeed. + assert.NotNil(t, tp) +} + +// --- Test: Data flows through the tunnel --- + +func TestTunnelProxy_DataRoundTrip(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Send data and verify round-trip through the WebSocket echo server. + msg := []byte("round-trip-payload-12345") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + + assert.Equal(t, msg, buf) +} diff --git a/sdk/go/openshell/v1/fake/broadcaster.go b/sdk/go/openshell/v1/fake/broadcaster.go new file mode 100644 index 0000000000..f5d01d4743 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster.go @@ -0,0 +1,121 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const watchChannelBuffer = 100 + +// watchBroadcaster manages a set of watchers and broadcasts events to them. +// Each watcher can optionally filter events by resource name. +type watchBroadcaster[T any] struct { + mu sync.Mutex + watchers []*fakeWatcher[T] +} + +// newWatchBroadcaster creates a new watchBroadcaster. +func newWatchBroadcaster[T any]() *watchBroadcaster[T] { + return &watchBroadcaster[T]{} +} + +// Watch registers a new watcher. If name is non-empty, the watcher only +// receives events matching that name. If name is empty, all events are +// delivered. The returned WatchInterface must be stopped by the caller. +func (b *watchBroadcaster[T]) Watch(name string) types.WatchInterface[T] { + ch := make(chan types.Event[T], watchChannelBuffer) + w := &fakeWatcher[T]{ + ch: ch, + name: name, + } + + b.mu.Lock() + b.watchers = append(b.watchers, w) + b.mu.Unlock() + + return w +} + +// Broadcast sends an event to all registered watchers whose name filter +// matches (or whose filter is empty). Stopped watchers are skipped and +// cleaned up lazily. +func (b *watchBroadcaster[T]) Broadcast(event types.Event[T], name string) { + b.mu.Lock() + defer b.mu.Unlock() + + active := b.watchers[:0] + for _, w := range b.watchers { + if w.isStopped() { + continue + } + active = append(active, w) + + if w.name != "" && w.name != name { + continue + } + + w.send(event) + } + b.watchers = active +} + +// StopAll closes all active watchers. +func (b *watchBroadcaster[T]) StopAll() { + b.mu.Lock() + defer b.mu.Unlock() + + for _, w := range b.watchers { + w.Stop() + } + b.watchers = nil +} + +// fakeWatcher implements types.WatchInterface[T] with a buffered channel +// and optional name filter. +type fakeWatcher[T any] struct { + ch chan types.Event[T] + name string + once sync.Once + stopped bool + mu sync.Mutex +} + +// ResultChan returns the channel delivering watch events. +func (w *fakeWatcher[T]) ResultChan() <-chan types.Event[T] { + return w.ch +} + +// send delivers an event to the watcher under its lock, preventing a +// race between Broadcast (send) and Stop (close) on w.ch. +func (w *fakeWatcher[T]) send(event types.Event[T]) { + w.mu.Lock() + defer w.mu.Unlock() + if w.stopped { + return + } + select { + case w.ch <- event: + default: + } +} + +// Stop closes the event channel. It is safe to call multiple times. +func (w *fakeWatcher[T]) Stop() { + w.once.Do(func() { + w.mu.Lock() + defer w.mu.Unlock() + w.stopped = true + close(w.ch) + }) +} + +// isStopped returns true if Stop has been called. +func (w *fakeWatcher[T]) isStopped() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.stopped +} diff --git a/sdk/go/openshell/v1/fake/broadcaster_test.go b/sdk/go/openshell/v1/fake/broadcaster_test.go new file mode 100644 index 0000000000..e3605503e5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster_test.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T005: watchBroadcaster tests --- + +func TestWatchBroadcaster_Watch_ReceivesEvents(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + defer w.Stop() + + item := &testItem{Name: "alpha", Value: "v1"} + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: item}, "alpha") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +func TestWatchBroadcaster_Watch_NameFiltering(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher filtered to "alpha" only + wAlpha := b.Watch("alpha") + defer wAlpha.Stop() + + // Watcher filtered to "beta" only + wBeta := b.Watch("beta") + defer wBeta.Stop() + + // Broadcast event for "alpha" + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // alpha watcher should receive event + select { + case ev := <-wAlpha.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("alpha watcher: timed out waiting for event") + } + + // beta watcher should NOT receive event + select { + case ev := <-wBeta.ResultChan(): + t.Fatalf("beta watcher: unexpected event %v", ev) + case <-time.After(50 * time.Millisecond): + // Expected: no event for beta + } +} + +func TestWatchBroadcaster_Watch_EmptyNameReceivesAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher with empty name receives all events + w := b.Watch("") + defer w.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "beta"}}, "beta") + + received := make([]string, 0, 2) + for i := 0; i < 2; i++ { + select { + case ev := <-w.ResultChan(): + received = append(received, ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } + assert.ElementsMatch(t, []string{"alpha", "beta"}, received) +} + +func TestWatchBroadcaster_MultipleWatchers(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + defer w1.Stop() + w2 := b.Watch("") + defer w2.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // Both watchers should receive the event + for _, w := range []types.WatchInterface[*testItem]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } +} + +func TestWatchBroadcaster_Stop_ClosesChannel(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Channel should be closed after Stop + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestWatchBroadcaster_Stop_Idempotent(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + + // Multiple stops should not panic + w.Stop() + w.Stop() +} + +func TestWatchBroadcaster_StopAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("alpha") + + b.StopAll() + + // Both channels should be closed + _, ok1 := <-w1.ResultChan() + assert.False(t, ok1, "w1 channel should be closed after StopAll") + + _, ok2 := <-w2.ResultChan() + assert.False(t, ok2, "w2 channel should be closed after StopAll") +} + +func TestWatchBroadcaster_BroadcastAfterStop_NoDelivery(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Broadcasting after a watcher stops should not panic + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") +} + +func TestWatchBroadcaster_StoppedWatcher_RemovedFromBroadcast(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("") + + // Stop w1, keep w2 + w1.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // w2 should still receive events + select { + case ev := <-w2.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on w2") + } + + w2.Stop() +} diff --git a/sdk/go/openshell/v1/fake/config.go b/sdk/go/openshell/v1/fake/config.go new file mode 100644 index 0000000000..3bb94605c5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/config.go @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeConfigClient implements v1.ConfigInterface. All methods return +// Unimplemented because configuration management requires a real gateway. +type fakeConfigClient struct { + closedFunc func() bool +} + +// newFakeConfigClient creates a new fakeConfigClient. +func newFakeConfigClient(closedFunc func() bool) *fakeConfigClient { + return &fakeConfigClient{closedFunc: closedFunc} +} + +// GetSandbox returns Unimplemented. +func (c *fakeConfigClient) GetSandbox(_ context.Context, _ string) (*types.SandboxConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetSandbox is not supported by the fake client"} +} + +// GetGateway returns Unimplemented. +func (c *fakeConfigClient) GetGateway(_ context.Context) (*types.GatewayConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetGateway is not supported by the fake client"} +} + +// Update returns Unimplemented. A nil update is rejected with InvalidArgument +// to match the real client's behavior. +func (c *fakeConfigClient) Update(_ context.Context, update *types.ConfigUpdate) (*types.ConfigUpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if update == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "update must not be nil"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Compile-time check that fakeConfigClient implements v1.ConfigInterface. +var _ v1.ConfigInterface = (*fakeConfigClient)(nil) diff --git a/sdk/go/openshell/v1/fake/config_test.go b/sdk/go/openshell/v1/fake/config_test.go new file mode 100644 index 0000000000..9f86e739ba --- /dev/null +++ b/sdk/go/openshell/v1/fake/config_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T020: fakeConfigClient stub tests --- + +func TestFakeConfig_GetSandbox_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetSandbox(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetGateway_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetSandbox_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetSandbox(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_GetGateway_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T033: MergeOperations acceptance test --- + +func TestFakeConfig_Update_MergeOperationsAccepted(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), &types.ConfigUpdate{ + Name: "sandbox-1", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + }) + require.Error(t, err) + // Should return Unimplemented (not InvalidArgument) — MergeOperations are now accepted + assert.True(t, types.IsUnimplemented(err)) + assert.False(t, types.IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/fake/doc.go b/sdk/go/openshell/v1/fake/doc.go new file mode 100644 index 0000000000..2b17dfcd63 --- /dev/null +++ b/sdk/go/openshell/v1/fake/doc.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package fake provides an in-memory fake implementation of the OpenShell SDK +// client interfaces for use in consumer test suites. +// +// The fake client follows the client-go/kubernetes/fake pattern: it maintains +// in-memory stores for sandboxes and providers, supports watch event broadcasting, +// and returns the same StatusError codes as the real client for equivalent error +// conditions (NotFound, AlreadyExists, Unavailable, Unimplemented). +// +// All operations are safe for concurrent use from multiple goroutines. +// +// # Usage +// +// Create a FakeClient, exercise the sandbox lifecycle, and assert results: +// +// func TestSandboxLifecycle(t *testing.T) { +// client := fake.NewClient() +// defer client.Close() +// +// ctx := context.Background() +// +// // Create a sandbox — starts in Provisioning phase +// sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) +// require.NoError(t, err) +// assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) +// +// // Wait until ready — transitions synchronously in the fake +// sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") +// require.NoError(t, err) +// assert.Equal(t, types.SandboxReady, sb.Status.Phase) +// +// // Clean up +// require.NoError(t, client.Sandboxes().Delete(ctx, "my-sandbox")) +// } +package fake diff --git a/sdk/go/openshell/v1/fake/exec.go b/sdk/go/openshell/v1/fake/exec.go new file mode 100644 index 0000000000..d8a4627b53 --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.ExecInterface = (*fakeExecClient)(nil) + +// fakeExecClient implements v1.ExecInterface. All methods return +// Unimplemented because command execution requires a real sandbox runtime. +type fakeExecClient struct { + closedFunc func() bool +} + +// newFakeExecClient creates a new fakeExecClient. +func newFakeExecClient(closedFunc func() bool) *fakeExecClient { + return &fakeExecClient{closedFunc: closedFunc} +} + +// Run returns Unimplemented. +func (c *fakeExecClient) Run(_ context.Context, _ string, _ []string, _ ...v1.ExecOptions) (*types.ExecResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Run is not supported by the fake client"} +} + +// Stream returns Unimplemented. +func (c *fakeExecClient) Stream(_ context.Context, _ string, _ []string, _ ...v1.ExecOptions) (v1.ExecStream, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Stream is not supported by the fake client"} +} + +// Interactive returns Unimplemented. +func (c *fakeExecClient) Interactive(_ context.Context, _ string, _ []string, _, _ uint32, _ ...v1.ExecOptions) (v1.InteractiveSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Interactive is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/exec_test.go b/sdk/go/openshell/v1/fake/exec_test.go new file mode 100644 index 0000000000..028a4e09b4 --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T021: Exec stub tests --- + +func TestExec_Run_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Run(ctx, "sandbox-1", []string{"echo", "hello"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Stream_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "sandbox-1", []string{"tail", "-f", "/var/log/app.log"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Interactive_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Run_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Run(ctx, "sandbox-1", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Stream_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "sandbox-1", []string{"tail"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Interactive_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/fake.go b/sdk/go/openshell/v1/fake/fake.go new file mode 100644 index 0000000000..c29634e25b --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Client implements v1.ClientInterface with in-memory stores. It is +// designed for testing consumers of the OpenShell SDK without requiring a +// real gRPC connection. Create one with NewClient. +type Client struct { + sandboxStore *objectStore[*types.Sandbox] + providerStore *objectStore[*types.Provider] + sandboxBroadcaster *watchBroadcaster[*types.Sandbox] + + sandboxes v1.SandboxInterface + providers v1.ProviderInterface + services v1.ServiceInterface + exec v1.ExecInterface + files v1.FileInterface + health v1.HealthInterface + ssh v1.SSHInterface + tcp v1.TCPInterface + cfg v1.ConfigInterface + policy v1.PolicyInterface + + closeOnce sync.Once + closed bool + mu sync.RWMutex // guards closed flag +} + +// ClientOption configures a Client during construction. +type ClientOption func(*Client) + +// WithHealthResult returns an option that configures the health sub-client +// to return the given result instead of the default healthy response. +func WithHealthResult(r *types.HealthResult) ClientOption { + return func(fc *Client) { + fc.health = newFakeHealthClient(r, fc.isClosed) + } +} + +// NewClient creates a new Client with all sub-clients wired up. +// Options (e.g., WithHealthResult) are applied after the default setup. +func NewClient(opts ...ClientOption) *Client { + fc := &Client{ + sandboxStore: newobjectStore(sandboxName, copySandbox), + providerStore: newobjectStore(providerName, copyProvider), + sandboxBroadcaster: newWatchBroadcaster[*types.Sandbox](), + } + + fc.sandboxes = newFakeSandboxClient(fc.sandboxStore, fc.sandboxBroadcaster, fc.isClosed) + fc.providers = newFakeProviderClient(fc.providerStore, fc.isClosed) + fc.services = newFakeServiceClient(fc.isClosed) + fc.exec = newFakeExecClient(fc.isClosed) + fc.files = newFakeFileClient(fc.isClosed) + fc.health = newFakeHealthClient(nil, fc.isClosed) + fc.ssh = newFakeSSHClient(fc.isClosed) + fc.tcp = newFakeTCPClient(fc.isClosed) + fc.cfg = newFakeConfigClient(fc.isClosed) + fc.policy = newFakePolicyClient(fc.isClosed) + + for _, opt := range opts { + opt(fc) + } + + return fc +} + +// isClosed returns true if the client has been closed. This is passed to +// all sub-clients as the closedFunc parameter. +func (fc *Client) isClosed() bool { + fc.mu.RLock() + defer fc.mu.RUnlock() + return fc.closed +} + +// Sandboxes returns the sandbox sub-client. +func (fc *Client) Sandboxes() v1.SandboxInterface { return fc.sandboxes } + +// Providers returns the provider sub-client. +func (fc *Client) Providers() v1.ProviderInterface { return fc.providers } + +// Services returns the service sub-client. +func (fc *Client) Services() v1.ServiceInterface { return fc.services } + +// Exec returns the exec sub-client. +func (fc *Client) Exec() v1.ExecInterface { return fc.exec } + +// Files returns the file sub-client. +func (fc *Client) Files() v1.FileInterface { return fc.files } + +// Health returns the health sub-client. +func (fc *Client) Health() v1.HealthInterface { return fc.health } + +// SSH returns the SSH session sub-client. +func (fc *Client) SSH() v1.SSHInterface { return fc.ssh } + +// TCP returns the TCP port forwarding sub-client. +func (fc *Client) TCP() v1.TCPInterface { return fc.tcp } + +// Config returns the configuration sub-client. +func (fc *Client) Config() v1.ConfigInterface { return fc.cfg } + +// Policy returns the policy management sub-client. +func (fc *Client) Policy() v1.PolicyInterface { return fc.policy } + +// Close marks the client as closed, stops all active watchers, and causes +// subsequent sub-client calls to return Unavailable. Safe to call multiple +// times. +func (fc *Client) Close() error { + fc.closeOnce.Do(func() { + fc.mu.Lock() + fc.closed = true + fc.mu.Unlock() + + fc.sandboxBroadcaster.StopAll() + }) + return nil +} + +// AddSandbox inserts a sandbox directly into the store without triggering +// watch events. This is intended for pre-seeding test fixtures before the +// test begins. The sandbox is deep-copied on insert. +func (fc *Client) AddSandbox(sb *types.Sandbox) { + fc.sandboxStore.Insert(sb) +} + +// AddProvider inserts a provider directly into the store without triggering +// any side effects. This is intended for pre-seeding test fixtures before +// the test begins. The provider is deep-copied on insert. +func (fc *Client) AddProvider(p *types.Provider) { + fc.providerStore.Insert(p) +} + +// Compile-time interface check. +var _ v1.ClientInterface = (*Client)(nil) diff --git a/sdk/go/openshell/v1/fake/fake_test.go b/sdk/go/openshell/v1/fake/fake_test.go new file mode 100644 index 0000000000..8be588119b --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T025: FakeClient Close tests --- + +func TestFakeClient_Close(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Close_Idempotent(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) + + err = fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Sandboxes_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Sandboxes().Create(ctx, "test", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Providers_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Providers().Create(ctx, &types.Provider{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Health_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Health().Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Exec_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Exec().Run(ctx, "sandbox", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Files_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + err := fc.Files().Upload(ctx, "sandbox", "/local", "/remote") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Watch_StoppedOnClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "") + require.NoError(t, err) + + _ = fc.Close() + + // Channel should be closed after FakeClient.Close + _, ok := <-w.ResultChan() + assert.False(t, ok, "watcher channel should be closed after FakeClient.Close") +} + +func TestFakeClient_WithHealthResult(t *testing.T) { + custom := &types.HealthResult{Healthy: false, Version: "broken"} + fc := NewClient(WithHealthResult(custom)) + ctx := context.Background() + + result, err := fc.Health().Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "broken", result.Version) +} + +func TestFakeClient_SubClients(t *testing.T) { + fc := NewClient() + + assert.NotNil(t, fc.Sandboxes()) + assert.NotNil(t, fc.Providers()) + assert.NotNil(t, fc.Exec()) + assert.NotNil(t, fc.Files()) + assert.NotNil(t, fc.Health()) +} + +// --- T013: Pre-seed tests --- + +func TestFakeClient_AddSandbox(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Spec: types.SandboxSpec{LogLevel: "debug"}, + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + } + + fc.AddSandbox(sb) + + got, err := fc.Sandboxes().Get(ctx, "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "pre-seeded", got.Name) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestFakeClient_AddSandbox_InList(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + fc.AddSandbox(&types.Sandbox{Name: "sb-1"}) + fc.AddSandbox(&types.Sandbox{Name: "sb-2"}) + + list, err := fc.Sandboxes().List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestFakeClient_AddSandbox_NoWatchEvents(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + fc.AddSandbox(&types.Sandbox{Name: "pre-seeded"}) + + // No event should be received — AddSandbox bypasses the broadcaster + select { + case ev := <-w.ResultChan(): + t.Fatalf("unexpected event: %v", ev) + default: + // Good — no event received + } +} + +func TestFakeClient_AddSandbox_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Labels: map[string]string{"env": "test"}, + } + fc.AddSandbox(sb) + + // Mutate the input + sb.Labels["env"] = "mutated" + + got, err := fc.Sandboxes().Get(ctx, "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) +} + +func TestFakeClient_AddProvider(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + fc.AddProvider(p) + + got, err := fc.Providers().Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestFakeClient_AddProvider_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + fc.AddProvider(p) + + // Mutate input + p.Spec.Config["model"] = "mutated" + + got, err := fc.Providers().Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} diff --git a/sdk/go/openshell/v1/fake/file.go b/sdk/go/openshell/v1/fake/file.go new file mode 100644 index 0000000000..1834ceb404 --- /dev/null +++ b/sdk/go/openshell/v1/fake/file.go @@ -0,0 +1,40 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.FileInterface = (*fakeFileClient)(nil) + +// fakeFileClient implements v1.FileInterface. All methods return +// Unimplemented because file transfer requires a real sandbox runtime. +type fakeFileClient struct { + closedFunc func() bool +} + +// newFakeFileClient creates a new fakeFileClient. +func newFakeFileClient(closedFunc func() bool) *fakeFileClient { + return &fakeFileClient{closedFunc: closedFunc} +} + +// Upload returns Unimplemented. +func (c *fakeFileClient) Upload(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Upload is not supported by the fake client"} +} + +// Download returns Unimplemented. +func (c *fakeFileClient) Download(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Download is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/file_test.go b/sdk/go/openshell/v1/fake/file_test.go new file mode 100644 index 0000000000..40cbcaa8b6 --- /dev/null +++ b/sdk/go/openshell/v1/fake/file_test.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T022: File stub tests --- + +func TestFile_Upload_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Upload(ctx, "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Download_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Download(ctx, "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Upload_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Upload(ctx, "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFile_Download_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Download(ctx, "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/health.go b/sdk/go/openshell/v1/fake/health.go new file mode 100644 index 0000000000..75bda3b3ce --- /dev/null +++ b/sdk/go/openshell/v1/fake/health.go @@ -0,0 +1,45 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeHealthClient implements v1.HealthInterface with a configurable +// health result. When no custom result is provided, Check returns a +// default healthy response. +type fakeHealthClient struct { + result *types.HealthResult + closedFunc func() bool +} + +// newFakeHealthClient creates a new fakeHealthClient. If result is nil, +// Check will return the default healthy response. +func newFakeHealthClient(result *types.HealthResult, closedFunc func() bool) *fakeHealthClient { + return &fakeHealthClient{ + result: result, + closedFunc: closedFunc, + } +} + +// Check returns the configured health result. If no custom result was +// provided, it returns {Healthy: true, Version: "fake"}. +func (c *fakeHealthClient) Check(_ context.Context) (*types.HealthResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if c.result != nil { + cp := *c.result + return &cp, nil + } + + return &types.HealthResult{ + Healthy: true, + Version: "fake", + }, nil +} diff --git a/sdk/go/openshell/v1/fake/health_test.go b/sdk/go/openshell/v1/fake/health_test.go new file mode 100644 index 0000000000..1fcdbc03e9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/health_test.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T017: Health check tests --- + +func TestHealth_DefaultHealthy(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "fake", result.Version) +} + +func TestHealth_ConfigurableResult(t *testing.T) { + custom := &types.HealthResult{ + Healthy: false, + Version: "v0.0.0-broken", + } + hc := newFakeHealthClient(custom, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "v0.0.0-broken", result.Version) +} + +func TestHealth_ClosedClient(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return true }) + ctx := context.Background() + + _, err := hc.Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go new file mode 100644 index 0000000000..df6a136901 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy.go @@ -0,0 +1,105 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakePolicyClient implements v1.PolicyInterface. All methods return +// Unimplemented because policy management requires a real gateway. +type fakePolicyClient struct { + closedFunc func() bool +} + +// newFakePolicyClient creates a new fakePolicyClient. +func newFakePolicyClient(closedFunc func() bool) *fakePolicyClient { + return &fakePolicyClient{closedFunc: closedFunc} +} + +// GetDraft returns Unimplemented. +func (c *fakePolicyClient) GetDraft(_ context.Context, _ string, _ ...v1.GetDraftOption) (*types.DraftPolicy, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraft is not supported by the fake client"} +} + +// ApproveDraftChunk returns Unimplemented. +func (c *fakePolicyClient) ApproveDraftChunk(_ context.Context, _, _ string) (*types.ApproveResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveDraftChunk is not supported by the fake client"} +} + +// RejectDraftChunk returns Unimplemented. +func (c *fakePolicyClient) RejectDraftChunk(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "RejectDraftChunk is not supported by the fake client"} +} + +// ApproveAllDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ApproveAllDraftChunks(_ context.Context, _ string, _ ...v1.ApproveAllOption) (*types.ApproveAllResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveAllDraftChunks is not supported by the fake client"} +} + +// ClearDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ClearDraftChunks(_ context.Context, _ string) (*types.ClearResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ClearDraftChunks is not supported by the fake client"} +} + +// GetDraftHistory returns Unimplemented. +func (c *fakePolicyClient) GetDraftHistory(_ context.Context, _ string) ([]types.DraftHistoryEntry, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraftHistory is not supported by the fake client"} +} + +// GetStatus returns Unimplemented. +func (c *fakePolicyClient) GetStatus(_ context.Context, _ string, _ ...v1.GetStatusOption) (*types.PolicyStatusResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakePolicyClient) List(_ context.Context, _ string, _ ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// EditDraftChunk returns Unimplemented. +func (c *fakePolicyClient) EditDraftChunk(_ context.Context, _, _ string, _ *types.NetworkPolicyRule) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "EditDraftChunk is not supported by the fake client"} +} + +// UndoDraftChunk returns Unimplemented. +func (c *fakePolicyClient) UndoDraftChunk(_ context.Context, _, _ string) (*types.UndoResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "UndoDraftChunk is not supported by the fake client"} +} + +// Compile-time check that fakePolicyClient implements v1.PolicyInterface. +var _ v1.PolicyInterface = (*fakePolicyClient)(nil) diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go new file mode 100644 index 0000000000..20a247d9c0 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T031: fakePolicyClient stub tests --- + +func TestFakePolicy_GetDraft_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraft(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_RejectDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.RejectDraftChunk(context.Background(), "sb-1", "chunk-1", "bad rule") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveAllDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveAllDraftChunks(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ClearDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ClearDraftChunks(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetDraftHistory_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraftHistory(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_List_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.List(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_EditDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.EditDraftChunk(context.Background(), "sb-1", "chunk-1", &types.NetworkPolicyRule{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_UndoDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.UndoDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +// --- Closed client tests --- + +func TestFakePolicy_GetDraft_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.GetDraft(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.ApproveDraftChunk(context.Background(), "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_RejectDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + err := c.RejectDraftChunk(context.Background(), "sb-1", "chunk-1", "reason") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/profile.go b/sdk/go/openshell/v1/fake/profile.go new file mode 100644 index 0000000000..34c7395096 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeProfileClient implements v1.ProfileInterface. All methods return +// Unimplemented because profile management requires a real server. +type fakeProfileClient struct { + closedFunc func() bool +} + +// newFakeProfileClient creates a new fakeProfileClient. +func newFakeProfileClient(closedFunc func() bool) *fakeProfileClient { + return &fakeProfileClient{closedFunc: closedFunc} +} + +// List returns Unimplemented. +func (c *fakeProfileClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeProfileClient) Get(_ context.Context, _ string) (*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// Import returns Unimplemented. +func (c *fakeProfileClient) Import(_ context.Context, _ []types.ProfileImportItem) (*types.ImportResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Import is not supported by the fake client"} +} + +// Update returns Unimplemented. +func (c *fakeProfileClient) Update(_ context.Context, _ string, _ uint64, _ types.ProfileImportItem) (*types.UpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Lint returns Unimplemented. +func (c *fakeProfileClient) Lint(_ context.Context, _ []types.ProfileImportItem) (*types.LintResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Lint is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeProfileClient) Delete(_ context.Context, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeProfileClient implements v1.ProfileInterface. +var _ v1.ProfileInterface = (*fakeProfileClient)(nil) diff --git a/sdk/go/openshell/v1/fake/profile_test.go b/sdk/go/openshell/v1/fake/profile_test.go new file mode 100644 index 0000000000..9dddc175f9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T027: fakeProfileClient stub tests --- + +func TestFakeProfile_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.List(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Get(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Import_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Import(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Update(context.Background(), "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Lint_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Lint(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.List(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Get(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Import_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Import(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Update(context.Background(), "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Lint_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Lint(context.Background(), []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/provider.go b/sdk/go/openshell/v1/fake/provider.go new file mode 100644 index 0000000000..c492dea1bf --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// providerName extracts the name from a Provider pointer for use as the +// objectStore key function. +func providerName(p *types.Provider) string { + return p.Name +} + +// copyProvider returns a deep copy of a Provider pointer. All maps are +// duplicated to prevent aliasing. +func copyProvider(p *types.Provider) *types.Provider { + if p == nil { + return nil + } + cp := *p + cp.Labels = copyStringMap(p.Labels) + cp.Spec = copyProviderSpec(p.Spec) + return &cp +} + +func copyProviderSpec(s types.ProviderSpec) types.ProviderSpec { + s.Credentials = copyStringMap(s.Credentials) + s.Config = copyStringMap(s.Config) + s.CredentialExpiresAt = copyTimeMap(s.CredentialExpiresAt) + return s +} + +// copyTimeMap returns a shallow copy of a string-to-time.Time map. +func copyTimeMap(m map[string]time.Time) map[string]time.Time { + if m == nil { + return nil + } + cp := make(map[string]time.Time, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// fakeProviderClient implements v1.ProviderInterface backed by an in-memory +// objectStore. +type fakeProviderClient struct { + store *objectStore[*types.Provider] + closedFunc func() bool + profiles *fakeProfileClient + refresh *fakeRefreshClient +} + +// newFakeProviderClient creates a new fakeProviderClient. +func newFakeProviderClient( + store *objectStore[*types.Provider], + closedFunc func() bool, +) *fakeProviderClient { + return &fakeProviderClient{ + store: store, + closedFunc: closedFunc, + profiles: newFakeProfileClient(closedFunc), + refresh: newFakeRefreshClient(closedFunc), + } +} + +// Profiles returns a sub-client for provider profile operations. +func (c *fakeProviderClient) Profiles() v1.ProfileInterface { + return c.profiles +} + +// Refresh returns a sub-client for credential refresh operations. +func (c *fakeProviderClient) Refresh() v1.RefreshInterface { + return c.refresh +} + +// Create adds a new provider. CreatedAt and ResourceVersion are set +// automatically. +func (c *fakeProviderClient) Create(_ context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + p := copyProvider(provider) + p.CreatedAt = time.Now() + p.ResourceVersion = 1 + + return c.store.Create(p) +} + +// Get retrieves a provider by name. +func (c *fakeProviderClient) Get(_ context.Context, name string) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(name) +} + +// List returns all providers. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeProviderClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.List(), nil +} + +// Update replaces an existing provider's data. ResourceVersion is +// incremented automatically. +func (c *fakeProviderClient) Update(_ context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + // Fetch existing to preserve CreatedAt and increment ResourceVersion + existing, err := c.store.Get(provider.Name) + if err != nil { + return nil, err + } + + p := copyProvider(provider) + p.CreatedAt = existing.CreatedAt + p.ResourceVersion = existing.ResourceVersion + 1 + + return c.store.Update(p) +} + +// Delete removes a provider by name. The operation is idempotent. +func (c *fakeProviderClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + c.store.Delete(name) + return nil +} + +// Ensure creates a provider if it does not exist, or updates it if it does. +func (c *fakeProviderClient) Ensure(ctx context.Context, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + _, err := c.store.Get(provider.Name) + if err != nil { + // Not found — create + if types.IsNotFound(err) { + return c.Create(ctx, provider) + } + return nil, err + } + // Exists — update + return c.Update(ctx, provider) +} diff --git a/sdk/go/openshell/v1/fake/provider_test.go b/sdk/go/openshell/v1/fake/provider_test.go new file mode 100644 index 0000000000..b02ab36388 --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider_test.go @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake provider client for testing. +func newTestProviderClient() *fakeProviderClient { + store := newobjectStore(providerName, copyProvider) + return newFakeProviderClient(store, func() bool { return false }) +} + +// --- T015: Provider CRUD tests --- + +func TestProvider_Create(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{ + Credentials: map[string]string{"api_key": "sk-test"}, + Config: map[string]string{"model": "gpt-4"}, + }, + } + + result, err := pc.Create(ctx, p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "openai", result.Type) + assert.Equal(t, "sk-test", result.Spec.Credentials["api_key"]) + assert.NotZero(t, result.CreatedAt) + assert.Equal(t, uint64(1), result.ResourceVersion) +} + +func TestProvider_Create_AlreadyExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{Name: "openai", Type: "openai"} + _, err := pc.Create(ctx, p) + require.NoError(t, err) + + _, err = pc.Create(ctx, p) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestProvider_Get(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai", Type: "openai"}) + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) +} + +func TestProvider_Get_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Get(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_List_Empty(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + list, err := pc.List(ctx) + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestProvider_List(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai", Type: "openai"}) + _, _ = pc.Create(ctx, &types.Provider{Name: "anthropic", Type: "anthropic"}) + + list, err := pc.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestProvider_Update(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + updated, err := pc.Update(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", updated.Spec.Config["model"]) + assert.Equal(t, uint64(2), updated.ResourceVersion) +} + +func TestProvider_Update_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Update(ctx, &types.Provider{Name: "nonexistent"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{Name: "openai"}) + + err := pc.Delete(ctx, "openai") + require.NoError(t, err) + + _, err = pc.Get(ctx, "openai") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete_Idempotent(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + err := pc.Delete(ctx, "nonexistent") + require.NoError(t, err) +} + +func TestProvider_Ensure_CreatesIfMissing(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + result, err := pc.Ensure(ctx, p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) + + // Verify it was stored + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestProvider_Ensure_UpdatesIfExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + result, err := pc.Ensure(ctx, &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) +} + +func TestProvider_DeepCopy_OnCreate(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + spec := types.ProviderSpec{ + Credentials: map[string]string{"key": "secret"}, + Config: map[string]string{"model": "gpt-4"}, + CredentialExpiresAt: map[string]time.Time{"key": time.Now()}, + } + p := &types.Provider{ + Name: "openai", + Labels: map[string]string{"env": "test"}, + Spec: spec, + } + + result, err := pc.Create(ctx, p) + require.NoError(t, err) + + // Mutate inputs + p.Labels["env"] = "mutated" + p.Spec.Credentials["key"] = "mutated" + p.Spec.Config["model"] = "mutated" + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "secret", got.Spec.Credentials["key"]) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) + + // Mutate returned object + result.Labels["env"] = "mutated-return" + got2, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestProvider_DeepCopy_OnGet(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + + got, err := pc.Get(ctx, "openai") + require.NoError(t, err) + + got.Spec.Config["model"] = "mutated" + + got2, err := pc.Get(ctx, "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got2.Spec.Config["model"]) +} + +// --- T020: Concurrent provider access tests --- + +func TestProvider_ConcurrentCreateGetListDeleteEnsure(_ *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("prov-%d-%d", id, j) + p := &types.Provider{Name: name, Type: "test"} + _, _ = pc.Create(ctx, p) + _, _ = pc.Get(ctx, name) + _, _ = pc.List(ctx) + _, _ = pc.Update(ctx, &types.Provider{Name: name, Type: "updated"}) + _, _ = pc.Ensure(ctx, &types.Provider{Name: name, Type: "ensured"}) + _ = pc.Delete(ctx, name) + } + }(i) + } + wg.Wait() +} diff --git a/sdk/go/openshell/v1/fake/refresh.go b/sdk/go/openshell/v1/fake/refresh.go new file mode 100644 index 0000000000..6f1dedd1ef --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeRefreshClient implements v1.RefreshInterface. All methods return +// Unimplemented because credential refresh requires a real server. +type fakeRefreshClient struct { + closedFunc func() bool +} + +// newFakeRefreshClient creates a new fakeRefreshClient. +func newFakeRefreshClient(closedFunc func() bool) *fakeRefreshClient { + return &fakeRefreshClient{closedFunc: closedFunc} +} + +// GetStatus returns Unimplemented. +func (c *fakeRefreshClient) GetStatus(_ context.Context, _, _ string) ([]*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// Configure returns Unimplemented. +func (c *fakeRefreshClient) Configure(_ context.Context, _ *types.RefreshConfig) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Configure is not supported by the fake client"} +} + +// Rotate returns Unimplemented. +func (c *fakeRefreshClient) Rotate(_ context.Context, _, _ string) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Rotate is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeRefreshClient) Delete(_ context.Context, _, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeRefreshClient implements v1.RefreshInterface. +var _ v1.RefreshInterface = (*fakeRefreshClient)(nil) diff --git a/sdk/go/openshell/v1/fake/refresh_test.go b/sdk/go/openshell/v1/fake/refresh_test.go new file mode 100644 index 0000000000..bc131816d3 --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T028: fakeRefreshClient stub tests --- + +func TestFakeRefresh_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Configure_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Configure(context.Background(), &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Rotate_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Rotate(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_GetStatus_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.GetStatus(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Configure_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Configure(context.Background(), &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Rotate_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Rotate(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go new file mode 100644 index 0000000000..0e1a97c138 --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -0,0 +1,504 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// sandboxName extracts the name from a Sandbox pointer for use as the +// objectStore key function. +func sandboxName(sb *types.Sandbox) string { + return sb.Name +} + +// copySandbox returns a deep copy of a Sandbox pointer. All maps, slices, +// and nested pointer fields are duplicated to prevent aliasing. +func copySandbox(sb *types.Sandbox) *types.Sandbox { + if sb == nil { + return nil + } + cp := *sb + cp.Labels = copyStringMap(sb.Labels) + cp.Spec = copySandboxSpec(sb.Spec) + cp.Status = copySandboxStatus(sb.Status) + return &cp +} + +func copySandboxSpec(s types.SandboxSpec) types.SandboxSpec { + s.Environment = copyStringMap(s.Environment) + s.Providers = copyStringSlice(s.Providers) + if s.Template != nil { + t := copySandboxTemplate(*s.Template) + s.Template = &t + } + if s.GPUCount != nil { + v := *s.GPUCount + s.GPUCount = &v + } + s.Policy = copySandboxPolicy(s.Policy) + return s +} + +// copySandboxPolicy returns a deep copy of a SandboxPolicy pointer. +// All sub-policies, slices, and map entries are duplicated. +func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { + if p == nil { + return nil + } + cp := *p + if p.Filesystem != nil { + fs := *p.Filesystem + fs.ReadOnly = copyStringSlice(p.Filesystem.ReadOnly) + fs.ReadWrite = copyStringSlice(p.Filesystem.ReadWrite) + cp.Filesystem = &fs + } + if p.Landlock != nil { + ll := *p.Landlock + cp.Landlock = &ll + } + if p.Process != nil { + pr := *p.Process + cp.Process = &pr + } + if p.NetworkPolicies != nil { + np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, rule := range p.NetworkPolicies { + r := rule + if rule.Endpoints != nil { + eps := make([]types.PolicyNetworkEndpoint, len(rule.Endpoints)) + for i, ep := range rule.Endpoints { + eps[i] = copyPolicyNetworkEndpoint(ep) + } + r.Endpoints = eps + } + if rule.Binaries != nil { + bins := make([]types.PolicyNetworkBinary, len(rule.Binaries)) + copy(bins, rule.Binaries) + r.Binaries = bins + } + np[k] = r + } + cp.NetworkPolicies = np + } + return &cp +} + +func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetworkEndpoint { + if ep.Ports != nil { + ports := make([]uint32, len(ep.Ports)) + copy(ports, ep.Ports) + ep.Ports = ports + } + if ep.Rules != nil { + rules := make([]types.L7Rule, len(ep.Rules)) + for i, r := range ep.Rules { + if r.Allow != nil { + a := *r.Allow + a.Query = copyL7QueryMap(r.Allow.Query) + a.Fields = copyStringSlice(r.Allow.Fields) + rules[i] = types.L7Rule{Allow: &a} + } + } + ep.Rules = rules + } + ep.AllowedIPs = copyStringSlice(ep.AllowedIPs) + if ep.DenyRules != nil { + drs := make([]types.L7DenyRule, len(ep.DenyRules)) + for i, dr := range ep.DenyRules { + dr.Query = copyL7QueryMap(dr.Query) + dr.Fields = copyStringSlice(dr.Fields) + drs[i] = dr + } + ep.DenyRules = drs + } + if ep.GraphqlPersistedQueries != nil { + gq := make(map[string]types.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + for k, v := range ep.GraphqlPersistedQueries { + v.Fields = copyStringSlice(v.Fields) + gq[k] = v + } + ep.GraphqlPersistedQueries = gq + } + return ep +} + +func copyL7QueryMap(m map[string]types.L7QueryMatcher) map[string]types.L7QueryMatcher { + if m == nil { + return nil + } + cp := make(map[string]types.L7QueryMatcher, len(m)) + for k, v := range m { + v.Any = copyStringSlice(v.Any) + cp[k] = v + } + return cp +} + +func copySandboxTemplate(t types.SandboxTemplate) types.SandboxTemplate { + t.Labels = copyStringMap(t.Labels) + t.Annotations = copyStringMap(t.Annotations) + t.Environment = copyStringMap(t.Environment) + if t.UserNamespaces != nil { + v := *t.UserNamespaces + t.UserNamespaces = &v + } + return t +} + +func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus { + if s.Conditions != nil { + conds := make([]types.SandboxCondition, len(s.Conditions)) + copy(conds, s.Conditions) + s.Conditions = conds + } + return s +} + +// copyStringMap returns a shallow copy of a string-to-string map. +func copyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + cp := make(map[string]string, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// copyStringSlice returns a copy of a string slice. +func copyStringSlice(s []string) []string { + if s == nil { + return nil + } + cp := make([]string, len(s)) + copy(cp, s) + return cp +} + +// fakeSandboxClient implements v1.SandboxInterface backed by an in-memory +// objectStore and watchBroadcaster. +type fakeSandboxClient struct { + store *objectStore[*types.Sandbox] + broadcaster *watchBroadcaster[*types.Sandbox] + closedFunc func() bool +} + +// newFakeSandboxClient creates a new fakeSandboxClient. +func newFakeSandboxClient( + store *objectStore[*types.Sandbox], + broadcaster *watchBroadcaster[*types.Sandbox], + closedFunc func() bool, +) *fakeSandboxClient { + return &fakeSandboxClient{ + store: store, + broadcaster: broadcaster, + closedFunc: closedFunc, + } +} + +// Create creates a new sandbox with Provisioning phase. +func (c *fakeSandboxClient) Create(_ context.Context, name string, spec *types.SandboxSpec, labels map[string]string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if spec == nil { + spec = &types.SandboxSpec{} + } + + sb := &types.Sandbox{ + Name: name, + CreatedAt: time.Now(), + Labels: copyStringMap(labels), + ResourceVersion: 1, + Spec: copySandboxSpec(*spec), + Status: types.SandboxStatus{ + SandboxName: name, + Phase: types.SandboxProvisioning, + }, + } + + result, err := c.store.Create(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventAdded, + Object: copySandbox(result), + }, name) + + return result, nil +} + +// Get retrieves a sandbox by name. +func (c *fakeSandboxClient) Get(_ context.Context, name string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(name) +} + +// List returns all sandboxes. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeSandboxClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.List(), nil +} + +// Delete removes a sandbox by name. The operation is idempotent. +func (c *fakeSandboxClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Atomically remove and retrieve the last-known object for the DELETED event. + deleted, existed := c.store.DeleteAndGet(name) + if !existed { + // Not found — idempotent delete + return nil + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventDeleted, + Object: deleted, + }, name) + + return nil +} + +// WaitReady transitions a sandbox to the Ready phase. In the fake +// implementation this happens synchronously — context cancellation is +// checked first to support timeout testing. +func (c *fakeSandboxClient) WaitReady(ctx context.Context, name string, _ ...v1.WaitOptions) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + // Check context before proceeding + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + sb, err := c.store.Get(name) + if err != nil { + return nil, err + } + + // If already ready, return immediately + if sb.Status.Phase == types.SandboxReady { + return sb, nil + } + + // Transition to Ready + sb.Status.Phase = types.SandboxReady + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, fmt.Errorf("updating sandbox phase: %w", err) + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, name) + + return updated, nil +} + +// Watch registers a watcher for sandbox events. If name is non-empty, only +// events for that sandbox are delivered. When StopOnTerminal is set, the +// watcher auto-closes after delivering a terminal phase event (SandboxReady +// or SandboxError). +func (c *fakeSandboxClient) Watch(_ context.Context, name string, opts ...v1.WatchOptions) (types.WatchInterface[*types.Sandbox], error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + inner := c.broadcaster.Watch(name) + + var stopOnTerminal bool + if len(opts) > 0 { + stopOnTerminal = opts[0].StopOnTerminal + } + + if !stopOnTerminal { + return inner, nil + } + + // Wrap with a filtering watcher that auto-stops after terminal events. + out := make(chan types.Event[*types.Sandbox], watchChannelBuffer) + tw := &terminalWatcher{ + ch: out, + inner: inner, + } + go func() { + defer close(out) + for ev := range inner.ResultChan() { + select { + case out <- ev: + default: + } + if ev.Object != nil && + (ev.Object.Status.Phase == types.SandboxReady || ev.Object.Status.Phase == types.SandboxError) { + inner.Stop() + return + } + } + }() + return tw, nil +} + +// terminalWatcher wraps an inner watcher and exposes its own output channel. +type terminalWatcher struct { + ch chan types.Event[*types.Sandbox] + inner types.WatchInterface[*types.Sandbox] + once sync.Once +} + +func (w *terminalWatcher) ResultChan() <-chan types.Event[*types.Sandbox] { + return w.ch +} + +func (w *terminalWatcher) Stop() { + w.once.Do(func() { + w.inner.Stop() + }) +} + +// AttachProvider adds a provider name to the sandbox's Spec.Providers list. +// If the provider is already attached, Attached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast. +func (c *fakeSandboxClient) AttachProvider(_ context.Context, sandboxName, providerName string, _ uint64) (*types.AttachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + // Check if already attached + for _, p := range sb.Spec.Providers { + if p == providerName { + return &types.AttachProviderResult{ + Sandbox: sb, + Attached: false, + }, nil + } + } + + sb.Spec.Providers = append(sb.Spec.Providers, providerName) + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.AttachProviderResult{ + Sandbox: updated, + Attached: true, + }, nil +} + +// DetachProvider removes a provider name from the sandbox's Spec.Providers +// list. If the provider is not attached, Detached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast when a provider is actually removed. +func (c *fakeSandboxClient) DetachProvider(_ context.Context, sandboxName, providerName string, _ uint64) (*types.DetachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + // Find and remove the provider + found := false + providers := make([]string, 0, len(sb.Spec.Providers)) + for _, p := range sb.Spec.Providers { + if p == providerName { + found = true + continue + } + providers = append(providers, p) + } + + if !found { + return &types.DetachProviderResult{ + Sandbox: sb, + Detached: false, + }, nil + } + + sb.Spec.Providers = providers + sb.ResourceVersion++ + + updated, err := c.store.Update(sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.DetachProviderResult{ + Sandbox: updated, + Detached: true, + }, nil +} + +// GetLogs returns Unimplemented — fake log retrieval is not yet supported. +func (c *fakeSandboxClient) GetLogs(_ context.Context, _ string, _ ...v1.LogOption) (*types.LogResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetLogs not implemented in fake client"} +} + +// ListProviders returns stub Provider objects for each provider name +// attached to the sandbox. The returned providers contain only the Name +// field, since the fake client does not maintain a full provider registry +// per sandbox. +func (c *fakeSandboxClient) ListProviders(_ context.Context, sandboxName string) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(sandboxName) + if err != nil { + return nil, err + } + + result := make([]*types.Provider, len(sb.Spec.Providers)) + for i, name := range sb.Spec.Providers { + result[i] = &types.Provider{Name: name} + } + return result, nil +} diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go new file mode 100644 index 0000000000..635455067a --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -0,0 +1,814 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake sandbox client for testing. +func newTestSandboxClient() *fakeSandboxClient { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + return newFakeSandboxClient(store, broadcaster, func() bool { return false }) +} + +// --- T008: Sandbox CRUD tests --- + +func TestSandbox_Create(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) + assert.Equal(t, "debug", sb.Spec.LogLevel) + assert.Equal(t, "test", sb.Labels["env"]) + assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + assert.NotZero(t, sb.CreatedAt) + assert.Equal(t, uint64(1), sb.ResourceVersion) +} + +func TestSandbox_Create_AlreadyExists(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + _, err = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestSandbox_Create_NilSpec(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", nil, nil) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) +} + +func TestSandbox_Get(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test-sb", got.Name) + assert.Equal(t, "info", got.Spec.LogLevel) +} + +func TestSandbox_Get_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Get(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_List_Empty(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + list, err := sc.List(ctx) + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestSandbox_List(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "sb-1", &types.SandboxSpec{}, nil) + _, _ = sc.Create(ctx, "sb-2", &types.SandboxSpec{}, nil) + + list, err := sc.List(ctx) + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestSandbox_Delete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + err := sc.Delete(ctx, "test-sb") + require.NoError(t, err) + + _, err = sc.Get(ctx, "test-sb") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_Delete_Idempotent(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Delete non-existent sandbox should not error + err := sc.Delete(ctx, "nonexistent") + require.NoError(t, err) +} + +func TestSandbox_DeepCopy_OnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + labels := map[string]string{"env": "test"} + spec := &types.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"KEY": "value"}, + } + + sb, err := sc.Create(ctx, "test-sb", spec, labels) + require.NoError(t, err) + + // Mutating inputs should not affect stored object + labels["env"] = "mutated" + spec.LogLevel = "mutated" + spec.Environment["KEY"] = "mutated" + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, "value", got.Spec.Environment["KEY"]) + + // Mutating returned object should not affect stored object + sb.Labels["env"] = "mutated-return" + got2, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestSandbox_DeepCopy_OnGet(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{ + Environment: map[string]string{"KEY": "value"}, + }, nil) + + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + + got.Spec.Environment["KEY"] = "mutated" + + got2, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, "value", got2.Spec.Environment["KEY"]) +} + +// --- T009: WaitReady tests --- + +func TestSandbox_WaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + sb, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) + + // Verify the store is also updated + got, err := sc.Get(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestSandbox_WaitReady_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.WaitReady(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_WaitReady_ContextCancellation(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err = sc.WaitReady(ctx, "test-sb") + require.Error(t, err) + // Should return a context error, not a status error + assert.ErrorIs(t, err, context.Canceled) +} + +func TestSandbox_WaitReady_AlreadyReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + // Make it ready + _, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // WaitReady on an already-ready sandbox should return immediately + sb, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) +} + +func TestSandbox_WaitReady_IncrementsResourceVersion(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + initialVersion := created.ResourceVersion + + ready, err := sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + assert.Greater(t, ready.ResourceVersion, initialVersion) +} + +func TestSandbox_WaitReady_ContextTimeout(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + // Override the sandbox phase to Error so WaitReady doesn't auto-transition + // Actually, with our simple fake, WaitReady transitions immediately unless context is done. + // So just test the context-cancelled path: + cancel() + _, err = sc.WaitReady(ctx, "test-sb") + require.Error(t, err) +} + +// --- T011: Watch tests --- + +func TestSandbox_Watch_AddedOnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + assert.Equal(t, "info", ev.Object.Spec.LogLevel) + case <-time.After(time.Second): + t.Fatal("timed out waiting for ADDED event") + } +} + +func TestSandbox_Watch_DeletedOnDelete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + err = sc.Delete(ctx, "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +func TestSandbox_Watch_ModifiedOnWaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event") + } +} + +func TestSandbox_Watch_NameFiltering(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Watch only "alpha" + w, err := sc.Watch(ctx, "alpha") + require.NoError(t, err) + defer w.Stop() + + // Create "beta" — should not be received + _, _ = sc.Create(ctx, "beta", &types.SandboxSpec{}, nil) + + // Create "alpha" — should be received + _, _ = sc.Create(ctx, "alpha", &types.SandboxSpec{}, nil) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered event") + } +} + +func TestSandbox_Watch_MultipleWatchers(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w1, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w1.Stop() + + w2, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w2.Stop() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + + for _, w := range []types.WatchInterface[*types.Sandbox]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on watcher") + } + } +} + +func TestSandbox_Watch_StopClosesChannel(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + + w.Stop() + + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestSandbox_Watch_DeletedEventContainsFullObject(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _ = sc.Delete(ctx, "test-sb") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + // Verify the DELETED event contains the full last-known object + assert.Equal(t, "debug", ev.Object.Spec.LogLevel) + assert.Equal(t, "test", ev.Object.Labels["env"]) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +// --- T019: Concurrent sandbox access tests --- + +func TestSandbox_ConcurrentCreateGetDeleteWatch(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + // Start a watcher to exercise broadcast under concurrency + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + // Drain watcher events in a background goroutine + done := make(chan struct{}) + go func() { + defer close(done) + for range w.ResultChan() { //nolint:revive // intentionally draining channel + } + }() + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("sb-%d-%d", id, j) + _, _ = sc.Create(ctx, name, &types.SandboxSpec{LogLevel: "info"}, nil) + _, _ = sc.Get(ctx, name) + _, _ = sc.List(ctx) + _, _ = sc.WaitReady(ctx, name) + _ = sc.Delete(ctx, name) + } + }(i) + } + wg.Wait() + + // Stop watcher and wait for drain goroutine + w.Stop() + <-done +} + +// --- T026: AttachProvider / DetachProvider / ListProviders tests --- + +func TestSandbox_AttachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + assert.Equal(t, "test-sb", result.Sandbox.Name) + assert.Contains(t, result.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_AttachProvider_AlreadyAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + + // Attach again — should return Attached=false (idempotent, already attached) + result2, err := sc.AttachProvider(ctx, "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.False(t, result2.Attached) +} + +func TestSandbox_AttachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.AttachProvider(ctx, "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_DetachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + detach, err := sc.DetachProvider(ctx, "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.True(t, detach.Detached) + assert.NotContains(t, detach.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_DetachProvider_NotAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.DetachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.False(t, result.Detached) +} + +func TestSandbox_DetachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.DetachProvider(ctx, "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_ListProviders(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // No providers yet + providers, err := sc.ListProviders(ctx, "test-sb") + require.NoError(t, err) + assert.Empty(t, providers) + + // Attach two providers + result, err := sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + _, err = sc.AttachProvider(ctx, "test-sb", "anthropic", result.Sandbox.ResourceVersion) + require.NoError(t, err) + + providers, err = sc.ListProviders(ctx, "test-sb") + require.NoError(t, err) + assert.Len(t, providers, 2) + + names := make([]string, len(providers)) + for i, p := range providers { + names[i] = p.Name + } + assert.Contains(t, names, "openai") + assert.Contains(t, names, "anthropic") +} + +func TestSandbox_ListProviders_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.ListProviders(ctx, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_AttachProvider_BroadcastsModified(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.AttachProvider(ctx, "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Contains(t, ev.Object.Spec.Providers, "openai") + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event from AttachProvider") + } +} + +// --- T033: StopOnTerminal tests for fake Watch --- + +func TestSandbox_Watch_StopOnTerminal_Ready(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Transition to Ready — this broadcasts a MODIFIED event with SandboxReady phase + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // Should receive the Ready event + var gotReady bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxReady { + gotReady = true + } + } + // Channel should be closed after the terminal event + assert.True(t, gotReady, "expected to receive a Ready event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_Error(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Manually transition to Error phase via store update + broadcast + sb.Status.Phase = types.SandboxError + sb.ResourceVersion++ + updated, err := sc.store.Update(sb) + require.NoError(t, err) + sc.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, "test-sb") + + // Should receive the Error event and then the channel closes + var gotError bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxError { + gotError = true + } + } + assert.True(t, gotError, "expected to receive an Error event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_False_DoesNotClose(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // Watch WITHOUT StopOnTerminal + w, err := sc.Watch(ctx, "test-sb") + require.NoError(t, err) + defer w.Stop() + + // Transition to Ready + _, err = sc.WaitReady(ctx, "test-sb") + require.NoError(t, err) + + // Receive the Ready event + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for Ready event") + } + + // Channel should still be open — verify by checking no close + select { + case _, ok := <-w.ResultChan(): + if !ok { + t.Fatal("channel closed unexpectedly when StopOnTerminal was not set") + } + // Got another event, that's fine + case <-time.After(100 * time.Millisecond): + // No event and not closed — correct behavior + } +} + +// --- T016: Sandbox Create with Policy --- + +func TestFakeSandboxCreateWithPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + spec := &types.SandboxSpec{ + LogLevel: "debug", + Policy: &types.SandboxPolicy{ + Version: 3, + Filesystem: &types.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp"}, + }, + Landlock: &types.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &types.ProcessPolicy{ + RunAsUser: "sandbox", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "web": { + Name: "web", + Endpoints: []types.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + }, + }, + } + + created, err := sc.Create(ctx, "policy-sb", spec, nil) + require.NoError(t, err) + + // Verify created sandbox has policy + require.NotNil(t, created.Spec.Policy) + assert.Equal(t, uint32(3), created.Spec.Policy.Version) + + // Get it back and verify all fields + got, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + require.NotNil(t, got.Spec.Policy) + + p := got.Spec.Policy + assert.Equal(t, uint32(3), p.Version) + + require.NotNil(t, p.Filesystem) + assert.True(t, p.Filesystem.IncludeWorkdir) + assert.Equal(t, []string{"/etc", "/usr/share"}, p.Filesystem.ReadOnly) + assert.Equal(t, []string{"/tmp"}, p.Filesystem.ReadWrite) + + require.NotNil(t, p.Landlock) + assert.Equal(t, "best_effort", p.Landlock.Compatibility) + + require.NotNil(t, p.Process) + assert.Equal(t, "sandbox", p.Process.RunAsUser) + assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + + require.Len(t, p.NetworkPolicies, 1) + webRule, ok := p.NetworkPolicies["web"] + require.True(t, ok) + assert.Equal(t, "web", webRule.Name) + require.Len(t, webRule.Endpoints, 1) + assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) + assert.Equal(t, uint32(443), webRule.Endpoints[0].Port) + + // Deep-copy isolation: mutate input spec, verify stored copy unchanged + spec.Policy.Version = 99 + spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} + + got2, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + assert.Equal(t, uint32(3), got2.Spec.Policy.Version) + assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) + + // Deep-copy isolation: mutate returned object, verify store unchanged + got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got3, err := sc.Get(ctx, "policy-sb") + require.NoError(t, err) + assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) +} + +func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "no-policy-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + assert.Nil(t, created.Spec.Policy) + + got, err := sc.Get(ctx, "no-policy-sb") + require.NoError(t, err) + assert.Nil(t, got.Spec.Policy) +} + +// --- T032: GetLogs stub tests --- + +func TestSandbox_GetLogs_ReturnsUnimplemented(t *testing.T) { + sc := newTestSandboxClient() + _, err := sc.GetLogs(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestSandbox_GetLogs_ClosedReturnsUnavailable(t *testing.T) { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + sc := newFakeSandboxClient(store, broadcaster, func() bool { return true }) + _, err := sc.GetLogs(context.Background(), "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/service.go b/sdk/go/openshell/v1/fake/service.go new file mode 100644 index 0000000000..3df23520cc --- /dev/null +++ b/sdk/go/openshell/v1/fake/service.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeServiceClient implements v1.ServiceInterface. All methods return +// Unimplemented because service exposure requires a real sandbox runtime. +type fakeServiceClient struct { + closedFunc func() bool +} + +// newFakeServiceClient creates a new fakeServiceClient. +func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient { + return &fakeServiceClient{closedFunc: closedFunc} +} + +// Expose returns Unimplemented. +func (c *fakeServiceClient) Expose(_ context.Context, _, _ string, _ uint32, _ bool) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Expose is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeServiceClient) Get(_ context.Context, _, _ string) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakeServiceClient) List(_ context.Context, _ string, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeServiceClient) Delete(_ context.Context, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeServiceClient implements v1.ServiceInterface. +var _ v1.ServiceInterface = (*fakeServiceClient)(nil) diff --git a/sdk/go/openshell/v1/fake/service_test.go b/sdk/go/openshell/v1/fake/service_test.go new file mode 100644 index 0000000000..9744ce5cb1 --- /dev/null +++ b/sdk/go/openshell/v1/fake/service_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T026: fakeServiceClient stub tests --- + +func TestFakeService_Expose_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Expose(context.Background(), "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Get(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.List(context.Background(), "sb1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + err := c.Delete(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Expose_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Expose(context.Background(), "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Get(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.List(context.Background(), "sb1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + err := c.Delete(context.Background(), "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/ssh.go b/sdk/go/openshell/v1/fake/ssh.go new file mode 100644 index 0000000000..654a5ab102 --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeSSHClient implements v1.SSHInterface. All methods return +// Unimplemented because SSH session management requires a real gateway. +type fakeSSHClient struct { + closedFunc func() bool +} + +// newFakeSSHClient creates a new fakeSSHClient. +func newFakeSSHClient(closedFunc func() bool) *fakeSSHClient { + return &fakeSSHClient{closedFunc: closedFunc} +} + +// CreateSession returns Unimplemented. +func (c *fakeSSHClient) CreateSession(_ context.Context, _ string) (*types.SSHSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "CreateSession is not supported by the fake client"} +} + +// RevokeSession returns Unimplemented. +func (c *fakeSSHClient) RevokeSession(_ context.Context, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "RevokeSession is not supported by the fake client"} +} + +// Tunnel returns Unimplemented. Ports outside 1-65535 and empty sandbox names +// are rejected with InvalidArgument to match the real client's behavior. +func (c *fakeSSHClient) Tunnel(_ context.Context, sandboxName string, port uint32, _ ...v1.TunnelOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Tunnel is not supported by the fake client"} +} + +// Compile-time check that fakeSSHClient implements v1.SSHInterface. +var _ v1.SSHInterface = (*fakeSSHClient)(nil) diff --git a/sdk/go/openshell/v1/fake/ssh_test.go b/sdk/go/openshell/v1/fake/ssh_test.go new file mode 100644 index 0000000000..fd18b84ffe --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T018: fakeSSHClient stub tests --- + +func TestFakeSSH_CreateSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.CreateSession(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_RevokeSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.RevokeSession(context.Background(), "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_CreateSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.CreateSession(context.Background(), "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeSSH_RevokeSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.RevokeSession(context.Background(), "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T014: Fake Tunnel tests --- + +func TestFakeSSH_Tunnel_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_WithTunnelOption(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22, v1.WithTunnelServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_InvalidPort(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + + _, err := c.Tunnel(context.Background(), "my-sandbox", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + + _, err = c.Tunnel(context.Background(), "my-sandbox", 65536) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_EmptySandboxName(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "", 22) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.Tunnel(context.Background(), "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/store.go b/sdk/go/openshell/v1/fake/store.go new file mode 100644 index 0000000000..e5c64c6441 --- /dev/null +++ b/sdk/go/openshell/v1/fake/store.go @@ -0,0 +1,137 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "fmt" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// objectStore is a generic, thread-safe, in-memory store for named objects. +// It deep-copies objects at all boundaries (insert and retrieval) to prevent +// callers from mutating internal state. +type objectStore[T any] struct { + mu sync.RWMutex + items map[string]T + nameFunc func(T) string + copyFunc func(T) T +} + +// newobjectStore creates a new objectStore with the given name-extraction +// and deep-copy functions. +func newobjectStore[T any](nameFunc func(T) string, copyFunc func(T) T) *objectStore[T] { + return &objectStore[T]{ + items: make(map[string]T), + nameFunc: nameFunc, + copyFunc: copyFunc, + } +} + +// Create adds a new object to the store. Returns AlreadyExists if an object +// with the same name already exists. The object is deep-copied on insert and +// a deep copy is returned. +func (s *objectStore[T]) Create(obj T) (T, error) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[name]; exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorAlreadyExists, + Message: fmt.Sprintf("%s already exists", name), + } + } + + stored := s.copyFunc(obj) + s.items[name] = stored + return s.copyFunc(stored), nil +} + +// Get retrieves an object by name. Returns NotFound if the object does not exist. +// The returned object is a deep copy. +func (s *objectStore[T]) Get(name string) (T, error) { + s.mu.RLock() + defer s.mu.RUnlock() + + obj, exists := s.items[name] + if !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + return s.copyFunc(obj), nil +} + +// List returns deep copies of all objects in the store. The order is not +// guaranteed. +func (s *objectStore[T]) List() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]T, 0, len(s.items)) + for _, obj := range s.items { + result = append(result, s.copyFunc(obj)) + } + return result +} + +// Update replaces an existing object in the store. Returns NotFound if the +// object does not exist. The object is deep-copied on insert and a deep copy +// is returned. +func (s *objectStore[T]) Update(obj T) (T, error) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[name]; !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + + stored := s.copyFunc(obj) + s.items[name] = stored + return s.copyFunc(stored), nil +} + +// Delete removes an object from the store by name. The operation is +// idempotent — deleting a non-existent object is a no-op. +func (s *objectStore[T]) Delete(name string) { + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, name) +} + +// DeleteAndGet atomically removes an object from the store and returns a +// deep copy of the removed object. Returns the zero value and false if the +// object did not exist. This avoids the race between a separate Get+Delete. +func (s *objectStore[T]) DeleteAndGet(name string) (T, bool) { + s.mu.Lock() + defer s.mu.Unlock() + + obj, exists := s.items[name] + if !exists { + var zero T + return zero, false + } + delete(s.items, name) + return s.copyFunc(obj), true +} + +// Insert directly places an object into the store without checking for +// duplicates. This is intended for pre-seeding test fixtures. The object +// is deep-copied on insert. +func (s *objectStore[T]) Insert(obj T) { + name := s.nameFunc(obj) + s.mu.Lock() + defer s.mu.Unlock() + s.items[name] = s.copyFunc(obj) +} diff --git a/sdk/go/openshell/v1/fake/store_test.go b/sdk/go/openshell/v1/fake/store_test.go new file mode 100644 index 0000000000..1eb738451d --- /dev/null +++ b/sdk/go/openshell/v1/fake/store_test.go @@ -0,0 +1,248 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// testItem is a simple struct used for objectStore tests. +type testItem struct { + Name string + Value string + Tags map[string]string +} + +func testItemName(t *testItem) string { return t.Name } + +func copyTestItem(t *testItem) *testItem { + if t == nil { + return nil + } + c := *t + if t.Tags != nil { + c.Tags = make(map[string]string, len(t.Tags)) + for k, v := range t.Tags { + c.Tags[k] = v + } + } + return &c +} + +func newTestStore() *objectStore[*testItem] { + return newobjectStore(testItemName, copyTestItem) +} + +// --- T004: objectStore CRUD tests --- + +func TestObjectStore_Create(t *testing.T) { + s := newTestStore() + + item := &testItem{Name: "alpha", Value: "v1"} + created, err := s.Create(item) + require.NoError(t, err) + assert.Equal(t, "alpha", created.Name) + assert.Equal(t, "v1", created.Value) +} + +func TestObjectStore_Create_AlreadyExists(t *testing.T) { + s := newTestStore() + + _, err := s.Create(&testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + _, err = s.Create(&testItem{Name: "alpha", Value: "v2"}) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err), "expected AlreadyExists error, got: %v", err) +} + +func TestObjectStore_Get(t *testing.T) { + s := newTestStore() + + _, err := s.Create(&testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "alpha", got.Name) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Get_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Get("nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_List_Empty(t *testing.T) { + s := newTestStore() + items := s.List() + assert.Empty(t, items) +} + +func TestObjectStore_List(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + _, _ = s.Create(&testItem{Name: "beta", Value: "v2"}) + + items := s.List() + assert.Len(t, items, 2) + + // Sort for deterministic comparison + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + assert.Equal(t, "alpha", items[0].Name) + assert.Equal(t, "beta", items[1].Name) +} + +func TestObjectStore_Update(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + + updated, err := s.Update(&testItem{Name: "alpha", Value: "v2"}) + require.NoError(t, err) + assert.Equal(t, "v2", updated.Value) + + // Verify the stored value is updated + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_Update_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Update(&testItem{Name: "nonexistent", Value: "v1"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_Delete(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + s.Delete("alpha") + + _, err := s.Get("alpha") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestObjectStore_Delete_Idempotent(_ *testing.T) { + s := newTestStore() + + // Deleting a non-existent item should not panic or error + s.Delete("nonexistent") + + // Create and delete twice + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1"}) + s.Delete("alpha") + s.Delete("alpha") // second delete should be idempotent +} + +func TestObjectStore_Insert(t *testing.T) { + s := newTestStore() + + // Insert bypasses duplicate checks (for pre-seeding) + s.Insert(&testItem{Name: "alpha", Value: "v1"}) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Insert_Overwrites(t *testing.T) { + s := newTestStore() + + s.Insert(&testItem{Name: "alpha", Value: "v1"}) + s.Insert(&testItem{Name: "alpha", Value: "v2"}) + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_DeepCopy_OnCreate(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + created, err := s.Create(original) + require.NoError(t, err) + + // Mutating original should not affect stored object + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) + + // Mutating returned object should not affect stored object + created.Value = "mutated-created" + got2, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) +} + +func TestObjectStore_DeepCopy_OnGet(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + got, err := s.Get("alpha") + require.NoError(t, err) + + // Mutating retrieved object should not affect stored object + got.Value = "mutated" + got.Tags["env"] = "mutated" + + got2, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) + assert.Equal(t, "test", got2.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnList(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(&testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + items := s.List() + require.Len(t, items, 1) + + // Mutating listed object should not affect stored object + items[0].Value = "mutated" + items[0].Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnInsert(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + s.Insert(original) + + // Mutating original should not affect stored object + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get("alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} diff --git a/sdk/go/openshell/v1/fake/tcp.go b/sdk/go/openshell/v1/fake/tcp.go new file mode 100644 index 0000000000..b39699c369 --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + "net" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeTCPClient implements v1.TCPInterface. All methods return +// Unimplemented because TCP port forwarding requires a real sandbox runtime. +type fakeTCPClient struct { + closedFunc func() bool +} + +// newFakeTCPClient creates a new fakeTCPClient. +func newFakeTCPClient(closedFunc func() bool) *fakeTCPClient { + return &fakeTCPClient{closedFunc: closedFunc} +} + +// Forward returns Unimplemented. Ports outside 1-65535 are rejected with +// InvalidArgument to match the real client's behavior. +func (c *fakeTCPClient) Forward(_ context.Context, _ string, port uint32, _ ...v1.ForwardOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Forward is not supported by the fake client"} +} + +// Listen validates inputs then returns Unimplemented. The fake does not bind +// any local port; it checks that sandboxName is non-empty, remotePort is in +// the range 1-65535, and localPort is in the range 0-65535. +func (c *fakeTCPClient) Listen(_ context.Context, sandboxName string, remotePort uint32, localPort uint32, _ ...v1.ListenOption) (net.Listener, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort)} + } + if localPort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Listen is not supported by the fake client"} +} + +// Compile-time check that fakeTCPClient implements v1.TCPInterface. +var _ v1.TCPInterface = (*fakeTCPClient)(nil) diff --git a/sdk/go/openshell/v1/fake/tcp_test.go b/sdk/go/openshell/v1/fake/tcp_test.go new file mode 100644 index 0000000000..2a8d1a2a70 --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T019: fakeTCPClient stub tests --- + +func TestFakeTCP_Forward_ReturnsUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Forward_WithForwardOption(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 8080, v1.WithForwardServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_InvalidPort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "sandbox-1", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +// --- T020: fakeTCPClient.Listen tests --- + +func TestFakeTCP_Listen_EmptySandboxName(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeTCP_Listen_InvalidRemotePort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := c.Listen(context.Background(), "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + }) + } +} + +func TestFakeTCP_Listen_ValidInputsReturnUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Listen_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Listen_WithOptions(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "my-sandbox", 8080, 0, + v1.WithBindAddress("0.0.0.0"), + v1.WithSSHTunnel(), + v1.WithListenServiceID("svc-1"), + ) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} diff --git a/sdk/go/openshell/v1/gateway/config.go b/sdk/go/openshell/v1/gateway/config.go new file mode 100644 index 0000000000..c0813986e9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// AuthMode represents the authentication mode configured for a gateway. +type AuthMode string + +// Known auth mode values matching the Rust CLI's gateway configuration. +const ( + // AuthModeNone indicates no authentication (default when auth_mode is + // unset or explicitly "none"). + AuthModeNone AuthMode = "" + + // AuthModePlaintext indicates an insecure plaintext connection with + // no TLS and no authentication. + AuthModePlaintext AuthMode = "plaintext" + + // AuthModeCloudflareJWT indicates Cloudflare Access JWT authentication + // using an edge token loaded from disk. + AuthModeCloudflareJWT AuthMode = "cloudflare_jwt" + + // AuthModeOIDC indicates OpenID Connect authentication using a + // refreshable token bundle loaded from disk. + AuthModeOIDC AuthMode = "oidc" + + // AuthModeMTLS indicates mutual TLS authentication. Currently + // unsupported; returns [ErrUnsupportedAuthMode] with guidance. + AuthModeMTLS AuthMode = "mtls" +) + +// ConfigSource identifies where a gateway configuration was found. +type ConfigSource string + +const ( + // SourceUser indicates the gateway was found in the user config + // directory ($XDG_CONFIG_HOME/openshell/gateways/). + SourceUser ConfigSource = "user" + + // SourceSystem indicates the gateway was found in the system config + // directory (/etc/openshell/gateways/). + SourceSystem ConfigSource = "system" +) + +// Config is a parsed representation of a gateway's on-disk metadata.json. +// It is an immutable snapshot captured at load time; subsequent changes to +// the on-disk files are not reflected. +type Config struct { + // Name is the validated gateway name. + Name string + + // Endpoint is the host:port address of the gateway. + Endpoint string + + // AuthMode is the resolved authentication mode. + AuthMode AuthMode + + // Source indicates whether the config came from the user or system + // directory. + Source ConfigSource + + // Dir is the absolute path to the gateway config directory. + Dir string + + // OIDCIssuer is the OIDC provider's issuer URL read from + // metadata.json. Empty when the gateway does not use OIDC auth. + OIDCIssuer string + + // OIDCClientID is the OAuth2 client ID read from metadata.json. + // Empty when the gateway does not use OIDC auth. + OIDCClientID string +} + +// Info is a lightweight summary of a gateway for listing purposes. +// It does not load tokens or validate config completeness. +type Info struct { + // Name is the gateway name derived from the directory listing. + Name string + + // Active indicates whether this is the currently active gateway. + Active bool + + // Source indicates whether the gateway is from the user or system + // directory. + Source ConfigSource +} + +// metadataJSON is the on-disk representation of metadata.json. +// Unknown fields are silently ignored for forward compatibility. +type metadataJSON struct { + Endpoint string `json:"gateway_endpoint"` + AuthMode string `json:"auth_mode"` + Name string `json:"name"` + OIDCIssuer string `json:"oidc_issuer"` + OIDCClientID string `json:"oidc_client_id"` +} + +// parseAuthMode converts a raw auth_mode string to the typed AuthMode. +// Empty string and "none" both map to AuthModeNone. +func parseAuthMode(raw string) (AuthMode, error) { + switch raw { + case "", "none": + return AuthModeNone, nil + case "plaintext": + return AuthModePlaintext, nil + case "cloudflare_jwt": + return AuthModeCloudflareJWT, nil + case "oidc": + return AuthModeOIDC, nil + case "mtls": + return AuthModeMTLS, nil + default: + return "", fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, raw) + } +} + +// parseMetadata reads and parses metadata.json from the given gateway +// directory. Unknown fields are silently ignored for forward compatibility +// with newer Rust CLI versions. +func parseMetadata(dir string) (*Config, error) { + path := filepath.Join(dir, "metadata.json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrConfigParse, err) + } + + var meta metadataJSON + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrConfigParse, path, err) + } + + if meta.Endpoint == "" { + return nil, fmt.Errorf("%w: missing gateway_endpoint in %s", ErrConfigParse, path) + } + + mode, err := parseAuthMode(meta.AuthMode) + if err != nil { + return nil, err + } + + return &Config{ + Name: meta.Name, + Endpoint: meta.Endpoint, + AuthMode: mode, + Dir: dir, + OIDCIssuer: meta.OIDCIssuer, + OIDCClientID: meta.OIDCClientID, + }, nil +} diff --git a/sdk/go/openshell/v1/gateway/config_test.go b/sdk/go/openshell/v1/gateway/config_test.go new file mode 100644 index 0000000000..8411d2eef9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config_test.go @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T010: metadata.json parsing tests --- + +func TestParseMetadata_ValidConfig(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"localhost:8080","auth_mode":"none","name":"prod"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "localhost:8080", cfg.Endpoint) + assert.Equal(t, AuthModeNone, cfg.AuthMode) + assert.Equal(t, dir, cfg.Dir) +} + +func TestParseMetadata_EmptyAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, AuthModeNone, cfg.AuthMode) +} + +func TestParseMetadata_AllAuthModes(t *testing.T) { + cases := []struct { + mode string + expected AuthMode + }{ + {"", AuthModeNone}, + {"none", AuthModeNone}, + {"plaintext", AuthModePlaintext}, + {"cloudflare_jwt", AuthModeCloudflareJWT}, + {"oidc", AuthModeOIDC}, + {"mtls", AuthModeMTLS}, + } + + for _, tc := range cases { + t.Run("mode_"+tc.mode, func(t *testing.T) { + dir := t.TempDir() + if tc.mode == "" { + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + } else { + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"`+tc.mode+`"}`) + } + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, tc.expected, cfg.AuthMode) + }) + } +} + +func TestParseMetadata_MissingEndpoint(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"auth_mode":"none","name":"prod"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "missing gateway_endpoint") +} + +func TestParseMetadata_MissingFile(t *testing.T) { + dir := t.TempDir() + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) +} + +func TestParseMetadata_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{invalid json}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseMetadata_UnknownFieldsIgnored(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"none", + "name":"prod", + "future_field":"some_value", + "another_new_thing": 42 + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +func TestParseMetadata_UnsupportedAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"kerberos"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "kerberos") +} + +func TestParseAuthMode(t *testing.T) { + cases := []struct { + input string + expected AuthMode + wantErr bool + }{ + {"", AuthModeNone, false}, + {"none", AuthModeNone, false}, + {"plaintext", AuthModePlaintext, false}, + {"cloudflare_jwt", AuthModeCloudflareJWT, false}, + {"oidc", AuthModeOIDC, false}, + {"mtls", AuthModeMTLS, false}, + {"unknown", "", true}, + {"NONE", "", true}, // case-sensitive + } + + for _, tc := range cases { + t.Run("input_"+tc.input, func(t *testing.T) { + mode, err := parseAuthMode(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, mode) + } + }) + } +} + +// --- T010: OIDC config field tests --- + +func TestParseMetadata_OIDCFields(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "name":"oidc-gw", + "oidc_issuer":"https://auth.example.com", + "oidc_client_id":"my-client-id" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "oidc-gw", cfg.Name) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, "https://auth.example.com", cfg.OIDCIssuer) + assert.Equal(t, "my-client-id", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsMissing(t *testing.T) { + // When OIDC fields are absent (older gateway or non-OIDC mode), + // the Config should have empty strings for OIDCIssuer/OIDCClientID. + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"cloudflare_jwt", + "name":"legacy-gw" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsEmpty(t *testing.T) { + // Explicit empty strings for OIDC fields should be handled + // gracefully (backward compatibility). + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "oidc_issuer":"", + "oidc_client_id":"" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +// writeJSON is a test helper that writes a metadata.json file. +func writeJSON(t *testing.T, dir, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/gateway/doc.go b/sdk/go/openshell/v1/gateway/doc.go new file mode 100644 index 0000000000..ef060530d7 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/doc.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package gateway reads on-disk gateway configurations created by the +// OpenShell Rust CLI and constructs fully wired SDK clients. +// +// The package resolves XDG config paths, validates gateway names, loads +// tokens lazily, maps auth modes to existing auth providers, and provides +// one-call convenience constructors. This eliminates 20+ lines of +// boilerplate for Go programs connecting to gateways managed by the CLI. +// +// # Quick Start +// +// Connect to a named gateway: +// +// client, err := gateway.NewClient("prod") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Connect to the active gateway (set via `openshell gateway use`): +// +// client, err := gateway.NewClient("") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Inspect configuration without creating a client: +// +// cfg, err := gateway.LoadConfig("staging") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Endpoint: %s, Auth: %s\n", cfg.Endpoint, cfg.AuthMode) +// +// List all configured gateways: +// +// gateways, err := gateway.ListGateways() +// if err != nil { +// log.Fatal(err) +// } +// for _, gw := range gateways { +// fmt.Printf("%s (active=%v, source=%s)\n", gw.Name, gw.Active, gw.Source) +// } +// +// # On-Disk Layout +// +// The package reads gateway metadata from the following locations: +// +// $XDG_CONFIG_HOME/openshell/gateways//metadata.json (user) +// /etc/openshell/gateways//metadata.json (system) +// +// Token files (edge_token, cf_token, oidc_token.json) sit alongside +// metadata.json and are loaded lazily on first authentication attempt. +// +// # Error Handling +// +// The package provides typed errors for precise failure classification: +// +// - [ErrGatewayNotFound]: no gateway directory found +// - [ErrConfigParse]: metadata.json missing or malformed +// - [ErrTokenLoad]: token file missing or unreadable +// - [ErrUnsupportedAuthMode]: unrecognized auth_mode value +// - [ErrInvalidGatewayName]: name fails validation +// - [ErrNoActiveGateway]: no active gateway configured +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. +package gateway diff --git a/sdk/go/openshell/v1/gateway/errors.go b/sdk/go/openshell/v1/gateway/errors.go new file mode 100644 index 0000000000..ef26774617 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import "errors" + +// Sentinel errors for gateway configuration failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrGatewayNotFound is returned when no gateway directory exists + // in either the user or system config paths. + ErrGatewayNotFound = errors.New("gateway: not found") + + // ErrConfigParse is returned when metadata.json is missing, + // unreadable, or contains invalid JSON. + ErrConfigParse = errors.New("gateway: config parse error") + + // ErrTokenLoad is returned when a token file (edge_token, + // oidc_token.json) is missing, unreadable, or malformed. + ErrTokenLoad = errors.New("gateway: token load error") + + // ErrUnsupportedAuthMode is returned when the auth_mode value in + // metadata.json is not recognized (not none, plaintext, + // cloudflare_jwt, oidc, or mtls). + ErrUnsupportedAuthMode = errors.New("gateway: unsupported auth mode") + + // ErrInvalidGatewayName is returned when a gateway name fails + // validation (empty, contains path separators, dots, or + // non-ASCII-alnum-dash-underscore characters). + ErrInvalidGatewayName = errors.New("gateway: invalid gateway name") + + // ErrNoActiveGateway is returned when no active gateway is + // configured (active_gateway file missing or empty). + ErrNoActiveGateway = errors.New("gateway: no active gateway") +) diff --git a/sdk/go/openshell/v1/gateway/errors_test.go b/sdk/go/openshell/v1/gateway/errors_test.go new file mode 100644 index 0000000000..09d8b40d6d --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- T011: Error type tests --- + +func TestSentinelErrors_ErrorsIs(t *testing.T) { + sentinels := []struct { + name string + err error + }{ + {"ErrGatewayNotFound", ErrGatewayNotFound}, + {"ErrConfigParse", ErrConfigParse}, + {"ErrTokenLoad", ErrTokenLoad}, + {"ErrUnsupportedAuthMode", ErrUnsupportedAuthMode}, + {"ErrInvalidGatewayName", ErrInvalidGatewayName}, + {"ErrNoActiveGateway", ErrNoActiveGateway}, + } + + for _, tc := range sentinels { + t.Run(tc.name+"_direct", func(t *testing.T) { + assert.True(t, errors.Is(tc.err, tc.err), + "errors.Is should match sentinel directly") + }) + + t.Run(tc.name+"_wrapped", func(t *testing.T) { + wrapped := fmt.Errorf("context: %w", tc.err) + assert.True(t, errors.Is(wrapped, tc.err), + "errors.Is should match through fmt.Errorf wrapping") + }) + + t.Run(tc.name+"_double_wrapped", func(t *testing.T) { + inner := fmt.Errorf("inner: %w", tc.err) + outer := fmt.Errorf("outer: %w", inner) + assert.True(t, errors.Is(outer, tc.err), + "errors.Is should match through double wrapping") + }) + } +} + +func TestSentinelErrors_NotConfused(t *testing.T) { + // Verify that different sentinel errors are not equal. + pairs := []struct { + a, b error + }{ + {ErrGatewayNotFound, ErrConfigParse}, + {ErrConfigParse, ErrTokenLoad}, + {ErrTokenLoad, ErrUnsupportedAuthMode}, + {ErrUnsupportedAuthMode, ErrInvalidGatewayName}, + {ErrInvalidGatewayName, ErrNoActiveGateway}, + {ErrNoActiveGateway, ErrGatewayNotFound}, + } + + for _, tc := range pairs { + t.Run(tc.a.Error()+"_vs_"+tc.b.Error(), func(t *testing.T) { + assert.False(t, errors.Is(tc.a, tc.b), + "different sentinels must not match") + }) + } +} + +func TestSentinelErrors_HaveMessages(t *testing.T) { + sentinels := []error{ + ErrGatewayNotFound, + ErrConfigParse, + ErrTokenLoad, + ErrUnsupportedAuthMode, + ErrInvalidGatewayName, + ErrNoActiveGateway, + } + + for _, err := range sentinels { + t.Run(err.Error(), func(t *testing.T) { + msg := err.Error() + assert.NotEmpty(t, msg) + assert.Contains(t, msg, "gateway:") + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go new file mode 100644 index 0000000000..8d1bd9d87b --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// NewClient creates a fully wired SDK client from an on-disk gateway +// configuration. If name is empty, the active gateway (set via +// `openshell gateway use`) is used. +// +// The function resolves the gateway directory, parses metadata.json, +// loads tokens lazily, maps the auth mode to an SDK auth provider, and +// applies any ClientOptions before delegating to [v1.NewClient]. +// +// NewClient is safe for concurrent use from multiple goroutines. +func NewClient(name string, opts ...ClientOption) (*v1.Client, error) { + cfg, err := loadConfigInternal(name) + if err != nil { + return nil, err + } + + // Apply caller options. + cc := &clientConfig{} + for _, o := range opts { + o(cc) + } + + // Resolve auth provider: caller override takes precedence. + auth := cc.auth + if auth == nil { + auth, err = resolveAuthProvider(cfg) + if err != nil { + return nil, err + } + } + + // Build the SDK Config. + sdkCfg := types.Config{ + Address: cfg.Endpoint, + Auth: auth, + } + + // Apply TLS: caller override or auth-mode defaults. + if cc.tls != nil { + sdkCfg.TLS = cc.tls + } else if cfg.AuthMode == AuthModePlaintext { + sdkCfg.TLS = &types.TLSConfig{Insecure: true} + } + + if cc.timeout > 0 { + sdkCfg.Timeout = cc.timeout + } + if cc.retryPolicy != nil { + sdkCfg.RetryPolicy = cc.retryPolicy + } + if cc.logger != nil { + sdkCfg.Logger = cc.logger + } + + return v1.NewClient(sdkCfg) +} + +// LoadConfig reads and parses a gateway's on-disk configuration without +// creating a client connection. If name is empty, the active gateway is +// used. +// +// The returned [Config] is an immutable snapshot; changes to the on-disk +// files after this call are not reflected. +// +// LoadConfig is safe for concurrent use from multiple goroutines. +func LoadConfig(name string) (*Config, error) { + return loadConfigInternal(name) +} + +// loadConfigInternal resolves the gateway name (including active gateway +// fallback), finds the config directory, and parses metadata.json. This +// shared implementation is used by both NewClient and LoadConfig. +func loadConfigInternal(name string) (*Config, error) { + // If name is empty, resolve the active gateway. + if name == "" { + activeName, err := resolveActiveGateway() + if err != nil { + return nil, err + } + name = activeName + } + + dir, source, err := resolveGatewayDir(name) + if err != nil { + return nil, err + } + + cfg, err := parseMetadata(dir) + if err != nil { + return nil, err + } + + // Override name from directory (validated) rather than metadata.json. + cfg.Name = name + cfg.Source = source + + return cfg, nil +} + +// ListGateways enumerates all available gateways from user and system +// directories. User gateways appear first. If the same name exists in +// both directories, only the user gateway is returned (user precedence). +// Returns an empty slice (not an error) when no gateways are configured. +// +// ListGateways is safe for concurrent use from multiple goroutines. +func ListGateways() ([]Info, error) { + seen := make(map[string]bool) + var result []Info + + activeName, _ := resolveActiveGateway() + + userBase, err := userConfigDir() + if err == nil { + names, listErr := listGatewayDirs(userBase) + if listErr != nil { + return nil, listErr + } + for _, name := range names { + seen[name] = true + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceUser, + }) + } + } + + sysNames, listErr := listGatewayDirs(systemConfigBase) + if listErr != nil { + return nil, listErr + } + for _, name := range sysNames { + if !seen[name] { + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceSystem, + }) + } + } + + return result, nil +} + +// resolveAuthProvider maps a Config's AuthMode to an SDK AuthProvider. +// Tokens are loaded lazily where possible. +func resolveAuthProvider(cfg *Config) (types.AuthProvider, error) { + switch cfg.AuthMode { + case AuthModeNone: + return v1.NoAuth(), nil + + case AuthModePlaintext: + return v1.NoAuth(), nil + + case AuthModeCloudflareJWT: + // Token loading is deferred to GetRequestMetadata so that + // NewClient succeeds even when the token file is missing. + // The error surfaces on first authentication attempt (FR-007). + return &lazyEdgeAuth{loader: &edgeTokenLoader{dir: cfg.Dir}}, nil + + case AuthModeOIDC: + src := newDiskTokenSource(cfg.Dir) + return v1.RefreshableToken(src) + + case AuthModeMTLS: + return nil, fmt.Errorf("%w: mtls is not yet supported; use WithAuth() to provide a custom auth provider", ErrUnsupportedAuthMode) + + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, cfg.AuthMode) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go new file mode 100644 index 0000000000..1462175f24 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -0,0 +1,477 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- Test helpers --- + +// setupGateway creates a gateway directory with the given metadata.json +// content under a temp XDG config dir. Returns the XDG root path. +func setupGateway(t *testing.T, name, metadataJSON string) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", metadataJSON) + + return tmp +} + +// setupGatewayWithTokens creates a gateway directory with metadata and +// token files. +func setupGatewayWithTokens(t *testing.T, name, metadataJSON string, tokens map[string]string) string { + t.Helper() + tmp := setupGateway(t, name, metadataJSON) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + for filename, content := range tokens { + writeFile(t, gwDir, filename, content) + } + + return tmp +} + +// --- T018: NewClient tests --- + +func TestNewClient_AuthModeNone(t *testing.T) { + setupGateway(t, "test-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("test-gw", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModePlaintext(t *testing.T) { + setupGateway(t, "plain-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"plaintext"}`) + + // Plaintext mode should auto-set insecure TLS. + client, err := NewClient("plain-gw") + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeCloudflareJWT(t *testing.T) { + setupGatewayWithTokens(t, "cf-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: "test-edge-token"}, + ) + + // StaticToken requires transport security, so use WithAuth override + // to bypass gRPC TLS requirement. Auth resolution is verified by + // TestResolveAuthProvider_CloudflareJWT. + client, err := NewClient("cf-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeOIDC(t *testing.T) { + setupGatewayWithTokens(t, "oidc-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`, + map[string]string{oidcTokenFile: `{"access_token":"test-oidc-token"}`}, + ) + + // RefreshableToken requires transport security, so use WithAuth + // override. Auth resolution verified by TestResolveAuthProvider_OIDC. + client, err := NewClient("oidc-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := NewClient("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestNewClient_InvalidName(t *testing.T) { + _, err := NewClient("../escape") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestNewClient_MissingEdgeToken(t *testing.T) { + setupGateway(t, "no-token-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Edge token loading is lazy (FR-007): NewClient succeeds even when + // the token file is missing. The error surfaces on first use via + // GetRequestMetadata, not at construction time. + _, err := NewClient("no-token-gw") + require.NoError(t, err) +} + +func TestNewClient_MissingOIDCToken(t *testing.T) { + setupGateway(t, "no-oidc-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`) + + _, err := NewClient("no-oidc-gw") + // OIDC uses RefreshableToken which defers the disk read to Token(), + // so NewClient should succeed. The error would come on first use. + // Let's verify it doesn't fail at construction time. + require.NoError(t, err) + assert.NoError(t, err) +} + +func TestNewClient_MTLSUnsupported(t *testing.T) { + setupGateway(t, "mtls-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"mtls"}`) + + _, err := NewClient("mtls-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") +} + +func TestNewClient_WithAuthOverride(t *testing.T) { + setupGateway(t, "override-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Even though auth_mode is cloudflare_jwt and there's no edge_token, + // the WithAuth override should bypass token loading entirely. + customAuth := &mockAuth{} + client, err := NewClient("override-gw", + WithAuth(customAuth), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithLogger(t *testing.T) { + setupGateway(t, "logger-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("logger-gw", + WithLogger(nil), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithOptions(t *testing.T) { + setupGateway(t, "opts-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("opts-gw", + WithTimeout(5*time.Second), + WithRetryPolicy(&types.RetryPolicy{MaxRetries: 3}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +// --- T019: Credential leak test --- + +func TestNewClient_NoCredentialLeaks(t *testing.T) { + // Test 1: Invalid gateway with path traversal attempt. + _, err := NewClient("../../../etc/passwd") + require.Error(t, err) + assert.NotContains(t, err.Error(), "passwd") + + // Test 2: Verify error from missing edge token does not reveal + // file system details beyond the generic message. + setupGateway(t, "no-edge-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + cfg := &Config{ + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "no-edge-gw"), + } + auth, authErr := resolveAuthProvider(cfg) + require.NoError(t, authErr) + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + + // Test 3: Verify credential values never appear in error strings. + secretToken := "SUPER_SECRET_TOKEN_12345" + setupGatewayWithTokens(t, "leak-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: secretToken}, + ) + // Use resolveAuthProvider directly to test token loading. + cfg = &Config{ + Name: "leak-gw", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "leak-gw"), + } + auth, err = resolveAuthProvider(cfg) + require.NoError(t, err) + + // The provider should not expose the token in its string form. + if stringer, ok := auth.(interface{ String() string }); ok { + assert.NotContains(t, stringer.String(), secretToken) + } +} + +func TestResolveAuthProvider_NoTokenLeaks(t *testing.T) { + secretToken := "CREDENTIAL_THAT_MUST_NOT_LEAK" + + // Create a gateway with a bad OIDC token. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + gwDir := filepath.Join(tmp, "openshell", "gateways", "leak-test") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + writeFile(t, gwDir, edgeTokenFile, secretToken) + + cfg := &Config{ + Name: "leak-test", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: gwDir, + } + + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + + // The auth provider should work but the token value should not + // appear in the provider's string representation (if any). + providerStr := "" + if stringer, ok := auth.(interface{ String() string }); ok { + providerStr = stringer.String() + assert.NotContains(t, providerStr, secretToken) + } + + // Verify the token IS used correctly via GetRequestMetadata. + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Contains(t, md["authorization"], secretToken) +} + +// --- Test helpers --- + +// mockAuth is a minimal AuthProvider for testing WithAuth overrides. +type mockAuth struct{} + +func (m *mockAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer mock-token"}, nil +} + +func (m *mockAuth) RequireTransportSecurity() bool { + return false +} + +// --- LoadConfig tests (T025 placeholder, implemented here for Phase 3 coverage) --- + +func TestLoadConfig_ValidConfig(t *testing.T) { + setupGateway(t, "cfg-gw", `{"gateway_endpoint":"host:443","auth_mode":"oidc","name":"ignored"}`) + + cfg, err := LoadConfig("cfg-gw") + require.NoError(t, err) + // Name comes from directory, not metadata.json "name" field. + assert.Equal(t, "cfg-gw", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, SourceUser, cfg.Source) + assert.NotEmpty(t, cfg.Dir) +} + +func TestLoadConfig_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := LoadConfig("missing-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestLoadConfig_FrozenSnapshot(t *testing.T) { + xdg := setupGateway(t, "snap-gw", `{"gateway_endpoint":"original:443","auth_mode":"none"}`) + + cfg, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "original:443", cfg.Endpoint) + + // Modify the on-disk file. + gwDir := filepath.Join(xdg, "openshell", "gateways", "snap-gw") + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"modified:443","auth_mode":"none"}`) + + // The previously loaded config should be unchanged. + assert.Equal(t, "original:443", cfg.Endpoint) + + // A new load should see the change. + cfg2, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "modified:443", cfg2.Endpoint) +} + +// --- resolveAuthProvider tests --- + +func TestResolveAuthProvider_None(t *testing.T) { + cfg := &Config{AuthMode: AuthModeNone} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_Plaintext(t *testing.T) { + cfg := &Config{AuthMode: AuthModePlaintext} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_CloudflareJWT(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "cf-jwt-token") + + cfg := &Config{AuthMode: AuthModeCloudflareJWT, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer cf-jwt-token", md["authorization"]) +} + +func TestResolveAuthProvider_OIDC(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token":"oidc-access-token"}`) + + cfg := &Config{AuthMode: AuthModeOIDC, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer oidc-access-token", md["authorization"]) +} + +func TestResolveAuthProvider_MTLS(t *testing.T) { + cfg := &Config{AuthMode: AuthModeMTLS} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") + assert.Contains(t, err.Error(), "WithAuth") +} + +func TestResolveAuthProvider_UnknownMode(t *testing.T) { + cfg := &Config{AuthMode: "alien_auth"} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) +} + +// --- T021/T023: Active gateway tests --- + +func TestNewClient_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "active-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + activeFile := filepath.Join(tmp, "openshell", "active_gateway") + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "active-gw") + _ = activeFile + + client, err := NewClient("", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NoActiveGateway(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := NewClient("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestLoadConfig_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "my-active", `{"gateway_endpoint":"host:443","auth_mode":"oidc"}`) + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "my-active") + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "my-active", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +// --- T027: ListGateways tests --- + +func TestListGateways_MultipleGateways(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"prod", "staging", "dev"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 3) + + names := make(map[string]bool) + for _, gw := range gateways { + names[gw.Name] = true + assert.Equal(t, SourceUser, gw.Source) + } + assert.True(t, names["prod"]) + assert.True(t, names["staging"]) + assert.True(t, names["dev"]) +} + +func TestListGateways_EmptyDirs(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Empty(t, gateways) +} + +func TestListGateways_ActiveStatus(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"alpha", "beta"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "beta") + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 2) + + for _, gw := range gateways { + if gw.Name == "beta" { + assert.True(t, gw.Active) + } else { + assert.False(t, gw.Active) + } + } +} diff --git a/sdk/go/openshell/v1/gateway/options.go b/sdk/go/openshell/v1/gateway/options.go new file mode 100644 index 0000000000..dfd16e89ca --- /dev/null +++ b/sdk/go/openshell/v1/gateway/options.go @@ -0,0 +1,63 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// clientConfig holds resolved options applied after gateway config +// resolution but before the v1.Client is created. +type clientConfig struct { + logger types.Logger + timeout time.Duration + tls *types.TLSConfig + auth types.AuthProvider + retryPolicy *types.RetryPolicy +} + +// ClientOption configures the behavior of [NewClient]. Options are +// applied after gateway configuration is resolved but before the +// underlying SDK client is created. +type ClientOption func(*clientConfig) + +// WithLogger sets the logger on the SDK client configuration. +func WithLogger(l types.Logger) ClientOption { + return func(c *clientConfig) { + c.logger = l + } +} + +// WithTimeout sets the connection timeout for the SDK client. +func WithTimeout(d time.Duration) ClientOption { + return func(c *clientConfig) { + c.timeout = d + } +} + +// WithTLS overrides the TLS settings derived from the gateway's auth mode. +// Use this to provide custom certificates or force insecure connections. +func WithTLS(cfg *types.TLSConfig) ClientOption { + return func(c *clientConfig) { + c.tls = cfg + } +} + +// WithAuth overrides the auth provider that would normally be resolved +// from the gateway's auth_mode. When set, the gateway package skips +// its own auth resolution and uses the provided provider directly. +func WithAuth(provider types.AuthProvider) ClientOption { + return func(c *clientConfig) { + c.auth = provider + } +} + +// WithRetryPolicy sets the retry policy on the SDK client configuration. +func WithRetryPolicy(p *types.RetryPolicy) ClientOption { + return func(c *clientConfig) { + c.retryPolicy = p + } +} diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go new file mode 100644 index 0000000000..fa0e1373c2 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +const ( + // appName is the application directory name used in XDG paths. + appName = "openshell" + + // gatewaySubdir is the subdirectory within the app config holding + // per-gateway directories. + gatewaySubdir = "gateways" + + // activeGatewayFile is the filename that stores the active gateway name. + activeGatewayFile = "active_gateway" + + // systemConfigBase is the system-wide config directory. + systemConfigBase = "/etc/openshell" +) + +// userConfigDir returns the user-specific configuration directory for +// OpenShell, following XDG Base Directory specification: +// +// $XDG_CONFIG_HOME/openshell (if XDG_CONFIG_HOME is set) +// ~/.config/openshell (fallback) +func userConfigDir() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if !filepath.IsAbs(xdg) { + return "", fmt.Errorf("XDG_CONFIG_HOME must be an absolute path, got %q", xdg) + } + return filepath.Join(xdg, appName), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + + return filepath.Join(home, ".config", appName), nil +} + +// systemGatewayDir returns the system-wide gateway config directory. +func systemGatewayDir() string { + return filepath.Join(systemConfigBase, gatewaySubdir) +} + +// resolveGatewayDir searches for a gateway directory by name, checking the +// user directory first, then the system directory. Returns the absolute +// directory path, the config source, or ErrGatewayNotFound. +func resolveGatewayDir(name string) (string, ConfigSource, error) { + if err := validateGatewayName(name); err != nil { + return "", "", err + } + + // Check user config dir first. + userBase, err := userConfigDir() + if err == nil { + userDir := filepath.Join(userBase, gatewaySubdir, name) + if info, statErr := os.Stat(userDir); statErr == nil && info.IsDir() { + return userDir, SourceUser, nil + } + } + + // Check system config dir. + sysDir := filepath.Join(systemGatewayDir(), name) + if info, statErr := os.Stat(sysDir); statErr == nil && info.IsDir() { + return sysDir, SourceSystem, nil + } + + return "", "", fmt.Errorf("%w: %q", ErrGatewayNotFound, name) +} + +// validateGatewayName checks that a gateway name is safe for use as a +// directory component. It rejects: +// - empty names +// - names containing path separators (/ or \) +// - names that are "." or ".." (directory traversal) +// - names containing "." (prevents hidden files and extension confusion) +// - names with characters outside ASCII alphanumerics, dashes, underscores +func validateGatewayName(name string) error { + if name == "" { + return fmt.Errorf("%w: name must not be empty", ErrInvalidGatewayName) + } + + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("%w: name must not contain path separators", ErrInvalidGatewayName) + } + + if strings.Contains(name, ".") { + return fmt.Errorf("%w: name must not contain dots", ErrInvalidGatewayName) + } + + for _, r := range name { + if !isValidNameRune(r) { + return fmt.Errorf("%w: name contains invalid character %q", ErrInvalidGatewayName, string(r)) + } + } + + return nil +} + +// resolveActiveGateway reads the active_gateway file from the user +// config directory and returns the validated gateway name. Returns +// ErrNoActiveGateway if the file is missing or empty. +func resolveActiveGateway() (string, error) { + userBase, err := userConfigDir() + if err != nil { + return "", fmt.Errorf("%w: cannot determine config directory: %v", ErrNoActiveGateway, err) + } + + path := filepath.Join(userBase, activeGatewayFile) + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("%w", ErrNoActiveGateway) + } + + name := strings.TrimSpace(string(data)) + if name == "" { + return "", fmt.Errorf("%w: active_gateway file is empty", ErrNoActiveGateway) + } + + if err := validateGatewayName(name); err != nil { + return "", fmt.Errorf("%w: active_gateway contains invalid name %q: %v", ErrNoActiveGateway, name, err) + } + + return name, nil +} + +// listGatewayDirs returns a list of gateway directories found under the +// given base path. Each entry is just the directory name (gateway name). +func listGatewayDirs(base string) ([]string, error) { + gatewaysDir := filepath.Join(base, gatewaySubdir) + entries, err := os.ReadDir(gatewaysDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var names []string + for _, e := range entries { + if e.IsDir() && validateGatewayName(e.Name()) == nil { + names = append(names, e.Name()) + } + } + return names, nil +} + +// isValidNameRune returns true if the rune is an ASCII letter, digit, +// dash, or underscore. +func isValidNameRune(r rune) bool { + if r > unicode.MaxASCII { + return false + } + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' +} diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go new file mode 100644 index 0000000000..547bd3fc3c --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T007: XDG resolution tests --- + +func TestUserConfigDir_XDGSet(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + dir, err := userConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmp, "openshell"), dir) +} + +func TestUserConfigDir_XDGUnset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + + dir, err := userConfigDir() + require.NoError(t, err) + + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "openshell"), dir) +} + +func TestSystemGatewayDir(t *testing.T) { + dir := systemGatewayDir() + assert.Equal(t, "/etc/openshell/gateways", dir) +} + +func TestResolveGatewayDir_UserDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + // Create a gateway directory in user config. + gwDir := filepath.Join(tmp, "openshell", "gateways", "prod") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("prod") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +func TestResolveGatewayDir_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, _, err := resolveGatewayDir("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestResolveGatewayDir_InvalidName(t *testing.T) { + _, _, err := resolveGatewayDir("../etc") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestResolveGatewayDir_UserPrecedenceOverSystem(t *testing.T) { + // This test verifies the search order: user dir is checked before + // system dir. We can only test the user dir path since we cannot + // write to /etc in tests. The logic is verified by the successful + // user dir resolution above. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", "shared") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("shared") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +// --- T008: Name validation tests --- + +func TestValidateGatewayName_ValidNames(t *testing.T) { + validNames := []string{ + "prod", + "staging", + "my-gateway", + "gateway_1", + "PROD", + "a", + "test-gateway-01", + "A_B_C", + } + + for _, name := range validNames { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + assert.NoError(t, err, "expected %q to be valid", name) + }) + } +} + +func TestValidateGatewayName_Empty(t *testing.T) { + err := validateGatewayName("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + assert.Contains(t, err.Error(), "empty") +} + +func TestValidateGatewayName_PathSeparators(t *testing.T) { + cases := []string{ + "../etc", + "foo/bar", + "foo\\bar", + "/absolute", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +func TestValidateGatewayName_Dots(t *testing.T) { + cases := []string{ + ".", + "..", + ".hidden", + "foo.bar", + "config.json", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +// --- T021: Active gateway resolution tests --- + +func TestResolveActiveGateway_ValidName(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte("my-gateway"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_WhitespaceHandling(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" my-gateway \n"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_FileMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestResolveActiveGateway_EmptyFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" \n"), 0o644)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestValidateGatewayName_SpecialCharacters(t *testing.T) { + cases := []struct { + name string + desc string + }{ + {"hello world", "space"}, + {"foo@bar", "at sign"}, + {"foo#bar", "hash"}, + {"café", "non-ASCII"}, + {"日本語", "unicode"}, + {"foo bar", "tab (space)"}, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + err := validateGatewayName(tc.name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/token.go b/sdk/go/openshell/v1/gateway/token.go new file mode 100644 index 0000000000..b4e13c359e --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token.go @@ -0,0 +1,171 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // edgeTokenFile is the primary edge token filename. + edgeTokenFile = "edge_token" + + // cfTokenFile is the legacy Cloudflare token filename, used as + // fallback when edge_token does not exist. + cfTokenFile = "cf_token" + + // oidcTokenFile is the OIDC token bundle filename. + oidcTokenFile = "oidc_token.json" +) + +// edgeTokenLoader provides lazy, thread-safe loading of the edge token +// from disk. The token is read on the first call to load() and cached +// for subsequent calls via sync.Once. +type edgeTokenLoader struct { + dir string + once sync.Once + token string + err error +} + +// load returns the edge token string, reading it from disk on the first +// call. It tries edge_token first, falling back to cf_token for legacy +// compatibility. The result (success or failure) is cached. +func (l *edgeTokenLoader) load() (string, error) { + l.once.Do(func() { + l.token, l.err = readEdgeToken(l.dir) + }) + return l.token, l.err +} + +// readEdgeToken reads the edge token from the given directory. It tries +// edge_token first, then cf_token as a legacy fallback. The file content +// is trimmed of surrounding whitespace. +func readEdgeToken(dir string) (string, error) { + // Try primary edge_token file. + primary := filepath.Join(dir, edgeTokenFile) + data, err := os.ReadFile(primary) + if err == nil { + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: edge_token file is empty", ErrTokenLoad) + } + return token, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, edgeTokenFile, err) + } + + // Fallback to legacy cf_token file (only when edge_token is absent). + legacy := filepath.Join(dir, cfTokenFile) + data, err = os.ReadFile(legacy) + if err != nil { + return "", fmt.Errorf("%w: neither edge_token nor cf_token found in gateway directory", ErrTokenLoad) + } + + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: cf_token file is empty", ErrTokenLoad) + } + + return token, nil +} + +// oidcBundle is the on-disk representation of oidc_token.json. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// diskTokenSource implements oauth2.TokenSource by reading oidc_token.json +// from disk on every Token() call. This allows the source to pick up +// tokens refreshed by the Rust CLI without process restart. +type diskTokenSource struct { + dir string +} + +// newDiskTokenSource returns an oauth2.TokenSource that reads +// oidc_token.json from the given gateway directory on each Token() call. +func newDiskTokenSource(dir string) oauth2.TokenSource { + return &diskTokenSource{dir: dir} +} + +// Token reads and parses oidc_token.json, returning an oauth2.Token. +// The file is read on every call to pick up CLI-refreshed tokens. +func (d *diskTokenSource) Token() (*oauth2.Token, error) { + path := filepath.Join(d.dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, oidcTokenFile, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s", ErrTokenLoad, oidcTokenFile) + } + + if bundle.AccessToken == "" { + return nil, fmt.Errorf("%w: missing access_token in %s", ErrTokenLoad, oidcTokenFile) + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry: prefer explicit "expiry" field, fall back to + // "expires_in" (seconds from now). + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr == nil { + tok.Expiry = expiry + } + // If expiry can't be parsed, leave it zero (token treated as + // non-expiring by oauth2). + } else if bundle.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(bundle.ExpiresIn) * time.Second) + } + + return tok, nil +} + +// lazyEdgeAuth implements types.AuthProvider for the cloudflare_jwt auth +// mode. Token loading is deferred to GetRequestMetadata so that +// NewClient succeeds even when the token file is missing on disk (FR-007). +// The error surfaces on the first authentication attempt. +type lazyEdgeAuth struct { + loader *edgeTokenLoader +} + +// GetRequestMetadata loads the edge token lazily and returns it as a +// Bearer authorization header. The first load is cached by the +// underlying edgeTokenLoader. +func (a *lazyEdgeAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + token, err := a.loader.load() + if err != nil { + return nil, err + } + return map[string]string{ + "authorization": "Bearer " + token, + }, nil +} + +// RequireTransportSecurity returns true because Bearer tokens must not +// be sent over plaintext connections. +func (a *lazyEdgeAuth) RequireTransportSecurity() bool { + return true +} diff --git a/sdk/go/openshell/v1/gateway/token_test.go b/sdk/go/openshell/v1/gateway/token_test.go new file mode 100644 index 0000000000..952c1e7d44 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token_test.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T014: Edge token loading tests --- + +func TestReadEdgeToken_PrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "my-edge-token-123") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "my-edge-token-123", token) +} + +func TestReadEdgeToken_PrimaryFileWithWhitespace(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " token-with-whitespace \n") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "token-with-whitespace", token) +} + +func TestReadEdgeToken_CfTokenFallback(t *testing.T) { + dir := t.TempDir() + // No edge_token file, only cf_token. + writeFile(t, dir, cfTokenFile, "legacy-cf-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "legacy-cf-token", token) +} + +func TestReadEdgeToken_PrimaryTakesPrecedence(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "primary-token") + writeFile(t, dir, cfTokenFile, "legacy-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "primary-token", token) +} + +func TestReadEdgeToken_MissingBothFiles(t *testing.T) { + dir := t.TempDir() + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "neither edge_token nor cf_token") +} + +func TestReadEdgeToken_EmptyPrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " \n") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestReadEdgeToken_EmptyFallbackFile(t *testing.T) { + dir := t.TempDir() + // No edge_token, only empty cf_token. + writeFile(t, dir, cfTokenFile, "") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestEdgeTokenLoader_Lazy(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "lazy-token") + + loader := &edgeTokenLoader{dir: dir} + + // First call reads from disk. + token1, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token1) + + // Remove the file. Second call should return cached value. + require.NoError(t, os.Remove(filepath.Join(dir, edgeTokenFile))) + + token2, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token2, "should return cached token") +} + +func TestEdgeTokenLoader_LazyError(t *testing.T) { + dir := t.TempDir() + // No token files exist. + + loader := &edgeTokenLoader{dir: dir} + + // First call fails. + _, err1 := loader.load() + require.Error(t, err1) + + // Write the file now. Second call should return the cached error. + writeFile(t, dir, edgeTokenFile, "late-token") + + _, err2 := loader.load() + require.Error(t, err2, "should return cached error from first attempt") +} + +// --- T015: diskTokenSource tests --- + +func TestDiskTokenSource_ValidBundle(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "`+expiry+`" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestDiskTokenSource_ExpiresIn(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-789", + "expires_in": 3600 + }`) + + before := time.Now() + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-789", tok.AccessToken) + // Expiry should be approximately 1 hour from now. + assert.WithinDuration(t, before.Add(time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_ExpiryPrecedence(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-abc", + "expiry": "`+expiry+`", + "expires_in": 60 + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + // "expiry" field takes precedence over "expires_in". + assert.WithinDuration(t, time.Now().Add(2*time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_NoExpiry(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "no-expiry-token"}`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "no-expiry-token", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "should have zero expiry when not set") +} + +func TestDiskTokenSource_MissingFile(t *testing.T) { + dir := t.TempDir() + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) +} + +func TestDiskTokenSource_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{not valid json}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestDiskTokenSource_MissingAccessToken(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"refresh_token": "only-refresh"}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "missing access_token") +} + +func TestDiskTokenSource_ReReadsOnEachCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v1"}`) + + src := newDiskTokenSource(dir) + tok1, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v1", tok1.AccessToken) + + // Update the file. Next call should read the new value. + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v2"}`) + + tok2, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v2", tok2.AccessToken) +} + +func TestDiskTokenSource_InvalidExpiryFormat(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "token-xyz", + "expiry": "not-a-date" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-xyz", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "unparseable expiry should result in zero time") +} + +// writeFile is a test helper that writes a file with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/authcode.go b/sdk/go/openshell/v1/oidc/authcode.go new file mode 100644 index 0000000000..d47f57b8a7 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" +) + +// generateCodeVerifier creates a PKCE code verifier per RFC 7636. +// It generates 32 random bytes and encodes them as base64url without +// padding, producing a 43-character string. +func generateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate code verifier: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// codeChallengeS256 computes the S256 PKCE code challenge for the +// given verifier. It returns BASE64URL(SHA256(verifier)) without +// padding, as specified in RFC 7636 Section 4.2. +func codeChallengeS256(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// generateState creates a cryptographic state parameter for the +// authorization request. It generates 16 random bytes encoded as +// base64url without padding. +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// buildAuthURL constructs the authorization endpoint URL with query +// parameters for the authorization code flow. If challenge is empty, +// PKCE parameters are omitted (for providers that do not support it). +func buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge string, scopes []string) string { + u, err := url.Parse(authEndpoint) + if err != nil || u.Scheme == "" { + u = &url.URL{Path: authEndpoint} + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("state", state) + q.Set("scope", strings.Join(scopes, " ")) + + if challenge != "" { + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String() +} + +// callbackResult carries the authorization code (or error) from the +// callback server to the auth code flow orchestrator. +type callbackResult struct { + code string + err error +} + +// startCallbackServer starts a localhost HTTP server to receive the +// OIDC provider's authorization callback. It listens on the specified +// port (use 0 for OS-assigned port). The server handles a single +// callback request and sends the result on the returned channel. +// +// The caller is responsible for calling srv.Close() when done. +func startCallbackServer(ctx context.Context, port int, expectedState string) (*http.Server, <-chan callbackResult, error) { + resultCh := make(chan callbackResult, 1) + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + // Check for provider error response. + if errCode := q.Get("error"); errCode != "" { + desc := q.Get("error_description") + msg := fmt.Sprintf("provider error: %s", errCode) + if desc != "" { + msg += ": " + desc + } + http.Error(w, msg, http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: %s", ErrAuthCode, msg)} + return + } + + // Validate state parameter. + state := q.Get("state") + if state != expectedState { + http.Error(w, "state mismatch", http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: state mismatch", ErrAuthCode)} + return + } + + // Extract authorization code. + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + resultCh <- callbackResult{err: fmt.Errorf("%w: missing authorization code in callback", ErrAuthCode)} + return + } + + w.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(w, "

Login successful

You can close this window.

") + resultCh <- callbackResult{code: code} + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, nil, fmt.Errorf("%w: failed to start callback server on port %d: %v", ErrCallbackServer, port, err) + } + + srv := &http.Server{ + Addr: listener.Addr().String(), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + go func() { + _ = srv.Serve(listener) + }() + + // Shut down the server when the context is cancelled. + go func() { + <-ctx.Done() + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + return srv, resultCh, nil +} + +// tokenResponse is the JSON structure returned by the token endpoint. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// exchangeCode exchanges an authorization code for tokens at the +// token endpoint. If codeVerifier is empty, the PKCE code_verifier +// parameter is omitted from the request. +// +// Secrets (code, verifier) are never included in error messages. +func exchangeCode(ctx context.Context, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + } + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request: %v", ErrAuthCode, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed: %v", ErrAuthCode, err) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response: %v", ErrAuthCode, err) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON: %v", ErrAuthCode, err) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + msg := "token exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("token exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrAuthCode, msg) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/authcode_test.go b/sdk/go/openshell/v1/oidc/authcode_test.go new file mode 100644 index 0000000000..dc241a3249 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode_test.go @@ -0,0 +1,372 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T012: PKCE verifier/challenge generation tests --- + +func TestGenerateCodeVerifier_Length(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // RFC 7636 requires 43-128 characters. Our implementation uses 32 + // random bytes -> 43 base64url characters (no padding). + assert.GreaterOrEqual(t, len(verifier), 43) + assert.LessOrEqual(t, len(verifier), 128) +} + +func TestGenerateCodeVerifier_Base64URLSafe(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // Must contain only base64url characters (A-Z, a-z, 0-9, -, _). + // No padding (=) allowed per RFC 7636. + for _, c := range verifier { + valid := (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' + assert.True(t, valid, "invalid character in verifier: %c", c) + } +} + +func TestGenerateCodeVerifier_Unique(t *testing.T) { + v1, err := generateCodeVerifier() + require.NoError(t, err) + + v2, err := generateCodeVerifier() + require.NoError(t, err) + + assert.NotEqual(t, v1, v2, "two verifiers should differ (random)") +} + +func TestCodeChallengeS256(t *testing.T) { + // RFC 7636 Appendix B test vector: + // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge) +} + +func TestCodeChallengeS256_NoPadding(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + challenge := codeChallengeS256(verifier) + + // S256 challenge must be base64url without padding. + assert.NotContains(t, challenge, "=") + assert.NotContains(t, challenge, "+") + assert.NotContains(t, challenge, "/") +} + +// --- T013: Auth code flow tests --- + +func TestGenerateState_Length(t *testing.T) { + state, err := generateState() + require.NoError(t, err) + + // 16 random bytes -> 22 base64url chars (no padding). + assert.GreaterOrEqual(t, len(state), 16) +} + +func TestGenerateState_Unique(t *testing.T) { + s1, err := generateState() + require.NoError(t, err) + + s2, err := generateState() + require.NoError(t, err) + + assert.NotEqual(t, s1, s2) +} + +func TestBuildAuthURL(t *testing.T) { + authEndpoint := "https://auth.example.com/authorize" + clientID := "test-client" + redirectURI := "http://localhost:8000/callback" + state := "random-state" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + scopes := []string{"openid", "profile"} + + authURL := buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge, scopes) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Equal(t, clientID, q.Get("client_id")) + assert.Equal(t, redirectURI, q.Get("redirect_uri")) + assert.Equal(t, state, q.Get("state")) + assert.Equal(t, challenge, q.Get("code_challenge")) + assert.Equal(t, "S256", q.Get("code_challenge_method")) + assert.Equal(t, "openid profile", q.Get("scope")) +} + +func TestBuildAuthURL_NoPKCE(t *testing.T) { + authURL := buildAuthURL( + "https://auth.example.com/authorize", + "test-client", + "http://localhost:8000/callback", + "state", + "", // empty challenge = no PKCE + []string{"openid"}, + ) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Empty(t, q.Get("code_challenge"), "no PKCE when challenge is empty") + assert.Empty(t, q.Get("code_challenge_method")) +} + +func TestStartCallbackServer_ReceivesCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state-123" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + // Extract the port from the server's listener address. + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=auth-code-xyz&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + result := <-resultCh + require.NoError(t, result.err) + assert.Equal(t, "auth-code-xyz", result.code) +} + +func TestStartCallbackServer_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "expected-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=some-code&state=wrong-state", addr, ) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "state") +} + +func TestStartCallbackServer_MissingCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) +} + +func TestStartCallbackServer_ProviderError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?error=access_denied&error_description=user+denied&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "access_denied") +} + +func TestStartCallbackServer_SpecificPort(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Port 0 tells OS to pick a free port. We just verify it works. + srv, _, err := startCallbackServer(ctx, 0, "state") + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + assert.NotEmpty(t, srv.Addr) +} + +func TestExchangeCode_Success(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, "test-code", r.Form.Get("code")) + assert.Equal(t, "test-client", r.Form.Get("client_id")) + assert.Equal(t, "http://localhost:8000/callback", r.Form.Get("redirect_uri")) + assert.NotEmpty(t, r.Form.Get("code_verifier"), "PKCE verifier should be sent") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-123", + "refresh_token": "rt-456", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "pkce-verifier", + ) + require.NoError(t, err) + assert.Equal(t, "at-123", tok.AccessToken) + assert.Equal(t, "rt-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestExchangeCode_NoPKCE(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Empty(t, r.Form.Get("code_verifier"), "no PKCE verifier when empty") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-no-pkce", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "", // empty verifier = no PKCE + ) + require.NoError(t, err) + assert.Equal(t, "at-no-pkce", tok.AccessToken) +} + +func TestExchangeCode_ErrorResponse(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant", "error_description": "code expired"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "bad-code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestExchangeCode_SecretsNotInError(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "secret-code-value", + "http://localhost/callback", + "secret-verifier-value", + ) + require.Error(t, err) + // The error message must not contain the auth code or verifier. + assert.NotContains(t, err.Error(), "secret-code-value") + assert.NotContains(t, err.Error(), "secret-verifier-value") +} + +func TestExchangeCode_InvalidJSON(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not json`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +// tokenResponseJSON is a helper for creating token endpoint responses. +func tokenResponseJSON(accessToken, refreshToken string, expiresIn int) string { + return fmt.Sprintf(`{ + "access_token": %q, + "refresh_token": %q, + "token_type": "Bearer", + "expires_in": %d + }`, accessToken, refreshToken, expiresIn) +} + diff --git a/sdk/go/openshell/v1/oidc/browser.go b/sdk/go/openshell/v1/oidc/browser.go new file mode 100644 index 0000000000..fd55c2ef36 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "fmt" + "os/exec" + "runtime" +) + +// browserCommand returns the platform-specific command name and +// arguments for opening a URL in the user's default browser. +func browserCommand(url string) (string, []string) { + switch runtime.GOOS { + case "darwin": + return "open", []string{url} + case "linux": + return "xdg-open", []string{url} + case "windows": + return "cmd", []string{"/c", "start", "", url} + default: + // Fallback: try xdg-open (common on Unix-like systems). + return "xdg-open", []string{url} + } +} + +// openBrowser attempts to open the given URL in the user's default +// browser using the platform-appropriate command. Returns an error if +// the browser could not be launched. +func openBrowser(url string) error { + name, args := browserCommand(url) + return openBrowserWith(name, args...) +} + +// openBrowserWith runs the given command with the provided arguments. +// This is separated from openBrowser to allow testing with arbitrary +// command names. +func openBrowserWith(name string, args ...string) error { + cmd := exec.Command(name, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open browser with %s: %w", name, err) + } + // We don't wait for the browser process to exit. It runs + // independently, and we only care that it launched. + return nil +} diff --git a/sdk/go/openshell/v1/oidc/browser_test.go b/sdk/go/openshell/v1/oidc/browser_test.go new file mode 100644 index 0000000000..49035238d9 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser_test.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "runtime" + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +// T015: Browser opener tests + +func TestBrowserCommand_Platform(t *testing.T) { + name, args := browserCommand("https://example.com/auth") + + switch runtime.GOOS { + case "darwin": + assert.Equal(t, "open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "linux": + assert.Equal(t, "xdg-open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "windows": + assert.Equal(t, "cmd", name) + assert.Contains(t, args, "/c") + assert.Contains(t, args, "start") + default: + // Unknown platform should still return something (even if it fails). + assert.NotEmpty(t, name) + } +} + +func TestBrowserCommand_URLPassedAsArg(t *testing.T) { + testURL := "https://auth.example.com/authorize?client_id=test&state=abc" + _, args := browserCommand(testURL) + + assert.True(t, slices.Contains(args, testURL), "URL should be passed as an argument to the browser command") +} + +func TestOpenBrowser_InvalidCommand(t *testing.T) { + // Attempting to open a browser with a non-existent command should + // return an error rather than panic. + err := openBrowserWith("nonexistent-browser-cmd-that-does-not-exist", "https://example.com") + assert.Error(t, err, "should fail when the browser command does not exist") +} diff --git a/sdk/go/openshell/v1/oidc/credentials.go b/sdk/go/openshell/v1/oidc/credentials.go new file mode 100644 index 0000000000..d49f19cbeb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials.go @@ -0,0 +1,142 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// ClientCredentials performs a non-interactive OAuth2 client credentials +// grant (RFC 6749 Section 4.4). It requires [WithIssuer], [WithClientID], +// and [WithClientSecret] (or [WithGateway] combined with [WithClientSecret]). +// +// This flow is intended for service accounts and machine-to-machine +// authentication. No user interaction occurs. The returned token +// typically contains only an access token (no refresh token). +// +// The client secret is never included in error messages (FR-014). +func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Client credentials should not send interactive scopes by default. + // Only send scopes if the caller explicitly set them via WithScopes. + if !cfg.scopesSet { + cfg.scopes = nil + } + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + if err != nil { + return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return nil, fmt.Errorf( + "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", + ErrOIDCConfig, cfg.gateway, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + if cfg.clientSecret == "" { + return nil, fmt.Errorf( + "%w: client secret is required (use WithClientSecret)", + ErrClientCredentials, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Build the token request with client credentials grant type. + data := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {cfg.clientID}, + "client_secret": {cfg.clientSecret}, + } + if len(cfg.scopes) > 0 { + data.Set("scope", strings.Join(cfg.scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request", ErrClientCredentials) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed", ErrClientCredentials) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response", ErrClientCredentials) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + // FR-014: Never include the client secret in error messages. + // Only include the provider's error code and description. + msg := "client credentials exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("provider error: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrClientCredentials, msg) + } + + if tokResp.AccessToken == "" { + return nil, fmt.Errorf("%w: token response missing access_token", ErrClientCredentials) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/credentials_test.go b/sdk/go/openshell/v1/oidc/credentials_test.go new file mode 100644 index 0000000000..f87c6b31fb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials_test.go @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T023: Client credentials tests --- + +// setupCredentialsMockProvider creates a mock OIDC provider for client +// credentials testing. The token endpoint validates Basic Auth and +// returns a token response. Returns the server and the expected +// client ID / client secret pair. +func setupCredentialsMockProvider(t *testing.T, expectedClientID, expectedSecret string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Validate grant type. + if r.Form.Get("grant_type") != "client_credentials" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type","error_description":"expected client_credentials"}`)) + return + } + + // Check credentials from form body (client_id + client_secret) + // or Basic Auth header. + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + if clientID == "" || clientSecret == "" { + // Try Basic Auth. + var ok bool + clientID, clientSecret, ok = r.BasicAuth() + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"missing credentials"}`)) + return + } + } + + if clientID != expectedClientID || clientSecret != expectedSecret { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"invalid credentials"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("cc-access-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestClientCredentials_Success verifies the happy path: valid client +// ID, secret, and issuer produce a valid access token. +func TestClientCredentials_Success(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) + assert.Empty(t, tok.RefreshToken, "client credentials should not return a refresh token") +} + +// TestClientCredentials_MissingIssuer verifies that ClientCredentials +// returns ErrOIDCConfig when the issuer is not set. +func TestClientCredentials_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientID verifies that ClientCredentials +// returns ErrOIDCConfig when the client ID is not set. +func TestClientCredentials_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientSecret verifies that +// ClientCredentials returns ErrClientCredentials when the secret is +// missing. +func TestClientCredentials_MissingClientSecret(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientID("my-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) +} + +// TestClientCredentials_InvalidCredentials verifies that +// ClientCredentials returns ErrClientCredentials when the provider +// rejects the credentials, and that the secret is not leaked in the +// error message. +func TestClientCredentials_InvalidCredentials(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "good-client", "good-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("good-client"), + WithClientSecret("wrong-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) + + // FR-014: The secret must NEVER appear in error messages. + assert.NotContains(t, err.Error(), "wrong-secret", "secret must not leak in error message") + assert.NotContains(t, err.Error(), "good-secret", "secret must not leak in error message") +} + +// TestClientCredentials_DiscoveryFailure verifies that +// ClientCredentials returns ErrDiscovery when the provider is +// unreachable. +func TestClientCredentials_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestClientCredentials_WithGateway verifies that ClientCredentials +// resolves OIDC config from gateway metadata when WithGateway is set. +func TestClientCredentials_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "gw-client", "gw-secret") + + fakeConfig := &gateway.Config{ + Name: "cc-gateway", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithGateway("cc-gateway"), + WithClientSecret("gw-secret"), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "cc-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) +} + +// TestClientCredentials_CustomScopes verifies that WithScopes overrides +// default scopes in the client credentials request. +func TestClientCredentials_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + var receivedScope string + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedScope = r.Form.Get("scope") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-cc-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(srv.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + WithScopes("api:read", "api:write"), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-cc-token", tok.AccessToken) + assert.Equal(t, "api:read api:write", receivedScope) +} + +// TestClientCredentials_ContextCancellation verifies that +// ClientCredentials respects context cancellation. +func TestClientCredentials_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/device.go b/sdk/go/openshell/v1/oidc/device.go new file mode 100644 index 0000000000..c6701ff80e --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device.go @@ -0,0 +1,285 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// deviceAuthResponse holds the parsed response from the device +// authorization endpoint (RFC 8628 Section 3.2). +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// DeviceLogin performs an OAuth2 device authorization grant (RFC 8628). +// +// The flow requests a device code and user code from the provider's +// device authorization endpoint, displays them to the user (via +// [WithDisplayFunc] or stdout), and polls the token endpoint until the +// user completes authorization. +// +// Required options: [WithIssuer] and [WithClientID], or [WithGateway]. +// +// The polling loop respects the provider's interval and handles the +// following token endpoint error codes: +// - "authorization_pending": continue polling at the current interval +// - "slow_down": increase the polling interval by 5 seconds (RFC 8628 Section 3.5) +// - "expired_token": the device code has expired, return [ErrDeviceCode] +// - any other error: return [ErrDeviceCode] +func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + if err != nil { + return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return nil, fmt.Errorf( + "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", + ErrOIDCConfig, cfg.gateway, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Verify the provider supports device authorization. + if provider.DeviceAuthorizationEndpoint == "" { + return nil, fmt.Errorf( + "%w: provider does not support device authorization (no device_authorization_endpoint in discovery)", + ErrDeviceCode, + ) + } + + // Request a device code from the provider. + deviceResp, err := requestDeviceCode(ctx, provider.DeviceAuthorizationEndpoint, cfg.clientID, cfg.scopes) + if err != nil { + return nil, err + } + + // Display the verification URL and user code to the user. + if cfg.displayFunc != nil { + cfg.displayFunc(deviceResp.VerificationURI, deviceResp.UserCode) + } else { + fmt.Printf("To sign in, visit: %s\n", deviceResp.VerificationURI) + fmt.Printf("Enter code: %s\n", deviceResp.UserCode) + } + + // Enforce device code lifetime from the provider's expires_in field. + // If the caller's context already has a shorter deadline, that takes + // precedence. This prevents indefinite polling against non-compliant + // providers that never return expired_token. + if deviceResp.ExpiresIn > 0 { + expiry := time.Duration(deviceResp.ExpiresIn) * time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, expiry) + defer cancel() + } + + // Poll the token endpoint until authorization completes, expires, + // or the context is cancelled. + interval := deviceResp.Interval + if interval < 1 { + interval = 5 // default polling interval per RFC 8628 + } + + return pollDeviceToken(ctx, provider.TokenEndpoint, cfg.clientID, deviceResp.DeviceCode, interval) +} + +// requestDeviceCode sends a POST to the device authorization endpoint +// and returns the parsed response containing the device code, user +// code, and verification URI. +func requestDeviceCode(ctx context.Context, endpoint, clientID string, scopes []string) (*deviceAuthResponse, error) { + data := url.Values{ + "client_id": {clientID}, + } + if len(scopes) > 0 { + data.Set("scope", strings.Join(scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create device authorization request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: device authorization request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read device authorization response", ErrDeviceCode) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: device authorization endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + var deviceResp deviceAuthResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("%w: invalid device authorization response JSON", ErrDeviceCode) + } + + if deviceResp.DeviceCode == "" || deviceResp.UserCode == "" { + return nil, fmt.Errorf("%w: device authorization response missing device_code or user_code", ErrDeviceCode) + } + + return &deviceResp, nil +} + +// pollDeviceToken polls the token endpoint at the given interval until +// the user completes authorization. It handles RFC 8628 error codes: +// - "authorization_pending": keep polling +// - "slow_down": increase interval by 5 seconds +// - "expired_token": return ErrDeviceCode +func pollDeviceToken(ctx context.Context, tokenEndpoint, clientID, deviceCode string, interval int64) (*oauth2.Token, error) { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case <-ticker.C: + tok, done, slowDown, err := tryDeviceTokenExchange(ctx, tokenEndpoint, clientID, deviceCode) + if done { + if err != nil { + return nil, err + } + return tok, nil + } + // Adjust interval if the provider requested slow_down + // (+5 seconds per RFC 8628 Section 3.5). + if slowDown < 0 { + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + } + } + } +} + +// tryDeviceTokenExchange makes a single token request for the device +// code grant. Returns: +// - (token, true, 0, nil): success +// - (nil, true, 0, err): terminal error (expired, access_denied, etc.) +// - (nil, false, interval, nil): continue polling (authorization_pending or slow_down) +func tryDeviceTokenExchange(ctx context.Context, tokenEndpoint, clientID, deviceCode string) (*oauth2.Token, bool, int64, error) { + data := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {clientID}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to create token request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + // If the context was cancelled or timed out, surface that as + // ErrTimeout so callers can distinguish "user/caller cancelled" + // from a genuine device-code error. + if ctx.Err() != nil { + return nil, true, 0, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + } + // Other network errors during polling are terminal. + return nil, true, 0, fmt.Errorf("%w: token request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to read token response", ErrDeviceCode) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, true, 0, fmt.Errorf("%w: invalid token response JSON", ErrDeviceCode) + } + + // Handle error responses per RFC 8628 Section 3.5. + if tokResp.Error != "" { + switch tokResp.Error { + case "authorization_pending": + // User has not yet completed authorization. Keep polling. + return nil, false, 0, nil + case "slow_down": + // Provider requests increased interval (+5 seconds per RFC 8628). + // Return a sentinel value; the caller adds 5 to current interval. + return nil, false, -1, nil + case "expired_token": + return nil, true, 0, fmt.Errorf("%w: device code expired", ErrDeviceCode) + case "access_denied": + return nil, true, 0, fmt.Errorf("%w: access denied by user", ErrDeviceCode) + default: + msg := fmt.Sprintf("device code exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + return nil, true, 0, fmt.Errorf("%w: %s", ErrDeviceCode, msg) + } + } + + // Success: parse the token. + if resp.StatusCode != http.StatusOK { + return nil, true, 0, fmt.Errorf("%w: token endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, true, 0, nil +} diff --git a/sdk/go/openshell/v1/oidc/device_test.go b/sdk/go/openshell/v1/oidc/device_test.go new file mode 100644 index 0000000000..43b2bae82b --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device_test.go @@ -0,0 +1,682 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T025: Device code flow tests --- + +// setupDeviceMockProvider creates a mock OIDC provider for device code +// flow testing. The device authorization endpoint returns a device code +// and verification URL. The token endpoint simulates polling behavior: +// it returns "authorization_pending" for the first N polls, then returns +// a valid token response. +func setupDeviceMockProvider(t *testing.T, pendingPolls int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=ABCD-1234", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Only handle device code grants here. + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type"}`)) + return + } + + count := pollCount.Add(1) + if int(count) <= pendingPolls { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("device-access-token", "device-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestDeviceLogin_Success verifies the happy path: the device code flow +// requests a device code, displays it, polls until authorized, and +// returns a valid token. +func TestDeviceLogin_Success(t *testing.T) { + resetDiscoveryCache() + + // Provider returns "authorization_pending" for the first 2 polls, + // then returns a token on the 3rd poll. + provider := setupDeviceMockProvider(t, 2) + + var displayedURL, displayedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + displayedURL = verificationURL + displayedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.Equal(t, "device-refresh-token", tok.RefreshToken) + + // Verify the display callback was invoked with correct values. + assert.Equal(t, "https://example.com/activate", displayedURL) + assert.Equal(t, "ABCD-1234", displayedCode) +} + +// TestDeviceLogin_MissingIssuer verifies that DeviceLogin returns +// ErrOIDCConfig when the issuer is not provided. +func TestDeviceLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_MissingClientID verifies that DeviceLogin returns +// ErrOIDCConfig when the client ID is not provided. +func TestDeviceLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithIssuer("https://example.com"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_DiscoveryFailure verifies that DeviceLogin returns +// ErrDiscovery when the OIDC provider is unreachable. +func TestDeviceLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestDeviceLogin_SlowDown verifies that the polling loop respects the +// "slow_down" response by increasing the polling interval. +func TestDeviceLogin_SlowDown(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "slow-device-code", + "user_code": "SLOW-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + count := pollCount.Add(1) + w.Header().Set("Content-Type", "application/json") + + switch count { + case 1: + // First poll: slow_down + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"slow_down"}`)) + case 2: + // Second poll: still pending + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + default: + // Third poll: success + _, _ = w.Write([]byte(tokenResponseJSON("slow-token", "", 3600))) + } + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.NoError(t, err) + assert.Equal(t, "slow-token", tok.AccessToken) +} + +// TestDeviceLogin_ExpiredDeviceCode verifies that DeviceLogin returns +// ErrDeviceCode when the device code expires before authorization. +func TestDeviceLogin_ExpiredDeviceCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "expiring-device-code", + "user_code": "EXPR-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"expired_token","error_description":"device code expired"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_CustomDisplayFunc verifies that WithDisplayFunc is +// invoked with the verification URL and user code. +func TestDeviceLogin_CustomDisplayFunc(t *testing.T) { + resetDiscoveryCache() + + // Provider that immediately returns a token (0 pending polls). + provider := setupDeviceMockProvider(t, 0) + + var called bool + var capturedURL, capturedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + called = true + capturedURL = verificationURL + capturedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.True(t, called, "display function should have been called") + assert.Equal(t, "https://example.com/activate", capturedURL) + assert.Equal(t, "ABCD-1234", capturedCode) +} + +// TestDeviceLogin_WithGateway verifies that DeviceLogin resolves OIDC +// config from gateway metadata when WithGateway is set. +func TestDeviceLogin_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupDeviceMockProvider(t, 0) + + fakeConfig := &gateway.Config{ + Name: "device-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-device-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithGateway("device-gw"), + WithDisplayFunc(func(_, _ string) {}), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "device-gw", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) +} + +// TestDeviceLogin_ContextCancellation verifies that DeviceLogin +// respects context cancellation during polling. +func TestDeviceLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + // Provider that always returns "authorization_pending" so + // the polling loop never succeeds on its own. + provider := setupDeviceMockProvider(t, 1000) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + // Should be ErrTimeout or context.DeadlineExceeded wrapped. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, context.DeadlineExceeded), + "expected timeout error, got: %v", err, + ) +} + +// TestDeviceLogin_AccessDenied verifies that DeviceLogin returns +// ErrDeviceCode when the user denies authorization. +func TestDeviceLogin_AccessDenied(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "denied-device-code", + "user_code": "DENY-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"user denied"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "access denied") +} + +// TestDeviceLogin_UnknownError verifies that DeviceLogin returns +// ErrDeviceCode with the error description for unknown error codes. +func TestDeviceLogin_UnknownError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "unknown-err-code", + "user_code": "UNKN-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"server_error","error_description":"internal failure"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "server_error") + assert.Contains(t, err.Error(), "internal failure") +} + +// TestDeviceLogin_DeviceEndpointHTTPError verifies that DeviceLogin +// returns ErrDeviceCode when the device authorization endpoint returns +// a non-200 HTTP status. +func TestDeviceLogin_DeviceEndpointHTTPError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_DeviceEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the device endpoint returns invalid JSON. +func TestDeviceLogin_DeviceEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_MissingUserCode verifies that DeviceLogin returns +// ErrDeviceCode when the device endpoint returns an empty user_code. +func TestDeviceLogin_MissingUserCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "code-but-no-user", + "user_code": "", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_TokenEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the token endpoint returns invalid JSON +// during polling. +func TestDeviceLogin_TokenEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "json-err-code", + "user_code": "JSON-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{invalid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_NoDeviceEndpoint verifies that DeviceLogin returns +// ErrDeviceCode when the provider does not advertise a device +// authorization endpoint. +func TestDeviceLogin_NoDeviceEndpoint(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + // No device_authorization_endpoint. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} diff --git a/sdk/go/openshell/v1/oidc/discovery.go b/sdk/go/openshell/v1/oidc/discovery.go new file mode 100644 index 0000000000..14ad5f47e4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" +) + +// providerConfig holds parsed fields from an OIDC discovery document +// (.well-known/openid-configuration). +type providerConfig struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +// discoveryCache stores successfully fetched provider configurations +// keyed by normalized issuer URL. Only successful results are cached; +// errors are not cached so that transient failures (network issues, +// context cancellation) do not permanently poison the cache. +var ( + discoveryCacheMu sync.Mutex + discoveryCache = make(map[string]*providerConfig) +) + +// resetDiscoveryCache clears the in-memory discovery cache. This is +// only used by tests to avoid interference between test cases. +func resetDiscoveryCache() { + discoveryCacheMu.Lock() + defer discoveryCacheMu.Unlock() + discoveryCache = make(map[string]*providerConfig) +} + +// normalizeIssuer strips a trailing slash from the issuer URL so that +// "https://auth.example.com" and "https://auth.example.com/" resolve +// to the same cache key. +func normalizeIssuer(issuer string) string { + return strings.TrimRight(issuer, "/") +} + +// discover fetches and caches the OIDC discovery document for the +// given issuer URL. Only successful results are cached; failed +// fetches are retried on the next call. +func discover(ctx context.Context, issuer string) (*providerConfig, error) { + key := normalizeIssuer(issuer) + + discoveryCacheMu.Lock() + if cached, ok := discoveryCache[key]; ok { + discoveryCacheMu.Unlock() + return cached, nil + } + discoveryCacheMu.Unlock() + + cfg, err := fetchDiscovery(ctx, key) + if err != nil { + return nil, err + } + + discoveryCacheMu.Lock() + // Check again in case another goroutine cached it while we fetched. + if existing, ok := discoveryCache[key]; ok { + discoveryCacheMu.Unlock() + return existing, nil + } + discoveryCache[key] = cfg + discoveryCacheMu.Unlock() + + return cfg, nil +} + +// fetchDiscovery performs the actual HTTP GET to the OIDC discovery +// endpoint and parses the response. +func fetchDiscovery(ctx context.Context, issuer string) (*providerConfig, error) { + url := issuer + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: discovery endpoint returned HTTP %d", ErrDiscovery, resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("%w: failed to read discovery response: %v", ErrDiscovery, err) + } + + var cfg providerConfig + if err := json.Unmarshal(body, &cfg); err != nil { + return nil, fmt.Errorf("%w: invalid discovery JSON: %v", ErrDiscovery, err) + } + + if cfg.TokenEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing token_endpoint", ErrDiscovery) + } + if cfg.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing authorization_endpoint", ErrDiscovery) + } + + return &cfg, nil +} diff --git a/sdk/go/openshell/v1/oidc/discovery_test.go b/sdk/go/openshell/v1/oidc/discovery_test.go new file mode 100644 index 0000000000..14af9ed6aa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery_test.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wellKnownJSON returns a valid OIDC discovery document JSON string +// with the given issuer URL as the base. +func wellKnownJSON(issuer string) string { + return `{ + "issuer": "` + issuer + `", + "authorization_endpoint": "` + issuer + `/authorize", + "token_endpoint": "` + issuer + `/token", + "device_authorization_endpoint": "` + issuer + `/device", + "scopes_supported": ["openid", "profile", "email"], + "code_challenge_methods_supported": ["S256"] + }` +} + +func TestDiscover_ValidDocument(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + // Clear cache to avoid interference from other tests. + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + assert.Equal(t, srv.URL, cfg.Issuer) + assert.Equal(t, srv.URL+"/authorize", cfg.AuthorizationEndpoint) + assert.Equal(t, srv.URL+"/token", cfg.TokenEndpoint) + assert.Equal(t, srv.URL+"/device", cfg.DeviceAuthorizationEndpoint) + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.ScopesSupported) + assert.Equal(t, []string{"S256"}, cfg.CodeChallengeMethodsSupported) +} + +func TestDiscover_CachesResult(t *testing.T) { + callCount := 0 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + // Same pointer should be returned from cache. + assert.Same(t, cfg1, cfg2) + assert.Equal(t, 1, callCount, "discovery should be fetched only once") +} + +func TestDiscover_DifferentIssuersNotCached(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + }) + + srv1 := httptest.NewServer(handler) + defer srv1.Close() + srv2 := httptest.NewServer(handler) + defer srv2.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv1.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv2.URL) + require.NoError(t, err) + + // Different issuers should yield different cached entries. + assert.NotSame(t, cfg1, cfg2) +} + +func TestDiscover_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json}`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_MissingTokenEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) + assert.Contains(t, err.Error(), "token_endpoint") +} + +func TestDiscover_ContextCancelled(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + _, err := discover(ctx, srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_ConcurrentAccess(t *testing.T) { + callCount := 0 + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + mu.Lock() + callCount++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON("http://example.com"))) + })) + defer srv.Close() + + resetDiscoveryCache() + + var wg sync.WaitGroup + results := make([]*providerConfig, 10) + errs := make([]error, 10) + + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = discover(context.Background(), srv.URL) + }(i) + } + wg.Wait() + + for i := range 10 { + require.NoError(t, errs[i]) + assert.NotNil(t, results[i]) + } + + // Without sync.Once, concurrent goroutines may each fetch before + // the first result is cached. All calls should succeed, and the + // server should be called at most once per concurrent racer (not + // 10 times if caching works at all). In practice, most calls + // should hit the cache after the first fetch completes. + mu.Lock() + defer mu.Unlock() + assert.LessOrEqual(t, callCount, 10, "caching should reduce total fetches") +} + +func TestDiscover_NoDeviceEndpointIsOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + assert.Empty(t, cfg.DeviceAuthorizationEndpoint) +} + +func TestDiscover_TrailingSlashNormalized(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "issuer": "http://example.com", + "authorization_endpoint": "http://example.com/authorize", + "token_endpoint": "http://example.com/token" + }`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + // Call with trailing slash and without; should be same cache entry. + _, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + _, err = discover(context.Background(), srv.URL+"/") + require.NoError(t, err) + + assert.Equal(t, 1, callCount, "trailing slash should be normalized for caching") +} diff --git a/sdk/go/openshell/v1/oidc/doc.go b/sdk/go/openshell/v1/oidc/doc.go new file mode 100644 index 0000000000..2575a09127 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/doc.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package oidc provides OIDC authentication flows for the OpenShell SDK. +// +// The package supports four authentication flows: +// +// - Authorization Code with PKCE (interactive browser-based login) +// - Keyboard flow (manual URL copy and code paste for headless environments) +// - Device Code flow (RFC 8628, for input-constrained devices) +// - Client Credentials grant (non-interactive service account authentication) +// +// # Gateway-Aware Login +// +// The primary use case is gateway-aware login, where OIDC provider +// configuration is read from a gateway's metadata.json file: +// +// token, err := oidc.Login(ctx, "my-gateway") +// if err != nil { +// log.Fatal(err) +// } +// +// After successful authentication, tokens are persisted to disk in the +// gateway directory as oidc_token.json, compatible with +// [gateway.NewClient] and the existing [gateway.diskTokenSource]. +// +// # Standalone Login +// +// For OIDC providers not tied to an OpenShell gateway, use explicit +// configuration: +// +// token, err := oidc.Login(ctx, "", +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// oidc.WithInMemory(), +// ) +// +// # Device Code Flow +// +// For environments without a browser: +// +// token, err := oidc.DeviceLogin(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// ) +// +// # Client Credentials +// +// For non-interactive service accounts: +// +// token, err := oidc.ClientCredentials(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-service"), +// oidc.WithClientSecret("secret"), +// ) +// +// # Error Handling +// +// The package provides typed sentinel errors for precise failure +// classification: +// +// - [ErrDiscovery]: OIDC discovery fetch or parse failed +// - [ErrAuthCode]: Authorization code exchange failed +// - [ErrDeviceCode]: Device code flow failed +// - [ErrClientCredentials]: Client credentials exchange failed +// - [ErrTimeout]: Interactive flow timed out +// - [ErrCallbackServer]: Localhost callback server failed to start +// - [ErrTokenPersist]: Token disk write failed +// - [ErrOIDCConfig]: Gateway metadata missing OIDC fields +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. OIDC discovery documents are cached in memory per issuer +// URL for the lifetime of the process. +package oidc diff --git a/sdk/go/openshell/v1/oidc/errors.go b/sdk/go/openshell/v1/oidc/errors.go new file mode 100644 index 0000000000..20c9105c03 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import "errors" + +// Sentinel errors for OIDC authentication failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrDiscovery is returned when the OIDC discovery document + // (.well-known/openid-configuration) cannot be fetched or parsed. + ErrDiscovery = errors.New("oidc: discovery failed") + + // ErrAuthCode is returned when the authorization code exchange + // fails (invalid code, expired code, provider error). + ErrAuthCode = errors.New("oidc: auth code exchange failed") + + // ErrDeviceCode is returned when the device code flow fails + // (request error, expired device code, provider error). + ErrDeviceCode = errors.New("oidc: device code flow failed") + + // ErrClientCredentials is returned when the client credentials + // grant fails (invalid credentials, provider error). The error + // message never contains the client secret. + ErrClientCredentials = errors.New("oidc: client credentials exchange failed") + + // ErrTimeout is returned when an interactive login flow + // (browser, keyboard, or device code) exceeds its deadline. + ErrTimeout = errors.New("oidc: login timed out") + + // ErrCallbackServer is returned when the localhost HTTP server + // for the authorization code redirect cannot bind to any port. + ErrCallbackServer = errors.New("oidc: callback server failed") + + // ErrTokenPersist is returned when the token cannot be written + // to disk (permission error, invalid path). + ErrTokenPersist = errors.New("oidc: token persistence failed") + + // ErrOIDCConfig is returned when gateway metadata is missing + // the required oidc_issuer or oidc_client_id fields. + ErrOIDCConfig = errors.New("oidc: gateway OIDC config missing") +) diff --git a/sdk/go/openshell/v1/oidc/errors_test.go b/sdk/go/openshell/v1/oidc/errors_test.go new file mode 100644 index 0000000000..ea9dfb5a10 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors_test.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSentinelErrors_AreDistinct(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for i, a := range sentinels { + for j, b := range sentinels { + if i == j { + continue + } + assert.False(t, errors.Is(a, b), + "expected %v and %v to be distinct", a, b) + } + } +} + +func TestSentinelErrors_MatchSelf(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for _, sentinel := range sentinels { + assert.True(t, errors.Is(sentinel, sentinel), + "expected %v to match itself", sentinel) + } +} + +func TestSentinelErrors_WrappedMatchViIs(t *testing.T) { + cases := []struct { + name string + sentinel error + }{ + {"ErrDiscovery", ErrDiscovery}, + {"ErrAuthCode", ErrAuthCode}, + {"ErrDeviceCode", ErrDeviceCode}, + {"ErrClientCredentials", ErrClientCredentials}, + {"ErrTimeout", ErrTimeout}, + {"ErrCallbackServer", ErrCallbackServer}, + {"ErrTokenPersist", ErrTokenPersist}, + {"ErrOIDCConfig", ErrOIDCConfig}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := fmt.Errorf("operation failed: %w", tc.sentinel) + assert.True(t, errors.Is(wrapped, tc.sentinel), + "wrapped error should match sentinel via errors.Is") + }) + } +} + +func TestSentinelErrors_HaveDescriptiveMessages(t *testing.T) { + cases := []struct { + sentinel error + contains string + }{ + {ErrDiscovery, "discovery"}, + {ErrAuthCode, "auth code"}, + {ErrDeviceCode, "device code"}, + {ErrClientCredentials, "client credentials"}, + {ErrTimeout, "timed out"}, + {ErrCallbackServer, "callback server"}, + {ErrTokenPersist, "token persistence"}, + {ErrOIDCConfig, "OIDC config"}, + } + + for _, tc := range cases { + t.Run(tc.sentinel.Error(), func(t *testing.T) { + assert.Contains(t, tc.sentinel.Error(), tc.contains) + }) + } +} + +func TestSentinelErrors_DoubleWrapped(t *testing.T) { + inner := fmt.Errorf("http timeout: %w", ErrDiscovery) + outer := fmt.Errorf("login failed: %w", inner) + + assert.True(t, errors.Is(outer, ErrDiscovery), + "double-wrapped error should still match sentinel") +} diff --git a/sdk/go/openshell/v1/oidc/example_test.go b/sdk/go/openshell/v1/oidc/example_test.go new file mode 100644 index 0000000000..c304ef0bfa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/example_test.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// These examples demonstrate OIDC package usage but are guarded from +// execution during `go test` because they require real network access +// and user interaction. The guard `if false` keeps the code type-checked +// by the compiler without executing during tests. + +package oidc_test + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" +) + +func ExampleLogin_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_standalone() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "", + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-app"), + oidc.WithInMemory(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Access token: %s...\n", token.AccessToken[:10]) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_keyboard() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway", + oidc.WithKeyboardFlow(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated via keyboard flow. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-device-app"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Device authorized. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin_customDisplay() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-tui-app"), + oidc.WithDisplayFunc(func(verificationURL, userCode string) { + fmt.Printf("Please visit: %s\n", verificationURL) + fmt.Printf("Enter code: %s\n", userCode) + }), + ) + if err != nil { + log.Fatal(err) + } + + _ = token + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-service"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithGateway("my-gateway"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated via gateway. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} diff --git a/sdk/go/openshell/v1/oidc/keyboard.go b/sdk/go/openshell/v1/oidc/keyboard.go new file mode 100644 index 0000000000..d7367b8186 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard.go @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +// keyboardFlow implements the keyboard fallback for the authorization +// code flow. It displays the authorization URL to the user and reads +// the pasted authorization code from the provided reader. +// +// Parameters: +// - ctx: context for cancellation/timeout +// - authURL: the full authorization URL to display +// - input: reader for user input (typically os.Stdin) +// - output: writer for prompts/instructions (typically os.Stderr) +// +// Returns the authorization code or an error. +func keyboardFlow(ctx context.Context, authURL string, input io.Reader, output io.Writer) (string, error) { + // Display instructions and URL. + _, _ = fmt.Fprintf(output, "\nOpen the following URL in your browser to authenticate:\n\n %s\n\n", authURL) + _, _ = fmt.Fprint(output, "Paste the authorization code here and press Enter: ") + + // Read code with context cancellation support. + type readResult struct { + code string + err error + } + ch := make(chan readResult, 1) + + go func() { + scanner := bufio.NewScanner(input) + if scanner.Scan() { + ch <- readResult{code: strings.TrimSpace(scanner.Text())} + } else { + err := scanner.Err() + if err == nil { + // EOF without reading a line. + ch <- readResult{err: fmt.Errorf("%w: no authorization code received (EOF)", ErrAuthCode)} + } else { + ch <- readResult{err: fmt.Errorf("%w: failed to read authorization code: %v", ErrAuthCode, err)} + } + } + }() + + select { + case <-ctx.Done(): + return "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-ch: + if result.err != nil { + return "", result.err + } + if result.code == "" { + return "", fmt.Errorf("%w: empty authorization code", ErrAuthCode) + } + return result.code, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/keyboard_test.go b/sdk/go/openshell/v1/oidc/keyboard_test.go new file mode 100644 index 0000000000..fa0c4ab17f --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard_test.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// T014: Keyboard fallback flow tests + +func TestKeyboardFlow_ReadsCode(t *testing.T) { + // Simulate user pasting a code via stdin. + input := strings.NewReader("my-auth-code\n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?client_id=test", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "my-auth-code", code) + + // Verify that the URL was displayed to the user. + assert.Contains(t, output.String(), "https://auth.example.com/authorize?client_id=test") +} + +func TestKeyboardFlow_TrimsWhitespace(t *testing.T) { + input := strings.NewReader(" some-code-with-spaces \n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "some-code-with-spaces", code) +} + +func TestKeyboardFlow_EmptyInput(t *testing.T) { + input := strings.NewReader("\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_EOFBeforeInput(t *testing.T) { + // Reader that returns EOF immediately (e.g., piped /dev/null). + input := strings.NewReader("") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + // Should be ErrAuthCode since no code was received. + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + // Use a reader that blocks forever (until context cancel). + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://auth.example.com/authorize", input, output) + require.Error(t, err) +} + +func TestKeyboardFlow_DisplaysInstructions(t *testing.T) { + input := strings.NewReader("test-code\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?response_type=code", + input, + output, + ) + require.NoError(t, err) + + displayed := output.String() + // Must show the URL and some instruction text. + assert.Contains(t, displayed, "https://auth.example.com/authorize?response_type=code") + // Should prompt user to paste the code. + lower := strings.ToLower(displayed) + assert.True(t, + strings.Contains(lower, "paste") || strings.Contains(lower, "code") || strings.Contains(lower, "enter"), + "output should instruct the user to paste or enter the code", + ) +} + +func TestKeyboardFlow_Timeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // Reader that never returns data. + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://example.com/auth", input, output) + require.Error(t, err) +} + +// blockingReader is an io.Reader that blocks until the context is cancelled. +// It is used to simulate a user who never types anything. +type blockingReader struct{} + +func (r *blockingReader) Read(_ []byte) (int, error) { + // Block for a long time to simulate waiting for input. + time.Sleep(10 * time.Second) + return 0, io.EOF +} diff --git a/sdk/go/openshell/v1/oidc/oidc.go b/sdk/go/openshell/v1/oidc/oidc.go new file mode 100644 index 0000000000..9b612cc280 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc.go @@ -0,0 +1,227 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "fmt" + "io" + "os" + "slices" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// Login performs an interactive OIDC authorization code login. +// +// When gatewayName is non-empty, Login resolves OIDC configuration +// (issuer URL and client ID) from the gateway's metadata.json file and +// persists tokens to the gateway directory. +// +// When gatewayName is empty, the caller must provide [WithIssuer] and +// [WithClientID] options explicitly. +// +// Before starting an interactive flow, Login checks for an existing +// valid token on disk (FR-019). If a valid, non-expired token is found, +// it is returned immediately without user interaction. +// +// The flow attempts to open a browser for authorization. If the browser +// cannot be opened, or if [WithKeyboardFlow] is set, the keyboard +// fallback flow is used instead. +func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Apply configured timeout if the caller's context has no deadline. + if _, hasDeadline := ctx.Deadline(); !hasDeadline && cfg.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.timeout) + defer cancel() + } + + // Resolve OIDC configuration from gateway or explicit options. + tokenDir, err := resolveOIDCConfig(cfg, gatewayName) + if err != nil { + return nil, err + } + + // FR-019: Check for existing valid token on disk before starting + // an interactive flow. + if tokenDir != "" { + tok, readErr := readToken(tokenDir) + if readErr == nil && tok != nil && tok.Valid() { + return tok, nil + } + // Stale/expired tokens or missing files are not errors; we + // simply proceed to the interactive flow. + } + + // Run OIDC discovery to get provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Generate PKCE verifier and challenge if the provider supports S256. + var codeVerifier, codeChallenge string + if supportsS256(provider) { + codeVerifier, err = generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + codeChallenge = codeChallengeS256(codeVerifier) + } + + // Generate cryptographic state for CSRF protection. + state, err := generateState() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + + // Determine the authorization code acquisition method. + // The redirectURI must match exactly between the auth request and + // the token exchange (OIDC/OAuth2 requirement). + var code, redirectURI string + if cfg.keyboardFlow { + redirectURI = "urn:ietf:wg:oauth:2.0:oob" + code, err = loginKeyboard(ctx, cfg, provider, state, codeChallenge) + } else { + code, redirectURI, err = loginBrowser(ctx, cfg, provider, state, codeChallenge) + } + if err != nil { + return nil, err + } + + // Exchange the authorization code for tokens. + tok, err := exchangeCode(ctx, provider.TokenEndpoint, cfg.clientID, code, redirectURI, codeVerifier) + if err != nil { + return nil, err + } + + // Persist token to disk unless in-memory mode is requested. + if !cfg.inMemory && tokenDir != "" { + if writeErr := writeToken(tokenDir, tok); writeErr != nil { + return nil, writeErr + } + } + + return tok, nil +} + +// resolveOIDCConfig resolves the OIDC issuer and client ID either from +// the gateway metadata or from explicit options. Returns the token +// directory path (empty if in-memory or no directory available). +func resolveOIDCConfig(cfg *loginConfig, gatewayName string) (string, error) { + tokenDir := cfg.tokenDir + + if gatewayName != "" { + // Resolve from gateway. + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(gatewayName) + if err != nil { + return "", fmt.Errorf("failed to load gateway %q: %w", gatewayName, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return "", fmt.Errorf("%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", ErrOIDCConfig, gatewayName) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + if tokenDir == "" { + tokenDir = gwCfg.Dir + } + } + + // Validate that we have the minimum required config. + if cfg.issuer == "" || cfg.clientID == "" { + return "", fmt.Errorf("%w: issuer and client ID are required (provide a gateway name or use WithIssuer and WithClientID)", ErrOIDCConfig) + } + + return tokenDir, nil +} + +// supportsS256 checks if the OIDC provider advertises S256 PKCE support. +func supportsS256(provider *providerConfig) bool { + return slices.Contains(provider.CodeChallengeMethodsSupported, "S256") +} + +// loginKeyboard performs the keyboard flow: builds the auth URL, shows +// it to the user, and reads the pasted authorization code. +func loginKeyboard(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, error) { + redirectURI := "urn:ietf:wg:oauth:2.0:oob" + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + input := cfg.input + if input == nil { + input = os.Stdin + } + var output io.Writer = os.Stderr + if cfg.output != nil { + output = cfg.output + } + + return keyboardFlow(ctx, authURL, input, output) +} + +// loginBrowser performs the browser-based flow: starts a callback server, +// opens the browser, and waits for the callback. Falls back to keyboard +// if the browser cannot be opened. +// +// Returns (code, redirectURI, error). The redirectURI must be passed to +// exchangeCode so that it exactly matches the URI used in the auth +// request. When the function falls back to keyboard flow, the returned +// redirectURI is the keyboard placeholder ("urn:ietf:wg:oauth:2.0:oob"). +func loginBrowser(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, string, error) { + port := cfg.callbackPort + if port == 0 { + port = 8000 + } + + srv, resultCh, err := startCallbackServer(ctx, port, state) + if err != nil { + // Try fallback port if the primary port failed and no custom + // port was specified. + if cfg.callbackPort == 0 { + port = 18000 + srv, resultCh, err = startCallbackServer(ctx, port, state) + } + if err != nil { + // Cannot start callback server, fall back to keyboard. + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + } + defer func() { + _ = srv.Close() + }() + + redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + // Try to open the browser. + if browserErr := openBrowser(authURL); browserErr != nil { + // Browser failed, fall back to keyboard flow. + _ = srv.Close() + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + + // Wait for the callback result or context cancellation. + select { + case <-ctx.Done(): + return "", "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-resultCh: + if result.err != nil { + return "", "", result.err + } + return result.code, redirectURI, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/oidc_test.go b/sdk/go/openshell/v1/oidc/oidc_test.go new file mode 100644 index 0000000000..691ff67c68 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc_test.go @@ -0,0 +1,496 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T021: Login entry point tests --- + +// setupMockProvider creates a mock OIDC provider that serves discovery, +// authorize, and token endpoints. The token endpoint returns a valid +// token response. Returns the server (auto-cleaned up) and its URL. +func setupMockProvider(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "scopes_supported": []string{"openid", "profile", "email"}, + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("login-access-token", "login-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestLogin_MissingOIDCConfig verifies that Login returns ErrOIDCConfig +// when called without a gateway name and without WithIssuer/WithClientID. +func TestLogin_MissingOIDCConfig(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingIssuer verifies that Login returns ErrOIDCConfig +// when only WithClientID is provided (missing issuer). +func TestLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithClientID("test-client")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingClientID verifies that Login returns ErrOIDCConfig +// when only WithIssuer is provided (missing client ID). +func TestLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithIssuer("https://example.com")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_ReusesValidToken verifies FR-019: when a valid token exists +// on disk in the token directory, Login returns it without starting an +// interactive flow. +func TestLogin_ReusesValidToken(t *testing.T) { + resetDiscoveryCache() + + // Create a temp dir with a valid, non-expired token file. + tokenDir := t.TempDir() + existingToken := &oauth2.Token{ + AccessToken: "existing-access-token", + RefreshToken: "existing-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(1 * time.Hour), + } + err := writeToken(tokenDir, existingToken) + require.NoError(t, err) + + // Login with explicit issuer/clientID and WithTokenDir (internal) + // pointing to the directory with the existing token. No OIDC + // provider is needed because the existing token is returned. + tok, err := Login(context.Background(), "", + WithIssuer("https://issuer-should-not-be-called.example.com"), + WithClientID("test-client"), + withTokenDir(tokenDir), + ) + require.NoError(t, err) + assert.Equal(t, "existing-access-token", tok.AccessToken) + assert.Equal(t, "existing-refresh-token", tok.RefreshToken) +} + +// TestLogin_ExpiredTokenTriggersFlow verifies that an expired token on +// disk does not short-circuit: Login proceeds to the interactive flow. +// Since we use keyboard flow (no browser), we feed it a code and verify +// a new token is returned. +func TestLogin_ExpiredTokenTriggersFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + // Write an expired token. + expiredToken := &oauth2.Token{ + AccessToken: "expired-token", + RefreshToken: "old-refresh", + TokenType: "Bearer", + Expiry: time.Now().Add(-1 * time.Hour), // expired + } + err := writeToken(tokenDir, expiredToken) + require.NoError(t, err) + + // Start a callback server ourselves to simulate the auth code callback. + // We'll use keyboard flow to avoid browser dependency. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Use keyboard flow with a reader that provides a fake auth code. + // The mock provider's /token endpoint accepts any code. + codeReader := strings.NewReader("fake-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + assert.Equal(t, "login-refresh-token", tok.RefreshToken) +} + +// TestLogin_KeyboardFlow verifies that Login completes using the +// keyboard flow when WithKeyboardFlow() is set. The test provides a +// mock OIDC provider and feeds an auth code through a reader. +func TestLogin_KeyboardFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted to disk. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_InMemorySkipsPersistence verifies that WithInMemory() +// returns a token without writing to disk. +func TestLogin_InMemorySkipsPersistence(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + WithInMemory(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify NO token file on disk. + _, err = os.Stat(filepath.Join(tokenDir, oidcTokenFile)) + assert.True(t, os.IsNotExist(err), "token file should not exist in in-memory mode") +} + +// TestLogin_DiscoveryFailure verifies that Login returns ErrDiscovery +// when the OIDC provider is unreachable. +func TestLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + // Point to a server that doesn't exist. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := Login(ctx, "", + WithIssuer("http://127.0.0.1:1"), // port 1 should refuse connections + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestLogin_GatewayResolution verifies that Login resolves OIDC config +// from gateway metadata when a gateway name is provided. We use the +// withGatewayResolver option to inject a fake gateway loader. +func TestLogin_GatewayResolution(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("gw-auth-code\n") + + fakeConfig := &gateway.Config{ + Name: "test-gateway", + Endpoint: "gateway.example.com:443", + Dir: tokenDir, + OIDCIssuer: provider.URL, + OIDCClientID: "gateway-client-id", + } + + tok, err := Login(ctx, "test-gateway", + WithKeyboardFlow(), + withInput(codeReader), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "test-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted in the gateway dir. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_GatewayMissingOIDCFields verifies that Login returns +// ErrOIDCConfig when the gateway config has empty OIDC fields. +func TestLogin_GatewayMissingOIDCFields(t *testing.T) { + resetDiscoveryCache() + + fakeConfig := &gateway.Config{ + Name: "no-oidc-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + // OIDCIssuer and OIDCClientID are empty. + } + + _, err := Login(context.Background(), "no-oidc-gw", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return fakeConfig, nil + }), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_GatewayResolutionError verifies that Login propagates +// errors from gateway resolution. +func TestLogin_GatewayResolutionError(t *testing.T) { + resetDiscoveryCache() + + gwErr := fmt.Errorf("gateway not found: no-such-gateway") + + _, err := Login(context.Background(), "no-such-gateway", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return nil, gwErr + }), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway not found") +} + +// TestLogin_NoPKCESupport verifies that Login proceeds without PKCE +// when the OIDC provider does not advertise S256 support. +func TestLogin_NoPKCESupport(t *testing.T) { + resetDiscoveryCache() + + // Create a provider that does NOT list S256 in supported methods. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "scopes_supported": []string{"openid"}, + // No code_challenge_methods_supported field. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + var receivedVerifier string + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedVerifier = r.Form.Get("code_verifier") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("no-pkce-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "no-pkce-token", tok.AccessToken) + + // Verify no PKCE verifier was sent to the token endpoint. + assert.Empty(t, receivedVerifier, "code_verifier should not be sent when PKCE is not supported") +} + +// TestLogin_ContextCancellation verifies that Login respects context +// cancellation during the interactive flow. +func TestLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that is already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + // Should get a context error or timeout error. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, ErrDiscovery) || errors.Is(err, context.Canceled), + "expected timeout/discovery/cancelled error, got: %v", err, + ) +} + +// TestLogin_CustomScopes verifies that WithScopes overrides the +// default scopes sent in the authorization request. +func TestLogin_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + // Provider that captures the auth URL scope parameter. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + WithScopes("openid", "custom-scope"), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-token", tok.AccessToken) +} + +// TestLoginBrowser_PortBusy_FallbackToKeyboard verifies that +// loginBrowser falls back to keyboard flow when the callback server +// port is already occupied and no custom port is set. +func TestLoginBrowser_PortBusy_FallbackToKeyboard(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Occupy port 8000 so startCallbackServer fails on the primary port. + // Then occupy port 18000 so the fallback port also fails. + // This forces loginBrowser into the keyboard fallback path. + ln1, err1 := net.Listen("tcp", "127.0.0.1:8000") + ln2, err2 := net.Listen("tcp", "127.0.0.1:18000") + if err1 != nil || err2 != nil { + if ln1 != nil { + _ = ln1.Close() + } + if ln2 != nil { + _ = ln2.Close() + } + t.Skip("Cannot bind test ports 8000 and 18000") + } + defer func() { _ = ln1.Close() }() + defer func() { _ = ln2.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-fallback-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) +} + +// TestLogin_ContextTimeout verifies that a Login with a very short +// timeout returns a timeout-related error. +func TestLogin_ContextTimeout(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that times out immediately. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + time.Sleep(1 * time.Millisecond) // ensure timeout fires + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/options.go b/sdk/go/openshell/v1/oidc/options.go new file mode 100644 index 0000000000..0c83be1c4a --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "io" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// defaultScopes are the OIDC scopes requested when no custom scopes +// are specified via [WithScopes]. +var defaultScopes = []string{"openid", "profile", "email"} + +// defaultTimeout is the maximum duration for interactive login flows +// (browser, keyboard, device code) when no custom timeout is set. +const defaultTimeout = 2 * time.Minute + +// loginConfig holds the resolved configuration for a single login +// attempt. It is built by applying [LoginOption] functions to a +// zero-value struct and then filling in defaults. +type loginConfig struct { + issuer string + clientID string + clientSecret string + scopes []string + scopesSet bool + callbackPort int + timeout time.Duration + keyboardFlow bool + inMemory bool + displayFunc func(verificationURL, userCode string) + gateway string + + // Internal fields for testing. Not exposed via public API. + tokenDir string // override token directory + input io.Reader // override stdin for keyboard flow + output io.Writer // override stderr for keyboard flow + gatewayResolver func(name string) (*gateway.Config, error) // override gateway.LoadConfig +} + +// applyDefaults fills in default values for fields that were not set +// by any option function. +func (c *loginConfig) applyDefaults() { + if len(c.scopes) == 0 { + // Deep copy to avoid callers mutating the package-level slice. + c.scopes = make([]string, len(defaultScopes)) + copy(c.scopes, defaultScopes) + } + if c.timeout == 0 { + c.timeout = defaultTimeout + } +} + +// LoginOption configures a login attempt. Use the With* functions to +// create option values. +type LoginOption func(*loginConfig) + +// WithIssuer sets the OIDC issuer URL. Required for standalone flows +// (when no gateway name is provided to [Login]). +func WithIssuer(url string) LoginOption { + return func(c *loginConfig) { + c.issuer = url + } +} + +// WithClientID sets the OAuth2 client ID. Required for standalone +// flows (when no gateway name is provided to [Login]). +func WithClientID(id string) LoginOption { + return func(c *loginConfig) { + c.clientID = id + } +} + +// WithClientSecret sets the client secret for the client credentials +// grant. Required for [ClientCredentials]. +func WithClientSecret(secret string) LoginOption { + return func(c *loginConfig) { + c.clientSecret = secret + } +} + +// WithScopes overrides the default scopes (openid, profile, email). +// The provided scopes replace the defaults entirely. +func WithScopes(scopes ...string) LoginOption { + return func(c *loginConfig) { + c.scopes = make([]string, len(scopes)) + copy(c.scopes, scopes) + c.scopesSet = true + } +} + +// WithCallbackPort sets a fixed port for the localhost callback server. +// By default the server tries port 8000, then 18000. +func WithCallbackPort(port int) LoginOption { + return func(c *loginConfig) { + c.callbackPort = port + } +} + +// WithTimeout sets the maximum duration for interactive login flows. +// The default is 2 minutes. +func WithTimeout(d time.Duration) LoginOption { + return func(c *loginConfig) { + c.timeout = d + } +} + +// WithKeyboardFlow forces the keyboard flow (manual URL copy and code +// paste) instead of attempting to open a browser. +func WithKeyboardFlow() LoginOption { + return func(c *loginConfig) { + c.keyboardFlow = true + } +} + +// WithInMemory skips persisting the token to disk. The returned token +// is only available in memory for the lifetime of the process. +func WithInMemory() LoginOption { + return func(c *loginConfig) { + c.inMemory = true + } +} + +// WithDisplayFunc sets a custom display function for the device code +// flow. The function receives the verification URL and user code that +// the user must enter to authorize the device. If not set, the default +// behavior prints to stdout. +func WithDisplayFunc(fn func(verificationURL, userCode string)) LoginOption { + return func(c *loginConfig) { + c.displayFunc = fn + } +} + +// WithGateway sets the gateway name for [DeviceLogin] and +// [ClientCredentials]. When set, OIDC config is read from the +// gateway's metadata.json and tokens are persisted to the gateway +// directory. +func WithGateway(name string) LoginOption { + return func(c *loginConfig) { + c.gateway = name + } +} + +// --- Internal options for testing (unexported) --- + +// withTokenDir overrides the token directory for testing. +func withTokenDir(dir string) LoginOption { + return func(c *loginConfig) { + c.tokenDir = dir + } +} + +// withInput overrides the input reader for keyboard flow testing. +func withInput(r io.Reader) LoginOption { + return func(c *loginConfig) { + c.input = r + } +} + +// withGatewayResolver overrides the gateway.LoadConfig function for +// testing. This allows tests to inject a fake gateway resolver +// without filesystem setup. +func withGatewayResolver(fn func(name string) (*gateway.Config, error)) LoginOption { + return func(c *loginConfig) { + c.gatewayResolver = fn + } +} diff --git a/sdk/go/openshell/v1/oidc/options_test.go b/sdk/go/openshell/v1/oidc/options_test.go new file mode 100644 index 0000000000..28d17c06b4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLoginConfig_Defaults(t *testing.T) { + var cfg loginConfig + cfg.applyDefaults() + + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.scopes) + assert.Equal(t, 2*time.Minute, cfg.timeout) + assert.Empty(t, cfg.issuer) + assert.Empty(t, cfg.clientID) + assert.Empty(t, cfg.clientSecret) + assert.Zero(t, cfg.callbackPort) + assert.False(t, cfg.keyboardFlow) + assert.False(t, cfg.inMemory) + assert.Nil(t, cfg.displayFunc) + assert.Empty(t, cfg.gateway) +} + +func TestWithIssuer(t *testing.T) { + var cfg loginConfig + WithIssuer("https://auth.example.com")(&cfg) + + assert.Equal(t, "https://auth.example.com", cfg.issuer) +} + +func TestWithClientID(t *testing.T) { + var cfg loginConfig + WithClientID("my-app")(&cfg) + + assert.Equal(t, "my-app", cfg.clientID) +} + +func TestWithClientSecret(t *testing.T) { + var cfg loginConfig + WithClientSecret("s3cret")(&cfg) + + assert.Equal(t, "s3cret", cfg.clientSecret) +} + +func TestWithScopes(t *testing.T) { + var cfg loginConfig + WithScopes("openid", "custom")(&cfg) + cfg.applyDefaults() + + // Custom scopes should not be overwritten by defaults. + assert.Equal(t, []string{"openid", "custom"}, cfg.scopes) +} + +func TestWithScopes_DeepCopy(t *testing.T) { + original := []string{"openid", "custom"} + var cfg loginConfig + WithScopes(original...)(&cfg) + + // Mutating the original slice should not affect the config. + original[0] = "mutated" + assert.Equal(t, "openid", cfg.scopes[0]) +} + +func TestWithCallbackPort(t *testing.T) { + var cfg loginConfig + WithCallbackPort(9090)(&cfg) + + assert.Equal(t, 9090, cfg.callbackPort) +} + +func TestWithTimeout(t *testing.T) { + var cfg loginConfig + WithTimeout(5 * time.Minute)(&cfg) + cfg.applyDefaults() + + // Custom timeout should not be overwritten by defaults. + assert.Equal(t, 5*time.Minute, cfg.timeout) +} + +func TestWithKeyboardFlow(t *testing.T) { + var cfg loginConfig + WithKeyboardFlow()(&cfg) + + assert.True(t, cfg.keyboardFlow) +} + +func TestWithInMemory(t *testing.T) { + var cfg loginConfig + WithInMemory()(&cfg) + + assert.True(t, cfg.inMemory) +} + +func TestWithDisplayFunc(t *testing.T) { + called := false + fn := func(_, _ string) { called = true } + + var cfg loginConfig + WithDisplayFunc(fn)(&cfg) + + assert.NotNil(t, cfg.displayFunc) + cfg.displayFunc("http://example.com", "ABCD-1234") + assert.True(t, called) +} + +func TestWithGateway(t *testing.T) { + var cfg loginConfig + WithGateway("prod-gw")(&cfg) + + assert.Equal(t, "prod-gw", cfg.gateway) +} + +func TestMultipleOptions(t *testing.T) { + opts := []LoginOption{ + WithIssuer("https://auth.example.com"), + WithClientID("app-id"), + WithScopes("openid"), + WithTimeout(30 * time.Second), + WithKeyboardFlow(), + } + + var cfg loginConfig + for _, opt := range opts { + opt(&cfg) + } + cfg.applyDefaults() + + assert.Equal(t, "https://auth.example.com", cfg.issuer) + assert.Equal(t, "app-id", cfg.clientID) + assert.Equal(t, []string{"openid"}, cfg.scopes) + assert.Equal(t, 30*time.Second, cfg.timeout) + assert.True(t, cfg.keyboardFlow) +} + +func TestDefaultScopes_NotMutatedByConfig(t *testing.T) { + // Verify the package-level defaultScopes slice is not shared. + var cfg loginConfig + cfg.applyDefaults() + cfg.scopes[0] = "mutated" + + assert.Equal(t, "openid", defaultScopes[0]) +} + +func TestLastOptionWins(t *testing.T) { + var cfg loginConfig + WithIssuer("first")(&cfg) + WithIssuer("second")(&cfg) + + assert.Equal(t, "second", cfg.issuer) +} diff --git a/sdk/go/openshell/v1/oidc/token.go b/sdk/go/openshell/v1/oidc/token.go new file mode 100644 index 0000000000..e551dafc39 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token.go @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/oauth2" +) + +// oidcTokenFile is the filename for persisted OIDC tokens. This must +// match the constant in gateway/token.go for interop. +const oidcTokenFile = "oidc_token.json" + +// tokenExpiryLeeway is the grace period subtracted from the token +// expiry when checking validity. Tokens expiring within this window +// are treated as expired to avoid using a token that expires during +// an in-flight request. +const tokenExpiryLeeway = 10 * time.Second + +// oidcBundle is the on-disk JSON representation of an OIDC token. +// The format is shared with the Rust CLI and the gateway package's +// diskTokenSource for interop. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// writeToken persists an oauth2.Token to disk as oidc_token.json in +// the given directory. The file is written with 0600 permissions +// (owner-only) to protect credentials. +func writeToken(dir string, tok *oauth2.Token) error { + bundle := oidcBundle{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + } + + if !tok.Expiry.IsZero() { + bundle.Expiry = tok.Expiry.UTC().Format(time.RFC3339) + remaining := time.Until(tok.Expiry) + if remaining > 0 { + bundle.ExpiresIn = int64(remaining.Seconds()) + } + } + + data, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("%w: failed to marshal token: %v", ErrTokenPersist, err) + } + + path := filepath.Join(dir, oidcTokenFile) + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("%w: failed to write %s: %v", ErrTokenPersist, path, err) + } + + return nil +} + +// readToken reads an existing oidc_token.json from the given +// directory. It returns: +// - (token, nil) if the file exists, is valid, and the token has not +// expired (with leeway) +// - (nil, nil) if the file does not exist, the token is expired, or +// the access token is empty (not an error, just no reusable token) +// - (nil, error) if the file exists but cannot be parsed +func readToken(dir string) (*oauth2.Token, error) { + path := filepath.Join(dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenPersist, path, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrTokenPersist, oidcTokenFile, err) + } + + if bundle.AccessToken == "" { + return nil, nil + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry from the "expiry" field (RFC 3339). Without an + // explicit expiry, the token is treated as non-expiring (always + // valid); "expires_in" alone cannot reconstruct an absolute time + // without a write timestamp. + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr != nil { + return nil, fmt.Errorf("%w: invalid expiry format in %s: %v", ErrTokenPersist, oidcTokenFile, parseErr) + } + tok.Expiry = expiry + } + + // Check if the token has expired (with leeway). + if !tok.Expiry.IsZero() && time.Now().After(tok.Expiry.Add(-tokenExpiryLeeway)) { + return nil, nil // Expired; caller should re-authenticate. + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/token_test.go b/sdk/go/openshell/v1/oidc/token_test.go new file mode 100644 index 0000000000..3590916b2f --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token_test.go @@ -0,0 +1,182 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestWriteToken_Success(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "access-123", + RefreshToken: "refresh-456", + Expiry: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + // Verify the file was written. + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + assert.Contains(t, string(data), `"access_token":"access-123"`) + assert.Contains(t, string(data), `"refresh_token":"refresh-456"`) + assert.Contains(t, string(data), `"expiry":"2026-07-03T12:00:00Z"`) +} + +func TestWriteToken_ExpiresInCalculated(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(3600 * time.Second) + tok := &oauth2.Token{ + AccessToken: "access-123", + Expiry: expiry, + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // expires_in should be roughly 3600 (within a few seconds). + assert.Contains(t, string(data), `"expires_in":`) +} + +func TestWriteToken_InvalidDirectory(t *testing.T) { + err := writeToken("/nonexistent/path/that/does/not/exist", &oauth2.Token{ + AccessToken: "test", + Expiry: time.Now().Add(time.Hour), + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_Success(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "2099-07-03T12:00:00Z", + "expires_in": 3600 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestReadToken_MissingFile(t *testing.T) { + dir := t.TempDir() + + tok, err := readToken(dir) + assert.Nil(t, tok) + assert.NoError(t, err, "missing file should return nil token, no error") +} + +func TestReadToken_InvalidJSON(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(`{invalid`), 0o600) + require.NoError(t, err) + + _, err = readToken(dir) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_ExpiredToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "expired-access", + "expiry": "2020-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "expired token should return nil") + assert.NoError(t, err, "expired token is not an error, just nil") +} + +func TestReadToken_ValidWithExpiresInFallback(t *testing.T) { + dir := t.TempDir() + // No expiry field, only expires_in. Since we wrote it "now", + // a large expires_in should make the token valid. + content := `{ + "access_token": "access-via-expires-in", + "expires_in": 99999 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + // Token with only expires_in cannot reconstruct a valid Expiry + // without knowing when the file was written. readToken should + // treat it as potentially valid and return it. + assert.NotNil(t, tok) + assert.Equal(t, "access-via-expires-in", tok.AccessToken) +} + +func TestReadToken_EmptyAccessToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "", + "expiry": "2099-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "empty access token should return nil") + assert.NoError(t, err) +} + +func TestWriteAndReadToken_Roundtrip(t *testing.T) { + dir := t.TempDir() + original := &oauth2.Token{ + AccessToken: "roundtrip-access", + RefreshToken: "roundtrip-refresh", + Expiry: time.Now().Add(time.Hour).Truncate(time.Second), + } + + err := writeToken(dir, original) + require.NoError(t, err) + + loaded, err := readToken(dir) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, original.AccessToken, loaded.AccessToken) + assert.Equal(t, original.RefreshToken, loaded.RefreshToken) + // Expiry should be close (within a second due to serialization). + assert.WithinDuration(t, original.Expiry, loaded.Expiry, time.Second) +} + +func TestWriteToken_FilePermissions(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "perm-check", + Expiry: time.Now().Add(time.Hour), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // File should be owner-only readable (0600). + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +}