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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions .github/workflows/ci-full-race.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
name: CI Full Race

# Weekly whole-suite race-detector sweep across all three platforms. The race
# detector is too slow/memory-heavy to run on every PR for Windows and macOS,
# so per-PR CI (ci.yml) runs -race only on ubuntu-latest. This workflow closes
# the gap by exercising -race on every OS on a schedule. See REQ-CI-010.

on:
schedule:
# Monday 06:00 UTC
- cron: '0 6 * * 1'
workflow_dispatch:

permissions:
contents: read

jobs:
race:
name: Race detector (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
env:
GOTOOLCHAIN: local
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'

- name: Race detector
run: go test -race -shuffle=on ./...
13 changes: 11 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,17 @@ jobs:
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
go-version: ['1.25']
# -race is expensive (~2x time, high memory). Scope it to ubuntu-latest
# on every PR; the full race sweep across all platforms runs weekly in
# .github/workflows/ci-full-race.yml. See REQ-CI-010.
include:
- os: ubuntu-latest
race: true
- os: windows-latest
race: false
- os: macos-latest
race: false
env:
GOTOOLCHAIN: local
steps:
Expand All @@ -63,7 +72,7 @@ jobs:
run: task vet

- name: Test
run: task test
run: go test -shuffle=on ${{ matrix.race && '-race' || '' }} ./...

coverage:
name: Coverage
Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/gga.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,14 @@ jobs:
echo "$HOME/.local/bin" >> "$GITHUB_PATH"
- name: Verify GGA
run: gga --version
- name: Install OpenCode CLI
run: |
curl -fsSL https://opencode.ai/install | bash
# The install script places the binary in ~/.opencode/bin. Add it to
# PATH for this and subsequent steps so `gga run` can shell out to it.
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
- name: Verify OpenCode CLI
run: opencode --version
- name: Run GGA
env:
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
Expand Down
50 changes: 37 additions & 13 deletions cmd/login_test.go
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
package cmd

import (
"bytes"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"

"github.com/spf13/cobra"

"github.com/danielxxomg/bak-cli/internal/cloud"
"github.com/danielxxomg/bak-cli/internal/config"
)

Expand Down Expand Up @@ -57,26 +61,46 @@ func TestRunLoginWithDeps_NonTTYGuard(t *testing.T) {
}
}

// TestRunLogin_EmptyToken proves the device-flow login path is deterministic and
// does NOT touch the real network. The Device Flow is redirected to a local
// httptest server that never authorizes, so it returns "timed out" within the
// server-advertised expires_in (1s). Calling runLoginWithDeps directly (rather
// than rootCmd.Execute) exercises the exact seam production uses. See REQ-CI-009.
func TestRunLogin_EmptyToken(t *testing.T) {
if os.Getenv("GITHUB_TOKEN") != "" {
t.Skip("GITHUB_TOKEN is set, login may succeed with env token")
t.Skip("GITHUB_TOKEN is set, env token would short-circuit the device flow")
}

bufOut := new(bytes.Buffer)
bufErr := new(bytes.Buffer)
rootCmd.SetOut(bufOut)
rootCmd.SetErr(bufErr)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/login/device/code"):
fmt.Fprint(w, `{"device_code":"dc","user_code":"UC","verification_uri":"http://x","interval":1,"expires_in":1}`)
default:
fmt.Fprint(w, `{"error":"authorization_pending"}`)
}
}))
t.Cleanup(srv.Close)

rootCmd.SetArgs([]string{"login"})
err := rootCmd.Execute()
origBase := cloud.DeviceLoginBase
cloud.DeviceLoginBase = srv.URL
t.Cleanup(func() { cloud.DeviceLoginBase = origBase })

deps, _, _ := setupTestDeps(t)

start := time.Now()
err := runLoginWithDeps(&cobra.Command{}, nil, deps)
elapsed := time.Since(start)

if err == nil {
t.Skip("login succeeded (token possibly already configured)")
t.Fatal("expected error from un-authorized device login, got nil")
}

errStr := err.Error()
if !strings.Contains(errStr, "token") && !strings.Contains(errStr, "config") && !strings.Contains(errStr, "read") {
t.Errorf("unexpected error from login: %v", err)
// Must resolve in seconds, not the server-advertised 10 minutes — proves no
// real network call and the test cannot hang CI.
if elapsed > 2*time.Second {
t.Errorf("login exceeded 2s (elapsed=%v); test is not isolated from the network", elapsed)
}
if !strings.Contains(err.Error(), "timed out") && !strings.Contains(err.Error(), "token") {
t.Errorf("expected a token/timeout error, got: %v", err)
}
}

