Skip to content
quarry Artifact [1a355920ff]
Login

Artifact [1a355920ff]

Artifact 1a355920ff3939c2dc6a969264a025883a83c04de08d5798fcb22de937c38980:


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