package main
import ( "bytes" "encoding/base64" "encoding/json" "io" "net/http" "net/http/httptest" "net/url" "path/filepath" "strings" "testing" "time" )
// makeToken builds a token with the given subject and expiry. The signature // segments are dummies (the helper never verifies). func makeToken(sub string, exp int64) string { payload, _ := json.Marshal(map[string]any{ "iss": "vault@cosmocr.at", "sub": sub, "aud": "gitweb", "iat": 0, "exp": exp, }) return "v1." + base64.RawURLEncoding.EncodeToString(payload) + ".AA.BB" }
func TestParseAttrs(t *testing.T) { in := "protocol=https\nhost=git.cosmocr.at\npath=u@d.com/x.git\n\nignored=1\n" attrs := parseAttrs(strings.NewReader(in)) if attrs["host"] != "git.cosmocr.at" || attrs["protocol"] != "https" { t.Fatalf("parsed %v", attrs) } if _, ok := attrs["ignored"]; ok { t.Error("parsing did not stop at blank line") } }
func TestWriteAttrs(t *testing.T) { var b bytes.Buffer writeAttrs(&b, map[string]string{"username": "u@d.com", "password": "tok", "extra": "x"}) got := b.String() if !strings.Contains(got, "username=u@d.com\n") || !strings.Contains(got, "password=tok\n") { t.Fatalf("output %q", got) } if strings.Contains(got, "extra") { t.Error("wrote an attribute git does not expect") } }
func TestDecodeTokenAndExpiry(t *testing.T) { future := time.Now().Add(time.Hour).Unix() tok := makeToken("alice@d.com", future) info, err := decodeToken(tok) if err != nil { t.Fatal(err) } if info.Sub != "alice@d.com" || info.Exp != future { t.Fatalf("decoded %+v", info) } if expired(tok, time.Now()) { t.Error("fresh token reported expired") } past := makeToken("alice@d.com", time.Now().Add(-time.Hour).Unix()) if !expired(past, time.Now()) { t.Error("expired token reported valid") } if !expired("garbage", time.Now()) { t.Error("garbage token should be treated as expired") } }
func TestCacheRoundTrip(t *testing.T) { c := cache{path: filepath.Join(t.TempDir(), "cosmocr", "tokens.json")} if _, ok := c.get("git.cosmocr.at"); ok { t.Fatal("empty cache returned a token") } if err := c.set("git.cosmocr.at", "tok1"); err != nil { t.Fatal(err) } if tok, ok := c.get("git.cosmocr.at"); !ok || tok != "tok1" { t.Fatalf("get after set = %q,%v", tok, ok) } if err := c.del("git.cosmocr.at"); err != nil { t.Fatal(err) } if _, ok := c.get("git.cosmocr.at"); ok { t.Fatal("token present after del") } }
// TestLoopbackLogin simulates the browser: openURL receives the authorize URL, // extracts the redirect_uri and state, and calls back with a minted token. func TestLoopbackLogin(t *testing.T) { want := makeToken("bob@d.com", time.Now().Add(time.Hour).Unix())
cfg := config{
vaultURL: "https://vault.example", // not contacted in loopback flow
audience: "gitweb",
client: http.DefaultClient,
stderr: io.Discard,
timeout: 5 * time.Second,
}
cfg.openURL = func(authURL string) {
u, err := url.Parse(authURL)
if err != nil {
t.Error(err)
return
}
q := u.Query()
cb, _ := url.Parse(q.Get("redirect_uri"))
cbq := cb.Query()
cbq.Set("token", want)
cbq.Set("state", q.Get("state"))
cb.RawQuery = cbq.Encode()
// Simulate the browser hitting the loopback callback.
go func() {
resp, err := http.Get(cb.String())
if err == nil {
resp.Body.Close()
}
}()
}
got, err := cfg.loopbackLogin()
if err != nil {
t.Fatalf("loopbackLogin: %v", err)
}
if got != want {
t.Fatalf("token = %q, want %q", got, want)
}
}
func TestLoopbackLoginStateMismatch(t *testing.T) { cfg := config{ audience: "gitweb", client: http.DefaultClient, stderr: io.Discard, timeout: 2 * time.Second, } cfg.openURL = func(authURL string) { u, _ := url.Parse(authURL) cb, _ := url.Parse(u.Query().Get("redirect_uri")) cbq := cb.Query() cbq.Set("token", "x") cbq.Set("state", "WRONG") cb.RawQuery = cbq.Encode() go func() { http.Get(cb.String()) }() } if _, err := cfg.loopbackLogin(); err == nil { t.Fatal("expected state-mismatch error") } }
// TestDeviceLogin runs the device flow against a fake vault that returns // "pending" once, then "approved". func TestDeviceLogin(t *testing.T) { want := makeToken("carol@d.com", time.Now().Add(time.Hour).Unix()) polls := 0 mux := http.NewServeMux() mux.HandleFunc("/api/device/code", func(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]any{ "device_code": "dc", "user_code": "ABCD-EFGH", "verification_uri": "/authorize", "interval": 1, "expires_in": 600, }) }) mux.HandleFunc("/api/device/token", func(w http.ResponseWriter, r *http.Request) { polls++ if polls < 2 { json.NewEncoder(w).Encode(map[string]string{"status": "pending"}) return } json.NewEncoder(w).Encode(map[string]string{"status": "approved", "token": want}) }) ts := httptest.NewServer(mux) defer ts.Close()
var log bytes.Buffer
cfg := config{
vaultURL: ts.URL, audience: "gitweb", client: ts.Client(),
stderr: &log, timeout: 10 * time.Second,
}
got, err := cfg.deviceLogin()
if err != nil {
t.Fatalf("deviceLogin: %v", err)
}
if got != want {
t.Fatalf("token = %q, want %q", got, want)
}
// The user must have been shown the verification URL and code.
if !strings.Contains(log.String(), "ABCD-EFGH") || !strings.Contains(log.String(), ts.URL+"/authorize") {
t.Errorf("instructions not printed: %q", log.String())
}
}