Expand Down
52 changes: 41 additions & 11 deletions internal/cloud/oauth_device.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cloud

import (
"context"
"encoding/json"
"fmt"
"io"
Expand All @@ -14,6 +15,12 @@ import (
// Overridable for tests (e.g., set to httptest.Server.URL).
var DeviceLoginBase = "https://github.com"

// deviceLoginTimeout bounds the entire device-login poll loop, independent of
// the expires_in the server reports. This prevents a hostile or stalled
// authorization endpoint from hanging the client for the full server-declared
// lifetime (GitHub reports 10 minutes). Overridable for tests.
var deviceLoginTimeout = 60 * time.Second

// DeviceClient performs GitHub OAuth Device Flow (RFC 8628) to obtain an
// access token without requiring a web server on the client side.
//
Expand Down Expand Up @@ -87,8 +94,14 @@ func (c *DeviceClient) RequestToken() (string, error) {
out = io.Discard
}

// Bound the entire Device Flow independently of the server-reported
// expires_in: a stalled or hostile authorization endpoint must not hold the
// client for the full server-declared lifetime (GitHub advertises 10 min).
ctx, cancel := context.WithTimeout(context.Background(), deviceLoginTimeout)
defer cancel()

// 1. Request device code.
deviceResp, err := requestDeviceCode(httpClient, baseURL, c.ClientID)
deviceResp, err := requestDeviceCode(ctx, httpClient, baseURL, c.ClientID)
if err != nil {
return "", fmt.Errorf("device code: %w", err)
}
Expand All @@ -112,6 +125,17 @@ func (c *DeviceClient) RequestToken() (string, error) {
}

// 3. Poll for access token.
return c.pollForAccessToken(ctx, httpClient, baseURL, deviceResp, out)
}

// pollForAccessToken drives the Device Flow poll loop until a token is issued,
// the server-declared expires_in passes, or deviceLoginTimeout (via ctx) fires.
// The server deadline bounds against a mismatched expires_in; the ctx bounds
// against a stalled/hostile endpoint and is the guarantee relied on by tests.
func (c *DeviceClient) pollForAccessToken(
ctx context.Context, httpClient *http.Client, baseURL string,
deviceResp *deviceCodeResponse, out io.Writer,
) (string, error) {
interval := deviceResp.Interval
if interval < 1 {
interval = 5 // default per RFC 8628
Expand All @@ -122,8 +146,11 @@ func (c *DeviceClient) RequestToken() (string, error) {
if time.Now().After(deadline) {
return "", fmt.Errorf("login: timed out waiting for authorization")
}
if err := ctx.Err(); err != nil {
return "", fmt.Errorf("login: timed out waiting for authorization: %w", err)
}

resp, err := pollAccessToken(httpClient, baseURL, c.ClientID, deviceResp.DeviceCode)
resp, err := pollAccessToken(ctx, httpClient, baseURL, c.ClientID, deviceResp.DeviceCode)
if err != nil {
return "", err
}
Expand Down Expand Up @@ -166,7 +193,7 @@ func (c *DeviceClient) RequestToken() (string, error) {
}

// requestDeviceCode POSTs to /login/device/code to start the flow.
func requestDeviceCode(httpClient *http.Client, baseURL, clientID string) (*deviceCodeResponse, error) {
func requestDeviceCode(ctx context.Context, httpClient *http.Client, baseURL, clientID string) (*deviceCodeResponse, error) {
if clientID == "" {
return nil, fmt.Errorf("request device code: client_id is required")
}
Expand All @@ -176,7 +203,7 @@ func requestDeviceCode(httpClient *http.Client, baseURL, clientID string) (*devi
form.Set("scope", "gist")

var dr deviceCodeResponse
if err := postOAuthForm(httpClient, baseURL, "/login/device/code", form, &dr); err != nil {
if err := postOAuthForm(ctx, httpClient, baseURL, "/login/device/code", form, &dr); err != nil {
return nil, fmt.Errorf("device code: %w", err)
}

Expand All @@ -191,14 +218,14 @@ func requestDeviceCode(httpClient *http.Client, baseURL, clientID string) (*devi
}

// pollAccessToken POSTs to /login/oauth/access_token to check for completion.
func pollAccessToken(httpClient *http.Client, baseURL, clientID, deviceCode string) (*tokenPollResponse, error) {
func pollAccessToken(ctx context.Context, httpClient *http.Client, baseURL, clientID, deviceCode string) (*tokenPollResponse, error) {
form := url.Values{}
form.Set("client_id", clientID)
form.Set("device_code", deviceCode)
form.Set("grant_type", "urn:ietf:params:oauth:grant-type:device_code")

var tr tokenPollResponse
if err := postOAuthForm(httpClient, baseURL, "/login/oauth/access_token", form, &tr); err != nil {
if err := postOAuthForm(ctx, httpClient, baseURL, "/login/oauth/access_token", form, &tr); err != nil {
return nil, fmt.Errorf("poll token: %w", err)
}

Expand All @@ -207,15 +234,18 @@ func pollAccessToken(httpClient *http.Client, baseURL, clientID, deviceCode stri

// postOAuthForm POSTs form-encoded data to an OAuth endpoint and unmarshals
// the JSON response into target. Returns an error on transport, status, or
// unmarshal failure.
func postOAuthForm(httpClient *http.Client, baseURL, path string, form url.Values, target any) error {
// unmarshal failure. The provided ctx bounds in-flight requests so a stalled
// authorization endpoint cannot hang the caller.
func postOAuthForm(ctx context.Context, httpClient *http.Client, baseURL, path string, form url.Values, target any) error {
urlStr := strings.TrimRight(baseURL, "/") + path
req, err := newRequest(http.MethodPost, urlStr, "",
"application/json", "application/x-www-form-urlencoded",
strings.NewReader(form.Encode()))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, urlStr, strings.NewReader(form.Encode()))
if err != nil {
return fmt.Errorf("build request: %w", err)
}
// Device Flow endpoints are unauthenticated: no Authorization header.
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "bak-cli")

body, status, err := doRequest(httpClient, req)
if err != nil {
Expand Down
52 changes: 52 additions & 0 deletions internal/cloud/oauth_device_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package cloud

import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -427,3 +429,53 @@ func TestDeviceClient_DefaultsApplied(t *testing.T) {
t.Errorf("expected gho_defaults, got %q", token)
}
}

// TestRequestToken_DeviceLoginTimeout proves the Device Flow poll loop honors a
// bounded client-side timeout independent of the server-reported expires_in.
// The /access_token endpoint below blocks longer than the test guard: without a
// ctx-aware request the call cannot be cancelled and the test fails on the 2s
// guard (RED); once RequestToken wraps deviceLoginTimeout via
// context.WithTimeout the in-flight request is cancelled and returns
// context.DeadlineExceeded well under a second (GREEN). See REQ-CI-009.
func TestRequestToken_DeviceLoginTimeout(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case strings.HasSuffix(r.URL.Path, "/login/device/code"):
fmt.Fprint(w, `{"device_code":"dc","user_code":"UC","verification_uri":"http://x","interval":5,"expires_in":2}`)
default:
// Slow poll: only a ctx-aware request can be cancelled before the guard.
time.Sleep(3 * time.Second)
fmt.Fprint(w, `{"error":"authorization_pending"}`)
}
}))
t.Cleanup(srv.Close)

origBase := DeviceLoginBase
DeviceLoginBase = srv.URL
t.Cleanup(func() { DeviceLoginBase = origBase })

origTimeout := deviceLoginTimeout
deviceLoginTimeout = 50 * time.Millisecond
t.Cleanup(func() { deviceLoginTimeout = origTimeout })

client := &DeviceClient{ClientID: "cid", sleepFn: func(time.Duration) {}}

done := make(chan error, 1)
go func() { _, err := client.RequestToken(); done <- err }()

start := time.Now()
select {
case err := <-done:
if err == nil {
t.Fatal("expected timeout error, got nil")
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("expected context.DeadlineExceeded, got: %v", err)
}
if elapsed := time.Since(start); elapsed > time.Second {
t.Errorf("RequestToken honored server expires_in, not deviceLoginTimeout: elapsed=%v", elapsed)
}
case <-time.After(2 * time.Second):
t.Fatal("RequestToken hung >2s — deviceLoginTimeout not honored")
}
}
Loading
Loading