Files
Andy Dixon 9c7643c616 License under GPLv3
Add SPDX headers to the source, a License section to the README, the
OFL notice for the bundled fonts, and ship LICENSE in the Docker image.
2026-09-20 10:05:06 +01:00

289 lines
11 KiB
Go

// SPDX-License-Identifier: GPL-3.0-or-later
// Copyright (C) 2026 Andy Dixon
package main
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)
func fakeGitHub(t *testing.T) *httptest.Server {
gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/login/oauth/access_token":
r.ParseForm()
if r.Form.Get("client_secret") != "secret" || r.Form.Get("code") != "good" {
json.NewEncoder(w).Encode(map[string]string{"error": "bad_verification_code"})
return
}
json.NewEncoder(w).Encode(map[string]any{"access_token": "ghu_token", "expires_in": 28800})
case "/user":
if r.Header.Get("Authorization") != "Bearer ghu_token" {
w.WriteHeader(401)
return
}
json.NewEncoder(w).Encode(map[string]string{"login": "octocat"})
case "/repos/o/r/code-scanning/alerts":
if r.Header.Get("Authorization") != "Bearer ghu_token" {
w.WriteHeader(401)
return
}
if r.URL.Query().Get("page") == "2" {
// A next link off the API host must not be followed (it would leak the token).
w.Header().Set("Link", `<https://evil.example/steal>; rel="next"`)
json.NewEncoder(w).Encode([]map[string]any{{"html_url": "https://github.com/o/r/security/code-scanning/2", "rule": map[string]any{"id": "go/sql-injection", "severity": "error", "description": "SQL injection"}, "most_recent_instance": map[string]any{"location": map[string]any{"path": "db/query.go", "start_line": 42}}}})
return
}
w.Header().Set("Link", fmt.Sprintf(`<%s/repos/o/r/code-scanning/alerts?page=2>; rel="next", <%s/repos/o/r/code-scanning/alerts?page=2>; rel="last"`, "http://"+r.Host, "http://"+r.Host))
json.NewEncoder(w).Encode([]map[string]any{{"html_url": "https://github.com/o/r/security/code-scanning/1", "rule": map[string]any{"id": "js/xss", "severity": "warning", "security_severity_level": "critical", "description": "XSS"}, "most_recent_instance": map[string]any{"location": map[string]any{"path": "web/app.js", "start_line": 7}}}})
case "/repos/o/r/dependabot/alerts":
w.WriteHeader(403)
case "/steal":
t.Error("followed a pagination link to another host")
default:
w.WriteHeader(404)
}
}))
t.Cleanup(gh.Close)
return gh
}
func (c client) noRedirects() client {
jarClient := *c.http
jarClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
return client{c.t, c.url, &jarClient}
}
func (c client) location(path string) *url.URL {
c.t.Helper()
res, err := c.http.Get(c.url + path)
if err != nil {
c.t.Fatal(err)
}
res.Body.Close()
if res.StatusCode != http.StatusFound {
c.t.Fatalf("GET %s = %d, want redirect", path, res.StatusCode)
}
loc, _ := url.Parse(res.Header.Get("Location"))
return loc
}
type githubStatus struct {
Configured, Connected bool
Login, InstallURL string
}
func TestGitHubSignIn(t *testing.T) {
server, c := newTestServer(t)
c = c.noRedirects()
gh := fakeGitHub(t)
server.github = githubApp{clientID: "client", clientSecret: "secret", slug: "code-nav", webURL: gh.URL, apiURL: gh.URL}
var status githubStatus
c.json("GET", "/api/github/status", "", 200, &status)
if !status.Configured || status.Connected || status.InstallURL != gh.URL+"/apps/code-nav/installations/new" {
t.Fatalf("status before sign-in = %+v", status)
}
login := c.location("/api/github/login")
state := login.Query().Get("state")
if login.Path != "/login/oauth/authorize" || login.Query().Get("client_id") != "client" || state == "" {
t.Fatalf("authorize redirect = %s", login)
}
if got := c.location("/api/github/callback?code=good&state=forged").String(); got != "/?github=error" {
t.Fatalf("forged state redirect = %s", got)
}
// State is single use, so the forged attempt burned it.
if got := c.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=error" {
t.Fatalf("reused state redirect = %s", got)
}
state = c.location("/api/github/login").Query().Get("state")
if got := c.stranger().noRedirects().location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=error" {
t.Fatalf("callback from another session = %s", got)
}
state = c.location("/api/github/login").Query().Get("state")
if got := c.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=connected" {
t.Fatalf("callback redirect = %s", got)
}
c.json("GET", "/api/github/status", "", 200, &status)
if !status.Connected || status.Login != "octocat" {
t.Fatalf("status after sign-in = %+v", status)
}
if raw, _ := json.Marshal(status); strings.Contains(string(raw), "ghu_token") {
t.Fatal("status leaked the token")
}
c.stranger().json("GET", "/api/github/status", "", 200, &status)
if status.Connected {
t.Fatal("another session sees the sign-in")
}
server.expire(time.Now().Add(9 * time.Hour))
c.json("GET", "/api/github/status", "", 200, &status)
if status.Connected || len(server.auth) != 0 {
t.Fatalf("expired token still present: %+v, %d entries", status, len(server.auth))
}
state = c.location("/api/github/login").Query().Get("state")
c.location("/api/github/callback?code=good&state=" + state)
c.json("POST", "/api/github/logout", "", 200, nil)
c.json("GET", "/api/github/status", "", 200, &status)
if status.Connected {
t.Fatal("still connected after logout")
}
}
func TestGitHubRoutesWhenUnconfigured(t *testing.T) {
server, c := newTestServer(t)
server.github = githubApp{}
var status githubStatus
c.json("GET", "/api/github/status", "", 200, &status)
if status.Configured {
t.Fatal("reported configured without credentials")
}
c.json("GET", "/api/github/login", "", 404, nil)
}
func TestGitCloneKeepsTokenOutOfArgsAndIgnoresHostCredentials(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
cmd := gitClone(t.Context(), "https://github.com/o/r", t.TempDir(), "ghu_secret")
if strings.Contains(strings.Join(cmd.Args, " "), "ghu_secret") {
t.Fatal("token appears in git arguments")
}
read := func(key string) string {
probe := exec.Command("git", "config", "--get-all", key)
probe.Env = cmd.Env
out, _ := probe.Output()
return strings.TrimSpace(string(out))
}
if got := read("http.https://github.com/.extraheader"); got != "Authorization: Basic eC1hY2Nlc3MtdG9rZW46Z2h1X3NlY3JldA==" {
t.Errorf("extraheader = %q", got)
}
if got := read("credential.helper"); got != "" {
t.Errorf("credential.helper = %q, want empty", got)
}
}
func TestCloneFailureMessages(t *testing.T) {
configured := githubApp{clientID: "id", clientSecret: "secret"}
private := "fatal: could not read Username for 'https://github.com': terminal prompts disabled"
for _, c := range []struct {
app githubApp
output string
signedIn bool
want string
}{
{configured, private, false, "signin"},
{configured, "remote: Repository not found.\nfatal: repository 'x' not found", true, "install"},
{githubApp{}, private, false, ""},
{configured, "fatal: unable to access: Could not resolve host: github.com", false, ""},
} {
message, got := c.app.cloneFailure(c.output, c.signedIn)
if got != c.want || message == "" {
t.Errorf("cloneFailure(%q, %v) = %q, %q; want %q", c.output, c.signedIn, message, got, c.want)
}
}
}
func TestCloneRepo(t *testing.T) {
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git not installed")
}
source := t.TempDir()
for _, args := range [][]string{{"init", "-q"}, {"-c", "user.name=t", "-c", "user.email=t@t", "commit", "-q", "--allow-empty", "-m", "x"}} {
if out, err := exec.Command("git", append([]string{"-C", source}, args...)...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v %s", args, err, out)
}
}
if out, err := cloneRepo(source, filepath.Join(t.TempDir(), "ok"), ""); err != nil {
t.Fatalf("local clone failed: %v %s", err, out)
}
out, err := cloneRepo(filepath.Join(source, "missing-ghu_secret"), filepath.Join(t.TempDir(), "x"), "ghu_secret")
if err == nil || strings.Contains(out, "timed out") || strings.Contains(out, "ghu_secret") || !strings.Contains(out, "***") {
t.Fatalf("failed clone = %v %q; want a masked, non-timeout error", err, out)
}
defer func(old time.Duration) { cloneTimeout = old }(cloneTimeout)
cloneTimeout = time.Nanosecond
if out, err := cloneRepo(source, filepath.Join(t.TempDir(), "slow"), ""); err == nil || !strings.HasPrefix(out, "timed out") {
t.Fatalf("expired clone = %v %q; want timeout", err, out)
}
}
func signIn(t *testing.T, server *Server, c client, gh *httptest.Server) {
t.Helper()
server.github = githubApp{clientID: "client", clientSecret: "secret", webURL: gh.URL, apiURL: gh.URL}
nr := c.noRedirects()
state := nr.location("/api/github/login").Query().Get("state")
if got := nr.location("/api/github/callback?code=good&state=" + state).String(); got != "/?github=connected" {
t.Fatalf("sign-in failed: %s", got)
}
}
func TestSecurityAlerts(t *testing.T) {
server, c := newTestServer(t)
gh := fakeGitHub(t)
_, snapshot := c.indexLocal(map[string]string{"web/app.js": "x"})
url := "/api/snapshots/" + snapshot + "/alerts"
var body struct {
Available bool
Reason string
CodeScanning alertSet
Dependabot alertSet
}
c.json("GET", url, "", 200, &body)
if body.Available || body.Reason != "not-github" {
t.Fatalf("local snapshot = %+v", body)
}
server.snapshotsMu.Lock()
server.snapshots[snapshot].Repo = "https://github.com/o/r"
server.snapshotsMu.Unlock()
server.github = githubApp{clientID: "client", clientSecret: "secret", webURL: gh.URL, apiURL: gh.URL}
c.json("GET", url, "", 200, &body)
if body.Available || body.Reason != "signin" {
t.Fatalf("signed out = %+v", body)
}
signIn(t, server, c, gh)
c.json("GET", url, "", 200, &body)
if !body.Available || body.CodeScanning.Status != "ok" || len(body.CodeScanning.Alerts) != 2 {
t.Fatalf("code scanning = %+v", body.CodeScanning)
}
first, second := body.CodeScanning.Alerts[0], body.CodeScanning.Alerts[1]
if first.Path != "web/app.js" || first.Severity != "critical" || first.Line != 7 || second.Path != "db/query.go" || second.Severity != "high" || second.Title != "SQL injection" {
t.Fatalf("alerts = %+v", body.CodeScanning.Alerts)
}
if body.Dependabot.Status != "forbidden" || body.Dependabot.Alerts == nil || len(body.Dependabot.Alerts) != 0 {
t.Fatalf("dependabot = %+v", body.Dependabot)
}
c.stranger().json("GET", url, "", 404, nil)
}
func TestDependabotAlertsMapToManifests(t *testing.T) {
gh := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]any{
{"html_url": "https://github.com/o/r/security/dependabot/3", "dependency": map[string]any{"package": map[string]any{"name": "lodash"}, "manifest_path": "web/package-lock.json"}, "security_advisory": map[string]any{"summary": "Prototype pollution", "severity": "HIGH"}},
{"html_url": "https://github.com/o/r/security/dependabot/4", "dependency": map[string]any{"package": map[string]any{"name": "x"}, "manifest_path": "go.mod"}, "security_advisory": map[string]any{"severity": "weird"}},
})
}))
defer gh.Close()
set := githubApp{apiURL: gh.URL}.dependabotAlerts("o/r", "t")
if set.Status != "ok" || len(set.Alerts) != 2 || set.Alerts[0].Path != "web/package-lock.json" || set.Alerts[0].Severity != "high" || set.Alerts[0].Title != "lodash: Prototype pollution" || set.Alerts[1].Severity != "low" {
t.Fatalf("dependabot = %+v", set)
}
}