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(), "")))
}