package web
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// linkSessions opens one session for one link, once.
type linkSessions struct {
*betaSessions
spent bool
}
func (l *linkSessions) RedeemSigninLink(link, ip, agent string) (string, time.Time, string, bool) {
if link != "good" || l.spent {
return "", time.Time{}, "", false
}
l.spent = true
return "cookie-from-link", time.Now().Add(time.Hour), "/r/quarry", true
}
func signinServer(t *testing.T, sess Sessions) *httptest.Server {
t.Helper()
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", Repos: api.NewRepos(map[string]*api.Repo{})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil, []Option{WithSessions(sess)},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
t.Cleanup(srv.Close)
return srv
}
func getNoFollow(t *testing.T, url string) *http.Response {
t.Helper()
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, err := client.Get(url)
if err != nil {
t.Fatal(err)
}
return res
}
// A SIGN-IN LINK OPENS ONE SESSION AND REDIRECTS AT ONCE WITH NO
// REFERRER; THE SECOND GET, AND ANY UNKNOWN LINK, IS ONE PAGE WITH NO
// REDIRECT.
func TestSigninLinkRedirectsOnceAndSpends(t *testing.T) {
accts := newBetaAccts()
srv := signinServer(t, &linkSessions{betaSessions: &betaSessions{accts: accts}})
res := getNoFollow(t, srv.URL+"/signin/good")
var cookie *http.Cookie
for _, c := range res.Cookies() {
if c.Name == sessionCookie {
cookie = c
}
}
if res.StatusCode != http.StatusSeeOther || res.Header.Get("Location") != "/r/quarry" {
t.Errorf("FAIL: first GET = %d to %q; want 303 to /r/quarry", res.StatusCode, res.Header.Get("Location"))
}
if res.Header.Get("Referrer-Policy") != "no-referrer" {
t.Errorf("FAIL: Referrer-Policy = %q; want no-referrer", res.Header.Get("Referrer-Policy"))
}
if cookie == nil || cookie.Value != "cookie-from-link" || !cookie.HttpOnly {
t.Errorf("FAIL: session cookie = %+v; want the link's, HttpOnly", cookie)
}
for _, path := range []string{"/signin/good", "/signin/unknown"} {
res := getNoFollow(t, srv.URL+path)
if res.StatusCode != http.StatusNotFound || res.Header.Get("Location") != "" || len(res.Cookies()) != 0 {
t.Errorf("FAIL: %s = %d location %q cookies %d; want 404, no redirect, no cookie",
path, res.StatusCode, res.Header.Get("Location"), len(res.Cookies()))
}
if res.Header.Get("Referrer-Policy") != "no-referrer" {
t.Errorf("FAIL: %s lacks Referrer-Policy: no-referrer", path)
}
}
}
// A FORGE WHOSE SESSIONS CANNOT REDEEM ANSWERS EVERY LINK AS SPENT.
func TestSigninLinkWithoutRedeemerIsSpent(t *testing.T) {
srv := signinServer(t, &betaSessions{accts: newBetaAccts()})
res := getNoFollow(t, srv.URL+"/signin/good")
if res.StatusCode != http.StatusNotFound || strings.Contains(res.Header.Get("Set-Cookie"), sessionCookie) {
t.Errorf("FAIL: got %d with cookie %q; want 404 and none", res.StatusCode, res.Header.Get("Set-Cookie"))
}
}
// testLetters is a capability set spelled out.
type testLetters string
func (l testLetters) HasAny(s string) bool { return strings.ContainsAny(string(l), s) }
// allowAll is a session plane whose one signed-in account is an admin
// everywhere: what the account could do, were it not on a token.
type allowAll struct{ *betaSessions }
func (allowAll) Resolve(string) (string, bool) { return "agent", true }
func (allowAll) Allow(string, string, ops.Auth) bool { return true }
// A SESSION OPENED BY A LINK IS JUDGED ON ITS TOKEN AT EVERY ADMIN GATE,
// REPOSITORY AND FLEET; A PASSWORD SESSION OF THE SAME ACCOUNT IS NOT.
func TestLinkSessionAdminGatesFollowTheToken(t *testing.T) {
sess := allowAll{&betaSessions{accts: newBetaAccts()}}
verify := Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })
onToken := func(letters string, repos ...string) *http.Request {
req := httptest.NewRequest(http.MethodPost, "/", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c"})
tok := ops.TokenAuth{ID: "T", Caps: func(string) ops.Caps { return testLetters(letters) },
Reaches: func(repo string) bool { return len(repos) == 0 || repo != "" && repo == repos[0] }}
return req.WithContext(ops.WithToken(req.Context(), tok))
}
plain := httptest.NewRequest(http.MethodPost, "/", nil)
plain.AddCookie(&http.Cookie{Name: sessionCookie, Value: "c"})
for _, repo := range []string{"quarry", ""} {
if _, ok := gateAt(verify, sess, repo, ops.Admin, httptest.NewRecorder(), onToken("o")); ok {
t.Errorf("FAIL: gateAt(%q, Admin) let a token ceilinged to o through", repo)
}
if mayAt(verify, sess, repo, ops.Admin, onToken("o")) {
t.Errorf("FAIL: mayAt(%q, Admin) is true for a token ceilinged to o", repo)
}
if _, ok := gateAt(verify, sess, repo, ops.Admin, httptest.NewRecorder(), plain); !ok {
t.Errorf("FAIL: gateAt(%q, Admin) refused a password session", repo)
}
}
if _, ok := gateAt(verify, sess, "quarry", ops.Admin, httptest.NewRecorder(), onToken("oa")); !ok {
t.Error("FAIL: a token holding a on the repository was refused")
}
if _, ok := gateAt(verify, sess, "other", ops.Admin, httptest.NewRecorder(), onToken("oa", "quarry")); ok {
t.Error("FAIL: a token reached a repository it does not name")
}
}
// NEXT CANNOT LEAVE THE SITE BY BACKSLASH OR CONTROL CHARACTER.
func TestSafeNextRefusesBackslashAndControls(t *testing.T) {
for _, next := range []string{"/\\evil.com", "/\t/evil.com", "/a\x00b", "//evil.com", "https://evil.com"} {
if got := safeNext(next); got != "/" {
t.Errorf("FAIL: safeNext(%q) = %q; want /", next, got)
}
}
if got := safeNext("/r/quarry?x=1"); got != "/r/quarry?x=1" {
t.Errorf("FAIL: a local path was changed to %q", got)
}
}
// A LINK OPENED IN A BROWSER THAT IS SIGNED IN KEEPS THAT SESSION IN THE
// STASH INSTEAD OF DROPPING IT.
func TestSigninLinkStashesTheSessionItReplaces(t *testing.T) {
accts := newBetaAccts()
_ = accts.AddAccount("pat", "pat-pw-long", false)
sess := &linkSessions{betaSessions: &betaSessions{accts: accts}}
srv := signinServer(t, sess)
req, _ := http.NewRequest("GET", srv.URL+"/signin/good", nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "tok-pat"})
client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
res, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
var others string
for _, c := range res.Cookies() {
if c.Name == othersCookie {
others = c.Value
}
}
if !strings.Contains(others, "tok-pat") {
t.Errorf("FAIL: the stash cookie %q lacks the replaced session", others)
}
}