Skip to content

Commit 3d2b34a

Browse files
author
jxu3
committed
Add unit tests for ProxyService and Server components
- Implement comprehensive tests for ProxyService including handler, caching, circuit breaker, retry logic, streaming responses, error handling, and concurrent requests. - Introduce tests for Server creation, configuration, worker pool functionality, HTTP client timeout handling, and memory management. - Ensure proper handling of various request scenarios and validate server routes and concurrency. - Utilize mock servers and helper functions to simulate and validate expected behaviors.
1 parent a99db0a commit 3d2b34a

File tree

25 files changed

+3515
-862
lines changed

25 files changed

+3515
-862
lines changed

Dockerfile

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,31 +5,37 @@ WORKDIR /app
55
# Install git and ca-certificates for building
66
RUN apk add --no-cache git ca-certificates
77

8-
# Copy go mod file
9-
COPY go.mod ./
8+
# Copy go mod and sum files
9+
COPY go.mod go.sum ./
1010

11-
# Download dependencies (this will create go.sum if it doesn't exist)
11+
# Download dependencies
1212
RUN go mod download
1313

1414
# Copy source code
1515
COPY . .
1616

1717
# Build the binary
18-
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=docker" -o github-copilot-svcs .
18+
ARG VERSION=docker
19+
RUN CGO_ENABLED=0 GOOS=linux go build -ldflags="-s -w -X main.version=${VERSION}" -o github-copilot-svcs ./cmd/github-copilot-svcs
1920

2021
# Final stage
2122
FROM alpine:latest
2223

2324
# Install ca-certificates for HTTPS requests
24-
RUN apk --no-cache add ca-certificates tzdata
25+
RUN apk --no-cache add ca-certificates tzdata wget
2526

26-
WORKDIR /root/
27+
# Create non-root user
28+
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
29+
30+
# Switch to non-root user
31+
USER appuser
32+
WORKDIR /home/appuser/
2733

2834
# Copy the binary from builder
2935
COPY --from=builder /app/github-copilot-svcs .
3036

31-
# Create config directory
32-
RUN mkdir -p /root/.local/share/github-copilot-svcs
37+
# Create config directory for non-root user
38+
RUN mkdir -p /home/appuser/.local/share/github-copilot-svcs
3339

3440
# Expose the default port
3541
EXPOSE 8081
@@ -39,4 +45,4 @@ HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
3945
CMD wget --no-verbose --tries=1 --spider http://localhost:8081/health || exit 1
4046

4147
# Run the binary
42-
CMD ["./github-copilot-svcs", "run"]
48+
CMD ["./github-copilot-svcs", "start"]

Makefile

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@ BINARY=github-copilot-svcs
22
CMD_PATH=./cmd/github-copilot-svcs
33
VERSION?=dev
44

5-
.PHONY: build test test-unit test-integration test-e2e test-all clean run dev
5+
.PHONY: build test test-unit test-integration test-e2e test-all clean run dev lint fmt vet deps update-deps security mocks docker-build docker-run help test-coverage test-short test-verbose
66

77
# Build the binary
88
build:
99
go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) $(CMD_PATH)
1010

1111
# Run the application
1212
run: build
13-
./$(BINARY)
13+
./$(BINARY) start
1414

1515
# Development server with hot reload (requires air: go install github.com/cosmtrek/air@latest)
1616
dev:
@@ -88,7 +88,7 @@ mocks:
8888

8989
# Docker build
9090
docker-build:
91-
docker build -t $(BINARY):$(VERSION) .
91+
docker build --build-arg VERSION=$(VERSION) -t $(BINARY):$(VERSION) .
9292

9393
# Docker run
9494
docker-run:

README.md

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -543,15 +543,53 @@ This is free software: you are free to change and redistribute it under the term
543543

544544
## Contributing
545545

546+
We welcome contributions! Please follow these guidelines:
547+
546548
1. Fork the repository
547-
2. Create a feature branch
548-
3. Make your changes
549-
4. Test thoroughly
550-
5. Submit a pull request
549+
2. Create a feature branch (use descriptive names)
550+
3. Make your changes (follow Go code style and best practices)
551+
4. Add or update tests as needed
552+
5. Run all tests and ensure coverage is not reduced
553+
6. Document your changes in the README if relevant
554+
7. Submit a pull request with a clear description
555+
556+
### Commit Messages
557+
- Use clear, descriptive commit messages
558+
- Reference related issues (e.g., `Fixes #123`)
559+
560+
### Pull Request Review
561+
- All PRs require review by a maintainer
562+
- Address review comments promptly
563+
564+
## Security
565+
566+
- Tokens and secrets are stored securely in the user's home directory with restricted permissions (0700)
567+
- No sensitive data is logged
568+
- All communication with GitHub Copilot uses HTTPS
569+
- Automatic token refresh prevents long-lived token exposure
570+
- Do not commit secrets or sensitive config files; check your `.gitignore`
571+
- For security issues, please contact the maintainers directly
572+
573+
## FAQ / Common Issues
574+
575+
**Q: Authentication fails or times out**
576+
A: Run `./github-copilot-svcs auth` again and check your network connection. Ensure your GitHub account has Copilot access.
577+
578+
**Q: Service won't start or port is in use**
579+
A: Edit your config file to use a different port, or stop the conflicting service.
580+
581+
**Q: Token expires too quickly**
582+
A: Check your system clock and ensure the refresh interval in config is set correctly.
583+
584+
**Q: How do I update configuration?**
585+
A: Edit `~/.local/share/github-copilot-svcs/config.json` or use environment variables if supported.
586+
587+
**Q: How do I report a bug or request a feature?**
588+
A: Open an issue on GitHub with details about your environment and the problem.
551589

