package server
import ( "crypto/ecdsa" "crypto/elliptic" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "net/http" "net/http/httptest" "net/url" "os" "strings" "testing" "time"
"gitweb/internal/auth"
"gitweb/internal/store"
"upspin.io/upspin"
)
// newSSOTestServer builds a Server with browser SSO enabled and returns a // function that mints issuer-signed tokens the server will accept. func newSSOTestServer(t *testing.T) (*Server, func(sub string, exp time.Time) string) { t.Helper() priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { t.Fatal(err) } pub := upspin.PublicKey(fmt.Sprintf("p256\n%s\n%s\n", priv.X.String(), priv.Y.String())) const issuer = "vault@cosmocr.at" verifier := auth.NewVaultVerifier(issuer, "gitweb", func(upspin.UserName) (upspin.PublicKey, error) { return pub, nil })
s, err := New(Config{
Store: store.NewLocalStore(t.TempDir()),
Web: http.FS(os.DirFS("../../web")),
CacheDir: t.TempDir(),
Vault: verifier,
SSOAuthorizeURL: "https://vault.cosmocr.at/authorize",
SSOAudience: "gitweb",
BaseURL: "https://git.cosmocr.at",
})
if err != nil {
t.Fatal(err)
}
mint := func(sub string, exp time.Time) string {
claims := map[string]any{
"iss": issuer, "sub": sub, "aud": "gitweb",
"iat": time.Now().Unix(), "exp": exp.Unix(),
}
payload, _ := json.Marshal(claims)
p := base64.RawURLEncoding.EncodeToString(payload)
hash := sha256.Sum256([]byte("v1." + p))
r, ss, err := ecdsa.Sign(rand.Reader, priv, hash[:])
if err != nil {
t.Fatal(err)
}
return "v1." + p + "." +
base64.RawURLEncoding.EncodeToString(r.Bytes()) + "." +
base64.RawURLEncoding.EncodeToString(ss.Bytes())
}
return s, mint
}
// TestAuthenticateSessionCookie: a valid session cookie authenticates the // browser; an expired one is ignored (anonymous) and cleared. func TestAuthenticateSessionCookie(t *testing.T) { s, mint := newSSOTestServer(t)
// Valid cookie -> authenticated subject.
r := httptest.NewRequest("GET", "/u@d.com/x", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(time.Hour))})
w := httptest.NewRecorder()
user, ok := s.authenticate(w, r)
if !ok || user != "alice@d.com" {
t.Fatalf("authenticate = (%q, %v), want (alice@d.com, true)", user, ok)
}
// Expired cookie -> anonymous, and the stale cookie is cleared.
r = httptest.NewRequest("GET", "/u@d.com/x", nil)
r.AddCookie(&http.Cookie{Name: sessionCookie, Value: mint("alice@d.com", time.Now().Add(-time.Minute))})
w = httptest.NewRecorder()
user, ok = s.authenticate(w, r)
if !ok || user != "" {
t.Fatalf("expired cookie: authenticate = (%q, %v), want (\"\", true)", user, ok)
}
if !clearsCookie(w, sessionCookie) {
t.Errorf("expired cookie was not cleared: %v", w.Result().Cookies())
}
}
// TestHandleSession covers the callback token-exchange endpoint: state must // match the oauth cookie and the token must verify before a session is set. func TestHandleSession(t *testing.T) { s, mint := newSSOTestServer(t) token := mint("bob@d.com", time.Now().Add(time.Hour))
post := func(oauthState, bodyState, tok string) *httptest.ResponseRecorder {
body, _ := json.Marshal(map[string]string{"token": tok, "state": bodyState})
r := httptest.NewRequest("POST", "/_auth/session", strings.NewReader(string(body)))
r.AddCookie(&http.Cookie{Name: oauthCookie,
Value: url.Values{"state": {oauthState}, "next": {"/u@d.com/priv.git"}}.Encode()})
w := httptest.NewRecorder()
s.handleSession(w, r)
return w
}
// Happy path: matching state + valid token -> 200, session cookie, next.
w := post("s123", "s123", token)
if w.Code != 200 {
t.Fatalf("status = %d, want 200 (%s)", w.Code, w.Body)
}
if !setsCookie(w, sessionCookie, token) {
t.Errorf("session cookie not set to the token")
}
var out struct{ Next string }
json.Unmarshal(w.Body.Bytes(), &out)
if out.Next != "/u@d.com/priv.git" {
t.Errorf("next = %q, want /u@d.com/priv.git", out.Next)
}
// State mismatch -> 400, no session.
if w := post("s123", "WRONG", token); w.Code != 400 {
t.Errorf("state mismatch status = %d, want 400", w.Code)
}
// Invalid token -> 401, no session.
if w := post("s123", "s123", "v1.garbage.a.b"); w.Code != 401 {
t.Errorf("invalid token status = %d, want 401", w.Code)
}
}
// TestDenyReadRoutes: an anonymous browser is redirected to login, a git/curl // client gets a Basic-auth challenge, an authenticated caller gets 404. func TestDenyReadRoutes(t *testing.T) { s, _ := newSSOTestServer(t)
// Browser (Accept: text/html), anonymous -> 303 to /_auth/login.
r := httptest.NewRequest("GET", "/u@d.com/priv.git", nil)
r.Header.Set("Accept", "text/html")
w := httptest.NewRecorder()
s.denyRead(w, r, "")
if w.Code != http.StatusSeeOther {
t.Fatalf("browser deny status = %d, want 303", w.Code)
}
if loc := w.Header().Get("Location"); !strings.HasPrefix(loc, "/_auth/login?next=") {
t.Errorf("Location = %q, want /_auth/login?next=…", loc)
}
// git/curl (no text/html), anonymous -> 401 challenge.
r = httptest.NewRequest("GET", "/u@d.com/priv.git/info/refs", nil)
w = httptest.NewRecorder()
s.denyRead(w, r, "")
if w.Code != http.StatusUnauthorized {
t.Errorf("cli deny status = %d, want 401", w.Code)
}
// Authenticated but unauthorized -> 404 (does not reveal existence).
r = httptest.NewRequest("GET", "/u@d.com/priv.git", nil)
r.Header.Set("Accept", "text/html")
w = httptest.NewRecorder()
s.denyRead(w, r, "eve@d.com")
if w.Code != http.StatusNotFound {
t.Errorf("authed deny status = %d, want 404", w.Code)
}
}
func setsCookie(w *httptest.ResponseRecorder, name, val string) bool { for _, c := range w.Result().Cookies() { if c.Name == name && c.Value == val && c.MaxAge >= 0 { return true } } return false }
func clearsCookie(w *httptest.ResponseRecorder, name string) bool { for _, c := range w.Result().Cookies() { if c.Name == name && c.MaxAge < 0 { return true } } return false }