package web
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"craftdesign.group/quarry/internal/api"
"craftdesign.group/quarry/internal/ops"
)
// resetToken is what betaAccts.ResetPassword mints.
var resetToken = strings.Repeat("cd", 32)
// textMailWeb keeps the plain part, so a test can find the link the
// owner was sent without the page ever having shown it.
type textMailWeb struct{ to, text []string }
func (m *textMailWeb) Send(_ context.Context, to, _, text, _ string) (string, error) {
m.to, m.text = append(m.to, to), append(m.text, text)
return "id", nil
}
// betaForge with mail and an origin: the two things an admin-asked
// reset needs before it will mint anything.
func resetWebForge(t *testing.T) (*httptest.Server, *betaAccts, *textMailWeb) {
t.Helper()
accts := newBetaAccts()
_ = accts.AddAccount("root", "root-pw", true)
sess := &betaSessions{accts: accts, admin: "root"}
mail := &textMailWeb{}
r := ops.NewRegistry()
if err := api.RegisterAll(r, api.Deps{Version: "v", Accounts: accts, Mail: mail,
Origin: "https://forge.example", Repos: api.NewRepos(map[string]*api.Repo{})}); err != nil {
t.Fatal(err)
}
srv := httptest.NewServer(HandlerWith(r, nil, nil,
[]Option{WithSessions(sess), WithCaps(betaCaps{admin: "root"})},
Verify(func(string, string, string, ops.Auth) api.Decision { return api.Denied })))
t.Cleanup(srv.Close)
return srv, accts, mail
}
// A forgotten password is answered by a link the OWNER is sent, at
// their own confirmed address. The admin who asked never sees it: not
// in the redirect, not in a cookie, not on the page.
func TestAForgottenPasswordIsResetThroughALink(t *testing.T) {
srv, accts, mail := resetWebForge(t)
if err := accts.AddAccount("jo", "the-old-password", false); err != nil {
t.Fatal(err)
}
_ = accts.SetEmail("jo", "[email protected]")
res := as(t, srv, "root", "POST", "/fleet/people/reset", url.Values{"name": {"jo"}})
body(res)
if res.StatusCode != 303 {
t.Fatalf("FAIL: reset answered %d", res.StatusCode)
}
if len(mail.to) != 1 || mail.to[0] != "[email protected]" {
t.Fatalf("FAIL: want one mail to [email protected], got %v", mail.to)
}
where := res.Header.Get("Location")
if strings.Contains(where, resetToken) || strings.Contains(where, "?s=") {
t.Errorf("FAIL: the redirect carries the link or a nonce for it: %q", where)
}
for _, c := range res.Cookies() {
if strings.Contains(c.Value, resetToken) {
t.Errorf("FAIL: cookie %q carries the link", c.Name)
}
}
req, _ := http.NewRequest("GET", srv.URL+where, nil)
req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "tok-root"})
for _, c := range res.Cookies() {
req.AddCookie(c)
}
res2, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
page := body(res2)
if strings.Contains(page, resetToken) {
t.Fatal("FAIL: the People tab shows the admin the reset link")
}
if !strings.Contains(page, `data-hook="reset-sent"`) {
t.Error("FAIL: the People tab does not say the reset went")
}
t.Log("ok: the owner is mailed; the admin is handed nothing")
// The old password still works: a reset that locked the account
// would make a lost link worse than the problem it was sent about.
if res := as(t, srv, "", "POST", "/login",
url.Values{"user": {"jo"}, "password": {"the-old-password"}}); res.StatusCode != 303 {
t.Errorf("FAIL: minting a reset locked jo out (login answered %d)", res.StatusCode)
}
// The link in jo's mail is on the forge's own origin, and works.
if !strings.Contains(mail.text[0], "https://forge.example/invite/"+resetToken) {
t.Fatal("FAIL: jo's mail carries no link on the forge's origin")
}
if res := as(t, srv, "", "POST", "/invite/"+resetToken,
url.Values{"password": {"a-brand-new-password"}, "again": {"a-brand-new-password"}}); res.StatusCode != 303 {
t.Fatalf("FAIL: setting the new password answered %d", res.StatusCode)
}
if res := as(t, srv, "", "POST", "/login",
url.Values{"user": {"jo"}, "password": {"a-brand-new-password"}}); res.StatusCode != 303 {
t.Error("FAIL: the new password does not sign jo in")
} else {
t.Log("ok: jo signs in with the password jo chose")
}
}
// No confirmed address, no reset from the forge: refused, nothing sent,
// and the row offers no button that would only ever be refused.
func TestAResetIsRefusedWithNoConfirmedAddress(t *testing.T) {
srv, accts, mail := resetWebForge(t)
if err := accts.AddAccount("jo", "the-old-password", false); err != nil {
t.Fatal(err)
}
page := body(as(t, srv, "root", "GET", "/fleet/people", nil))
if strings.Contains(page, `action="/fleet/people/reset"`) {
t.Error("FAIL: a reset button is offered for an account with no address")
}
res := as(t, srv, "root", "POST", "/fleet/people/reset", url.Values{"name": {"jo"}})
body(res)
if len(mail.to) != 0 {
t.Fatalf("FAIL: mail went out: %v", mail.to)
}
if accts.resets[resetToken] {
t.Fatal("FAIL: a token was minted for an account with no address")
}
t.Log("ok: refused, nothing minted, no button")
}
// Only a site admin may ask. A reset link is a credential for somebody
// else's account, so the gate here is the gate on the page.
func TestOnlyAnAdminMayResetAPassword(t *testing.T) {
srv, accts, _ := resetWebForge(t)
if err := accts.AddAccount("jo", "the-old-password", false); err != nil {
t.Fatal(err)
}
_ = accts.SetEmail("jo", "[email protected]")
if err := accts.AddAccount("mallory", "mallorys-password", false); err != nil {
t.Fatal(err)
}
for _, who := range []string{"", "mallory"} {
res := as(t, srv, who, "POST", "/fleet/people/reset", url.Values{"name": {"jo"}})
body(res)
// A 303 is not success here: signed out, the gate answers with
// a redirect to the login page. Read WHERE it points, or a
// refusal reads as a reset.
where := res.Header.Get("Location")
refused := res.StatusCode != 303 || strings.HasPrefix(where, "/login")
if !refused {
t.Errorf("FAIL: %q minted a reset for somebody else (%d -> %s)", who, res.StatusCode, where)
} else {
t.Logf("ok: %q was refused (%d -> %s)", who, res.StatusCode, where)
}
}
}
// No web door sets somebody else's password either.
func TestNoWebDoorSetsAnotherAccountsPassword(t *testing.T) {
srv, accts, _ := resetWebForge(t)
if err := accts.AddAccount("jo", "the-old-password", false); err != nil {
t.Fatal(err)
}
for _, path := range []string{"/api/v1/accounts/passwd", "/fleet/people/passwd"} {
res := as(t, srv, "root", "POST", path, url.Values{"name": {"jo"}, "password": {"chosen-by-root"}})
body(res)
}
if res := as(t, srv, "", "POST", "/login",
url.Values{"user": {"jo"}, "password": {"chosen-by-root"}}); res.StatusCode == 303 && !strings.HasPrefix(res.Header.Get("Location"), "/login") {
t.Fatal("FAIL: root's chosen password signs jo in")
}
if res := as(t, srv, "", "POST", "/login",
url.Values{"user": {"jo"}, "password": {"the-old-password"}}); res.StatusCode != 303 {
t.Fatalf("FAIL: jo's own password stopped working (%d)", res.StatusCode)
}
t.Log("ok: jo's password is still jo's")
}