package login_test import ( "context" "encoding/json" "net/http" "net/http/httptest" "os" "strings" "testing" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "sourcecraft.dev/bigbes/sr-ht-core/auth" "sourcecraft.dev/bigbes/sr-ht-core/crypto" "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" "sourcecraft.dev/bigbes/sr-ht-ecore/login" ) // TestMain installs the shared test keyset. Without it crypto.Encrypt and // crypto.DecryptWithoutExpiration have no fernet key at all and every test here // would be testing the anonymous path by accident. func TestMain(m *testing.M) { ecoretest.InitCrypto() os.Exit(m.Run()) } // foreignKey stands in for the network key of another instance — or of this one // before a rotation. It is a valid fernet key that is not ecoretest.NetworkKey, // which is the only property the wrong-key test needs. const foreignKey = "AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8=" // seal mints a cookie value the way meta.sr.ht does: the auth.AuthCookie JSON, // fernet-sealed with the instance network key. func seal(t *testing.T, name string) string { t.Helper() payload, err := json.Marshal(auth.AuthCookie{Name: name}) require.NoError(t, err) return string(crypto.Encrypt(payload)) } // sealRaw seals an arbitrary payload, for the cases where the ciphertext is // sound and what is inside it is not. func sealRaw(t *testing.T, payload string) string { t.Helper() return string(crypto.Encrypt([]byte(payload))) } // sealWithForeignKey mints a well-formed fernet token under a key this instance // does not hold — the shape a cookie has after meta rotates the network key, // which is the one "invalid" cookie a legitimate viewer meets in practice. // // It swaps the process-global keyset and puts it back, so tests in this package // must not run in parallel with it. The restore is exact rather than approximate // because ecoretest's keys are constants. func sealWithForeignKey(t *testing.T, name string) string { t.Helper() crypto.InitCrypto(ecoretest.Config("", ecoretest.Set("sr.ht", "network-key", foreignKey))) t.Cleanup(func() { crypto.InitCrypto(ecoretest.Config("")) }) payload, err := json.Marshal(auth.AuthCookie{Name: name}) require.NoError(t, err) value := string(crypto.Encrypt(payload)) // The token has to be a *good* one under the foreign key, or the test using // it would be re-testing "garbage" and the wrong-key path would go // unexercised. require.NotNil(t, crypto.DecryptWithoutExpiration([]byte(value))) crypto.InitCrypto(ecoretest.Config("")) require.Nil(t, crypto.DecryptWithoutExpiration([]byte(value))) return value } // request builds a GET carrying the given cookie; an empty name sets no cookie // at all, which is what a first-time visitor looks like. func request(name, value string) *http.Request { r := httptest.NewRequest(http.MethodGet, "/~bigbes/thing", nil) if name != "" { r.AddCookie(&http.Cookie{Name: name, Value: value}) } return r } // countingHandler records how many times it ran and what identity it saw. Both // halves matter: a middleware that refuses has to not call it at all, and one // that admits has to hand it a resolved identity. type countingHandler struct { calls int username string } func (h *countingHandler) ServeHTTP(_ http.ResponseWriter, r *http.Request) { h.calls++ h.username = login.FromContext(r.Context()) } // --------------------------------------------------------------------------- // Username // --------------------------------------------------------------------------- func TestUsernameDecodesASealedCookie(t *testing.T) { assert.Equal(t, "bigbes", login.Username(seal(t, "bigbes"))) } func TestUsernameStripsTheOwnerSigil(t *testing.T) { // meta writes the bare name, but the canonical form turns up in stored // values and in hand-written fixtures, and "~bigbes" is the same person. assert.Equal(t, "bigbes", login.Username(seal(t, "~bigbes"))) } func TestUsernameIsAnonymousFor(t *testing.T) { tests := []struct { name string value func(t *testing.T) string }{ { name: "an empty value", value: func(*testing.T) string { return "" }, }, { name: "a value that is not a fernet token at all", value: func(*testing.T) string { return "not-a-token" }, }, { name: "a tampered ciphertext", value: func(t *testing.T) string { v := []byte(seal(t, "bigbes")) v[len(v)/2] ^= 'A' ^ 'B' // flip one byte in the middle return string(v) }, }, { name: "a truncated ciphertext", value: func(t *testing.T) string { v := seal(t, "bigbes") return v[:len(v)/2] }, }, { name: "a cookie sealed with a key this instance no longer holds", value: func(t *testing.T) string { return sealWithForeignKey(t, "bigbes") }, }, { name: "a well-sealed payload that is not JSON", value: func(t *testing.T) string { return sealRaw(t, "bigbes") }, }, { name: "a payload carrying no name", value: func(t *testing.T) string { return sealRaw(t, `{"other":"bigbes"}`) }, }, { name: "a payload whose name is only the sigil", value: func(t *testing.T) string { return seal(t, "~") }, }, { name: "a name holding a path separator", value: func(t *testing.T) string { return seal(t, "../../etc/passwd") }, }, { name: "a name that is the parent directory", value: func(t *testing.T) string { return seal(t, "..") }, }, { name: "a name starting with a dash", value: func(t *testing.T) string { return seal(t, "-oProxyCommand") }, }, { name: "a name holding a NUL", value: func(t *testing.T) string { return seal(t, "big\x00bes") }, }, { name: "a name holding a newline", value: func(t *testing.T) string { return seal(t, "bigbes\nlevel=error") }, }, { name: "a name outside ASCII", value: func(t *testing.T) string { return seal(t, "bigbés") }, }, { name: "a name longer than the cap", value: func(t *testing.T) string { return seal(t, strings.Repeat("a", login.MaxUsernameLen+1)) }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { assert.Equal(t, "", login.Username(tt.value(t))) }) } } func TestUsernameAcceptsTheNamesMetaIssues(t *testing.T) { for _, name := range []string{ "bigbes", "a", "user_name", "user-name", "user.name", "CamelCase", "digits1234", strings.Repeat("a", login.MaxUsernameLen), } { t.Run(name, func(t *testing.T) { assert.Equal(t, name, login.Username(seal(t, name))) }) } } // --------------------------------------------------------------------------- // UsernameFromRequest // --------------------------------------------------------------------------- func TestUsernameFromRequest(t *testing.T) { t.Run("reads the unified-login cookie", func(t *testing.T) { r := request(login.CookieName, seal(t, "bigbes")) assert.Equal(t, "bigbes", login.UsernameFromRequest(r)) }) t.Run("is anonymous with no cookie at all", func(t *testing.T) { assert.Equal(t, "", login.UsernameFromRequest(request("", ""))) }) t.Run("ignores a cookie under another name", func(t *testing.T) { // A sound cookie under the wrong name is not our session: reading it // would make any cookie on the parent domain an identity. r := request("sr.ht.other", seal(t, "bigbes")) assert.Equal(t, "", login.UsernameFromRequest(r)) }) } // --------------------------------------------------------------------------- // The validator // --------------------------------------------------------------------------- func TestWithValidator(t *testing.T) { onlyBigbes := func(name string) bool { return name == "bigbes" } t.Run("narrows the default rule", func(t *testing.T) { assert.Equal(t, "bigbes", login.Username(seal(t, "bigbes"), login.WithValidator(onlyBigbes))) assert.Equal(t, "", login.Username(seal(t, "someone"), login.WithValidator(onlyBigbes))) }) t.Run("still runs the decode before the rule", func(t *testing.T) { // A validator that accepts everything does not turn a broken cookie // into an identity: it only replaces the last of the five checks. everything := func(string) bool { return true } assert.Equal(t, "", login.Username("not-a-token", login.WithValidator(everything))) }) t.Run("a nil validator restores the default rather than disabling it", func(t *testing.T) { assert.Equal(t, "", login.Username(seal(t, "../etc"), login.WithValidator(nil))) assert.Equal(t, "bigbes", login.Username(seal(t, "bigbes"), login.WithValidator(nil))) }) t.Run("applies to the request and middleware forms too", func(t *testing.T) { r := request(login.CookieName, seal(t, "someone")) assert.Equal(t, "", login.UsernameFromRequest(r, login.WithValidator(onlyBigbes))) next := &countingHandler{} login.Optional(login.WithValidator(onlyBigbes))(next). ServeHTTP(httptest.NewRecorder(), r) assert.Equal(t, 1, next.calls) assert.Equal(t, "", next.username) }) } func TestValidName(t *testing.T) { assert.True(t, login.ValidName("bigbes")) assert.False(t, login.ValidName("")) assert.False(t, login.ValidName(".")) assert.False(t, login.ValidName("..")) assert.False(t, login.ValidName("-lead")) assert.False(t, login.ValidName("a/b")) assert.False(t, login.ValidName(`a\b`)) assert.False(t, login.ValidName("~bigbes")) // the sigil is stripped before this runs assert.False(t, login.ValidName(strings.Repeat("a", login.MaxUsernameLen+1))) } // --------------------------------------------------------------------------- // Optional / Required // --------------------------------------------------------------------------- func TestOptional(t *testing.T) { t.Run("carries the identity to the handler", func(t *testing.T) { next := &countingHandler{} w := httptest.NewRecorder() login.Optional()(next).ServeHTTP(w, request(login.CookieName, seal(t, "bigbes"))) assert.Equal(t, 1, next.calls) assert.Equal(t, "bigbes", next.username) assert.Equal(t, http.StatusOK, w.Code) }) t.Run("lets an anonymous request through", func(t *testing.T) { // The whole point of Optional: public browsing and public clones must // keep working with no credential at all. next := &countingHandler{} w := httptest.NewRecorder() login.Optional()(next).ServeHTTP(w, request("", "")) assert.Equal(t, 1, next.calls) assert.Equal(t, "", next.username) assert.Equal(t, http.StatusOK, w.Code) }) t.Run("lets an unreadable cookie through as anonymous", func(t *testing.T) { next := &countingHandler{} login.Optional()(next).ServeHTTP(httptest.NewRecorder(), request(login.CookieName, sealWithForeignKey(t, "bigbes"))) assert.Equal(t, 1, next.calls) assert.Equal(t, "", next.username) }) } func TestRequired(t *testing.T) { t.Run("calls deny exactly once and never the handler", func(t *testing.T) { denials := 0 deny := func(w http.ResponseWriter, _ *http.Request) { denials++ http.Error(w, "go and log in", http.StatusFound) } next := &countingHandler{} w := httptest.NewRecorder() login.Required(deny)(next).ServeHTTP(w, request("", "")) assert.Equal(t, 1, denials) assert.Equal(t, 0, next.calls) assert.Equal(t, http.StatusFound, w.Code) }) t.Run("refuses a cookie the validator rejects", func(t *testing.T) { denials := 0 next := &countingHandler{} login.Required(func(http.ResponseWriter, *http.Request) { denials++ })(next). ServeHTTP(httptest.NewRecorder(), request(login.CookieName, seal(t, "../etc"))) assert.Equal(t, 1, denials) assert.Equal(t, 0, next.calls) }) t.Run("passes an authenticated request through with its identity", func(t *testing.T) { denials := 0 next := &countingHandler{} w := httptest.NewRecorder() login.Required(func(http.ResponseWriter, *http.Request) { denials++ })(next). ServeHTTP(w, request(login.CookieName, seal(t, "bigbes"))) assert.Equal(t, 0, denials) assert.Equal(t, 1, next.calls) assert.Equal(t, "bigbes", next.username) assert.Equal(t, http.StatusOK, w.Code) }) t.Run("a nil deny answers a plain 401 instead of panicking", func(t *testing.T) { next := &countingHandler{} w := httptest.NewRecorder() login.Required(nil)(next).ServeHTTP(w, request("", "")) assert.Equal(t, 0, next.calls) assert.Equal(t, http.StatusUnauthorized, w.Code) assert.Contains(t, w.Body.String(), login.Message) }) } // --------------------------------------------------------------------------- // Context // --------------------------------------------------------------------------- func TestContextRoundTrip(t *testing.T) { ctx := login.NewContext(context.Background(), "bigbes") assert.Equal(t, "bigbes", login.FromContext(ctx)) // A context nothing stored an identity in reads as anonymous, which is the // same answer as an anonymous viewer on purpose. assert.Equal(t, "", login.FromContext(context.Background())) // An anonymous identity is stored and read back as "" rather than as a // missing value, so a handler behind Optional never has to tell the two // apart. assert.Equal(t, "", login.FromContext(login.NewContext(context.Background(), ""))) }