552590
## Support
553591

554592
For issues and questions:
555-
1. Check the troubleshooting section
593+
1. Check the troubleshooting and FAQ sections
556594
2. Review the logs for error messages
557595
3. Open an issue with detailed information about your setup and the problem

cmd/github-copilot-svcs/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ var version = "dev"
1212
func main() {
1313
// Initialize logger early
1414
internal.Init()
15+
internal.RegisterProxyMetrics()
1516

1617
const minArgsRequired = 2
1718
if len(os.Args) < minArgsRequired {

docker-compose.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ services:
1010
- LOG_LEVEL=info
1111
volumes:
1212
# Mount config directory for persistent authentication
13-
- ./config:/root/.local/share/github-copilot-svcs
13+
- ./config:/home/appuser/.local/share/github-copilot-svcs
1414
restart: unless-stopped
1515
healthcheck:
1616
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:8081/health"]

go.mod

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,17 @@
11
module github.com/privapps/github-copilot-svcs
22

3-
go 1.21
3+
go 1.23.0
4+
5+
toolchain go1.23.5
6+
7+
require (
8+
github.com/beorn7/perks v1.0.1 // indirect
9+
github.com/cespare/xxhash/v2 v2.3.0 // indirect
10+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
11+
github.com/prometheus/client_golang v1.23.0 // indirect
12+
github.com/prometheus/client_model v0.6.2 // indirect
13+
github.com/prometheus/common v0.65.0 // indirect
14+
github.com/prometheus/procfs v0.16.1 // indirect
15+
golang.org/x/sys v0.33.0 // indirect
16+
google.golang.org/protobuf v1.36.6 // indirect
17+
)

go.sum

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
2+
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
3+
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
4+
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
5+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
6+
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
7+
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
8+
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
9+
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
10+
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
11+
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
12+
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
13+
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
14+
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
15+
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
16+
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
17+
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
18+
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=

internal/auth.go

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
package internal
22

33
import (
4+
"context"
45
"encoding/json"
5-
"errors"
66
"fmt"
77
"net/http"
88
"strings"
@@ -60,14 +60,14 @@ func NewAuthService(httpClient *http.Client) *AuthService {
6060
func (s *AuthService) Authenticate(cfg *Config) error {
6161
now := time.Now().Unix()
6262
if cfg.CopilotToken != "" && cfg.ExpiresAt > now+60 {
63-
logger.Info("Token still valid", "expires_in", cfg.ExpiresAt-now)
63+
Info("Token still valid", "expires_in", cfg.ExpiresAt-now)
6464
return nil // Already authenticated
6565
}
6666

6767
if cfg.CopilotToken != "" {
68-
logger.Info("Token expired or expiring soon, triggering re-auth", "expires_in", cfg.ExpiresAt-now)
68+
Info("Token expired or expiring soon, triggering re-auth", "expires_in", cfg.ExpiresAt-now)
6969
} else {
70-
logger.Info("No token found, starting authentication flow")
70+
Info("No token found, starting authentication flow")
7171
}
7272

7373
// Step 1: Get device code
@@ -105,45 +105,55 @@ func (s *AuthService) Authenticate(cfg *Config) error {
105105

106106
// RefreshToken refreshes the Copilot token using the stored GitHub token
107107
func (s *AuthService) RefreshToken(cfg *Config) error {
108+
return s.RefreshTokenWithContext(context.Background(), cfg)
109+
}
110+
111+
func (s *AuthService) RefreshTokenWithContext(ctx context.Context, cfg *Config) error {
108112
if cfg.GitHubToken == "" {
109-
logger.Warn("Cannot refresh token: no GitHub token available")
110-
return errors.New("no GitHub token available for refresh")
113+
Warn("Cannot refresh token: no GitHub token available")
114+
return NewAuthError("no GitHub token available for refresh", nil)
111115
}
112116

113117
// Retry with exponential backoff
114118
for attempt := 1; attempt <= maxRefreshRetries; attempt++ {
115-
logger.Info("Attempting to refresh Copilot token", "attempt", attempt, "max_attempts", maxRefreshRetries)
119+
Info("Attempting to refresh Copilot token", "attempt", attempt, "max_attempts", maxRefreshRetries)
116120

117121
copilotToken, expiresAt, refreshIn, err := s.getCopilotToken(cfg, cfg.GitHubToken)
118122
if err != nil {
119123
if attempt == maxRefreshRetries {
120-
logger.Error("Token refresh failed after max attempts", "attempts", maxRefreshRetries, "error", err)
124+
Error("Token refresh failed after max attempts", "attempts", maxRefreshRetries, "error", err)
121125
return err
122126
}
123127

124128
// Wait before retry with exponential backoff
125129
waitTime := time.Duration(baseRetryDelay*attempt*attempt) * time.Second
126-
logger.Warn("Token refresh failed, retrying", "attempt", attempt, "wait_time", waitTime, "error", err)
127-
time.Sleep(waitTime)
128-
continue
130+
Warn("Token refresh failed, retrying", "attempt", attempt, "wait_time", waitTime, "error", err)
131+
132+
// Use context-aware sleep
133+
select {
134+
case <-time.After(waitTime):
135+
continue
136+
case <-ctx.Done():
137+
return ctx.Err()
138+
}
129139
}
130140

131-
logger.Info("Token refresh successful", "expires_in", expiresAt-time.Now().Unix())
141+
Info("Token refresh successful", "expires_in", expiresAt-time.Now().Unix())
132142
cfg.CopilotToken = copilotToken
133143
cfg.ExpiresAt = expiresAt
134144
cfg.RefreshIn = refreshIn
135145

136146
return cfg.SaveConfig()
137147
}
138148

139-
return errors.New("maximum retry attempts exceeded")
149+
return NewAuthError("maximum retry attempts exceeded", nil)
140150
}
141151

142152
// EnsureValidToken ensures we have a valid token, refreshing if necessary
143153
func (s *AuthService) EnsureValidToken(cfg *Config) error {
144154
now := time.Now().Unix()
145155
if cfg.CopilotToken == "" {
146-
return errors.New("no token available - authentication required")
156+
return NewAuthError("no token available - authentication required", nil)
147157
}
148158

149159
// Check if token needs refresh (within 5 minutes of expiry or already expired)
@@ -179,8 +189,18 @@ func (s *AuthService) getDeviceCode(cfg *Config) (*deviceCodeResponse, error) {
179189
}
180190

181191
func (s *AuthService) pollForGitHubToken(cfg *Config, deviceCode string, interval int) (string, error) {
192+
return s.pollForGitHubTokenWithContext(context.Background(), cfg, deviceCode, interval)
193+
}
194+
195+
func (s *AuthService) pollForGitHubTokenWithContext(ctx context.Context, cfg *Config, deviceCode string, interval int) (string, error) {
182196
for i := 0; i < 120; i++ { // Poll for 2 minutes max
183-
time.Sleep(time.Duration(interval) * time.Second)
197+
// Use context-aware sleep
198+
select {
199+
case <-time.After(time.Duration(interval) * time.Second):
200+
// Continue with polling
201+
case <-ctx.Done():
202+
return "", ctx.Err()
203+
}
184204

185205
body := fmt.Sprintf(`{"client_id":%q,"device_code":%q,"grant_type":"urn:ietf:params:oauth:grant-type:device_code"}`,
186206
copilotClientID, deviceCode)
@@ -208,15 +228,15 @@ func (s *AuthService) pollForGitHubToken(cfg *Config, deviceCode string, interva
208228
if tr.Error == "authorization_pending" {
209229
continue
210230
}
211-
return "", fmt.Errorf("authorization error: %s - %s", tr.Error, tr.ErrorDesc)
231+
return "", NewAuthError(fmt.Sprintf("authorization failed: %s - %s", tr.Error, tr.ErrorDesc), nil)
212232
}
213233

214234
if tr.AccessToken != "" {
215235
return tr.AccessToken, nil
216236
}
217237
}
218238

219-
return "", fmt.Errorf("authentication timed out")
239+
return "", NewAuthError("authentication timed out", nil)
220240
}
221241

222242
func (s *AuthService) getCopilotToken(cfg *Config, githubToken string) (token string, expiresAt, refreshIn int64, err error) {
@@ -234,7 +254,7 @@ func (s *AuthService) getCopilotToken(cfg *Config, githubToken string) (token st
234254
defer resp.Body.Close()
235255

236256
if resp.StatusCode != http.StatusOK {
237-
return "", 0, 0, fmt.Errorf("failed to get Copilot token: %d", resp.StatusCode)
257+
return "", 0, 0, NewNetworkError("getCopilotToken", copilotAPIKeyURL, fmt.Sprintf("HTTP %d response", resp.StatusCode), nil)
238258
}
239259

240260
var ctr copilotTokenResponse

internal/cli.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -275,7 +275,7 @@ func handleModels() error {
275275
}
276276

277277
// Fetch models
278-
modelList, err := FetchFromModelsDev()
278+
modelList, err := FetchFromModelsDev(httpClient)
279279
if err != nil {
280280
fmt.Printf("Failed to fetch models from models.dev: %v\n", err)
281281
fmt.Println("Using default models:")

0 commit comments

Comments
 (0)