package slidestest
import (
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
slides "amadan.net/oficina/slides/internal/slides"
)
// mailedLink asks for a link for address and returns the one emailed.
func mailedLink(t *testing.T, a *testApp, address string) string {
t.Helper()
before := len(a.mailer.messages())
if rec := post(t, a, "/signin", url.Values{"address": {address}}); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?sent=1" {
t.Fatalf("POST /signin = %d %q, want 303 /signin?sent=1", rec.Code, rec.Header().Get("Location"))
}
msgs := a.mailer.messages()
if len(msgs) != before+1 {
t.Fatalf("POST /signin for %q mailed %d messages, want 1", address, len(msgs)-before)
}
link := magicLinkPattern.FindString(msgs[len(msgs)-1].Body)
if link == "" {
t.Fatalf("no magic link in the mailed body:\n%s", msgs[len(msgs)-1].Body)
}
return link
}
// sessionCookie reports whether rec set a live session cookie.
func sessionCookie(rec *httptest.ResponseRecorder) bool {
for _, c := range rec.Result().Cookies() {
if strings.Contains(c.Name, "session") && c.Value != "" && c.MaxAge >= 0 {
return true
}
}
return false
}
// TestEmailedLinkOpensTheConfirmDoor walks the magic-link sign-in end to
// end: begin, the mail, GET of the emailed link (the confirm door, which
// spends nothing), the door's POST, and a session that opens the deck
// list.
//
// GET /auth/verify used to sign in on sight, so a mail-security gateway
// that fetches every link in an inbound message (Safe Links, Proofpoint)
// spent it before the person clicked, and they were told it had expired.
// rastrillo now confirms on GET and redeems on POST (#64); this pins that
// the confirm step is Slides' own door with one Sign in button, not
// rastrillo's plain English fallback, and that the POST is mounted.
func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) {
a := newApp(t)
link := mailedLink(t, a, allowedAddress)
u, err := url.Parse(link)
if err != nil {
t.Fatal(err)
}
// Opening it, twice, as a scanner and then the person would.
for _, who := range []string{"scanner", "person"} {
rec := get(t, a, u.RequestURI())
if rec.Code != http.StatusOK || len(rec.Result().Cookies()) != 0 {
t.Fatalf("%s's GET = %d with %d cookies, want 200 and nothing minted", who, rec.Code, len(rec.Result().Cookies()))
}
body := rec.Body.String()
for _, want := range []string{
"estilo-door", "estilo-signin-form", "Slides",
"Select Sign in to finish signing in.",
`action="/auth/verify"`,
`name="token" value="` + u.Query().Get("token") + `"`,
} {
if !strings.Contains(body, want) {
t.Errorf("%s: confirm door lacks %q:\n%s", who, want, body)
}
}
if n := strings.Count(body, "`) {
t.Errorf("%s: confirm door has %d buttons, want exactly one Sign in:\n%s", who, n, body)
}
if strings.Contains(body, `name="address"`) {
t.Errorf("%s: confirm door asks for an address again", who)
}
if strings.Contains(body, "slides.signin.") {
t.Errorf("%s: a catalog key leaked onto the confirm door", who)
}
if strings.Contains(body, "
Sign in to ") {
t.Errorf("%s: rastrillo's fallback confirm page, not the door", who)
}
// rauth sets these before RenderConfirm; render must not undo them.
for header, want := range map[string]string{
"Cache-Control": "no-store",
"Referrer-Policy": "strict-origin",
"X-Frame-Options": "DENY",
} {
if got := rec.Header().Get(header); got != want {
t.Errorf("%s: %s = %q, want %q", who, header, got, want)
}
}
}
// A cross-site post is refused and spends nothing: the proper POST
// below still signs in with the same token.
action, token := openLink(t, a, link)
cross := httptest.NewRequest(http.MethodPost, action, strings.NewReader(url.Values{"token": {token}}.Encode()))
cross.Header.Set("Content-Type", "application/x-www-form-urlencoded")
cross.Header.Set("Sec-Fetch-Site", "cross-site")
refused := httptest.NewRecorder()
a.ServeHTTP(refused, cross)
if refused.Code != http.StatusForbidden || len(refused.Result().Cookies()) != 0 {
t.Fatalf("cross-site POST = %d with %d cookies, want 403 and nothing minted", refused.Code, len(refused.Result().Cookies()))
}
// Sign in: the POST spends the link and mints the session.
signed := postLink(t, a, action, token)
if signed.Code != http.StatusSeeOther || signed.Header().Get("Location") != "/auth/landed" || !sessionCookie(signed) {
t.Fatalf("POST %s = %d %q, want 303 /auth/landed with a session", action, signed.Code, signed.Header().Get("Location"))
}
cookies := signed.Result().Cookies()
if list := get(t, a, "/", cookies...); list.Code != http.StatusOK {
t.Fatalf("GET / with the new session = %d, want 200", list.Code)
}
// Spent: the door still opens (it looks nothing up), but its button
// now says the link expired.
action, token = openLink(t, a, link)
again := postLink(t, a, action, token)
if sessionCookie(again) || again.Header().Get("Location") != "/signin?err=expired" {
t.Fatalf("second POST = %d %q, want /signin?err=expired and no session", again.Code, again.Header().Get("Location"))
}
}
// TestALinkRefusedAtRedemptionIsTheDoor: an address on the allowlist
// when its link was mailed but off it when the link is redeemed — the
// list changed and the instance restarted in between — gets the sign-in
// door with the refusal on it, in rauth's 403 (Config.Refused, refused),
// and no session.
func TestALinkRefusedAtRedemptionIsTheDoor(t *testing.T) {
const leaver = "leaver@example.test"
cfg, mailer := newConfig(t)
cfg.AllowedAddresses = []string{allowedAddress, leaver}
d := openTestDB(t, cfg)
before, b, err := slides.New(d, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { b.Close() })
mailer.flush = b.FlushMail
link := mailedLink(t, &testApp{Handler: localised(t, before), mailer: mailer, db: d}, leaver)
// The same database, restarted without the leaver on the list.
cfg.AllowedAddresses = []string{allowedAddress}
afterMux, after, err := slides.New(d, cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { after.Close() })
h := localised(t, afterMux)
rec := followLink(t, h, link)
if rec.Code != http.StatusForbidden || rec.Header().Get("Location") != "" {
t.Fatalf("refused redemption = %d %q, want 403 and the door itself", rec.Code, rec.Header().Get("Location"))
}
if sessionCookie(rec) {
t.Fatal("a refused sign-in set a session cookie")
}
body := rec.Body.String()
for _, want := range []string{"estilo-door", `name="address"`, `action="/signin"`, "can’t sign in to Slides"} {
if !strings.Contains(body, want) {
t.Errorf("refusal door lacks %q:\n%s", want, body)
}
}
if strings.Contains(body, "not admitted here") {
t.Error("the refusal is rastrillo's bare text")
}
if got := rec.Header().Get("Cache-Control"); got != "no-store" {
t.Errorf("refusal door Cache-Control = %q, want no-store", got)
}
}