package authn
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sourcecraft.dev/bigbes/sr-ht-core/auth"
"sourcecraft.dev/bigbes/sr-ht-ecore/login"
)
// captureHandler records the caller present on the request context when reached.
func captureHandler(dst **auth.AuthContext, reached *bool) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
*reached = true
*dst = CallerFromContext(r.Context())
w.WriteHeader(http.StatusOK)
})
}
func runCookieMiddleware(t *testing.T, cookie *http.Cookie) (*auth.AuthContext, int) {
t.Helper()
var got *auth.AuthContext
var reached bool
h := OptionalCookieMiddleware()(captureHandler(&got, &reached))
req := httptest.NewRequest(http.MethodGet, "/", nil)
if cookie != nil {
req.AddCookie(cookie)
}
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
require.True(t, reached, "middleware must always call next (never rejects)")
return got, rec.Code
}
func TestOptionalCookieMiddleware_ValidCookie(t *testing.T) {
withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
}})
got, code := runCookieMiddleware(t, &http.Cookie{
Name: login.CookieName,
Value: forgeCookie(t, "bigbes"),
})
require.Equal(t, http.StatusOK, code)
require.NotNil(t, got, "expected an authenticated caller")
assert.Equal(t, "bigbes", got.Username)
assert.Equal(t, 1, got.UserID)
assert.Equal(t, auth.AUTH_COOKIE, got.AuthMethod)
}
// TestOptionalCookieMiddleware_StripsCanonicalSigil pins a difference from the
// decode this package used to carry: a payload holding the canonical "~name"
// form resolves to the bare name rather than being looked up with the sigil
// still on it — which meta would answer for nobody.
func TestOptionalCookieMiddleware_StripsCanonicalSigil(t *testing.T) {
stub := &stubBackend{users: map[string]auth.AuthContext{
"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
}}
withStubBackend(t, stub)
got, code := runCookieMiddleware(t, &http.Cookie{
Name: login.CookieName,
Value: forgeCookie(t, "~bigbes"),
})
require.Equal(t, http.StatusOK, code)
require.NotNil(t, got)
assert.Equal(t, "bigbes", got.Username)
// The backend is asked for the bare name, not "~bigbes". Asserting on the
// resolved caller alone would not show this: the stub normalises the sigil
// away itself, as meta.LookupUser does, so both spellings resolve either
// way and only the recorded argument says which one was sent.
assert.Equal(t, []string{"bigbes"}, stub.lookedUp)
}
func TestOptionalCookieMiddleware_SuspendedStillResolves(t *testing.T) {
withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{
"susp": sampleUser(2, "susp", auth.USER_TYPE_SUSPENDED),
}})
got, _ := runCookieMiddleware(t, &http.Cookie{
Name: login.CookieName,
Value: forgeCookie(t, "susp"),
})
require.NotNil(t, got, "suspended users must still resolve (reads are allowed)")
assert.True(t, AsCoreCaller(got).Suspended, "resolved caller must be flagged suspended")
}
// TestOptionalCookieMiddleware_UnusableCookieIsAnonymous is this package's own
// contract rather than login's: whatever the decode answers, the middleware
// resolves nobody and still calls next. It is what keeps public browsing and
// public clones working when anything about the session goes wrong.
//
// The name cases are the second thing this package used not to do. A cookie
// naming "../../etc/passwd" or an empty string decoded to that string and went
// straight to the user lookup; now it never reaches the backend at all, and the
// assertion below is that it did not.
func TestOptionalCookieMiddleware_UnusableCookieIsAnonymous(t *testing.T) {
cases := []struct {
name string
cookie func(t *testing.T) *http.Cookie
}{
{"no cookie at all", func(*testing.T) *http.Cookie { return nil }},
{"undecryptable value", func(*testing.T) *http.Cookie {
return &http.Cookie{Name: login.CookieName, Value: "not-a-valid-fernet-token"}
}},
{"empty name in payload", func(t *testing.T) *http.Cookie {
return &http.Cookie{Name: login.CookieName, Value: forgeCookie(t, "")}
}},
{"path separator in name", func(t *testing.T) *http.Cookie {
return &http.Cookie{Name: login.CookieName, Value: forgeCookie(t, "../../etc/passwd")}
}},
{"newline in name", func(t *testing.T) *http.Cookie {
return &http.Cookie{Name: login.CookieName, Value: forgeCookie(t, "alice\nWARN forged")}
}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
stub := &stubBackend{users: map[string]auth.AuthContext{
"bigbes": sampleUser(1, "bigbes", auth.USER_TYPE_USER),
}}
withStubBackend(t, stub)
got, code := runCookieMiddleware(t, c.cookie(t))
assert.Nil(t, got, "must degrade to anonymous")
assert.Equal(t, http.StatusOK, code, "must not reject")
assert.Empty(t, stub.lookedUp, "an unusable cookie must not reach the user lookup")
})
}
}
func TestOptionalCookieMiddleware_UnknownUser(t *testing.T) {
withStubBackend(t, &stubBackend{users: map[string]auth.AuthContext{}})
got, code := runCookieMiddleware(t, &http.Cookie{
Name: login.CookieName,
Value: forgeCookie(t, "ghost"),
})
assert.Nil(t, got, "unknown user must degrade to anonymous")
assert.Equal(t, http.StatusOK, code, "must not reject")
}
func TestOptionalCookieMiddleware_BackendDownIsAnonymous(t *testing.T) {
withStubBackend(t, &stubBackend{lookupErr: errBackendDown})
got, code := runCookieMiddleware(t, &http.Cookie{
Name: login.CookieName,
Value: forgeCookie(t, "bigbes"),
})
assert.Nil(t, got, "backend failure must degrade to anonymous")
assert.Equal(t, http.StatusOK, code, "must not 500")
}