Skip to content
quarry Artifact [f75e9cef81]
Login

Artifact [f75e9cef81]

Artifact f75e9cef81118dc34703d22c9affbba292a945f95f433ba749b245b7dc6515b4:


package web

import (
	"context"
	"net/http"
	"net/http/httptest"
	"net/url"
	"strings"
	"testing"

	"craftdesign.group/quarry/internal/api"
	"craftdesign.group/quarry/internal/ops"
)

// fakeMailWeb records who a message went to, never its copy — the
// account-email tests only ever need to know that a send happened and
// to whom.
type fakeMailWeb struct{ sent []string }

func (f *fakeMailWeb) Send(ctx context.Context, to, subject, text, html string) (string, error) {
	f.sent = append(f.sent, to)
	return "id", nil
}

func (f *fakeMailWeb) to(address string) bool {
	for _, a := range f.sent {
		if a == address {
			return true
		}
	}
	return false
}

// emailForge is betaForge plus a mail plane: the account-email surface
// (self-serve and the fleet row) needs both an AccountEmail-capable
// store and something to observe what it sent.
func emailForge(t *testing.T) (*httptest.Server, *betaAccts, *fakeMailWeb) {
	t.Helper()
	accts := newBetaAccts()
	_ = accts.AddAccount("root", "root-pw", true)
	_ = accts.AddAccount("eve", "eve-pw", false)
	sess := &betaSessions{accts: accts, admin: "root"}
	mail := &fakeMailWeb{}
	r := ops.NewRegistry()
	if err := api.RegisterAll(r, api.Deps{Version: "v", Accounts: accts, Mail: mail,
		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
}

// followFlash re-requests path with the session cookie AND whatever the
// prior response set (the one-shot flash cookie), the way a browser
// does after a redirect. Mirrors the pattern beta_test.go's invite
// round trip already uses.
func followFlash(t *testing.T, srv *httptest.Server, user, path string, prior *http.Response) string {
	t.Helper()
	req, _ := http.NewRequest("GET", srv.URL+path, nil)
	req.AddCookie(&http.Cookie{Name: sessionCookie, Value: "tok-" + user})
	for _, c := range prior.Cookies() {
		req.AddCookie(c)
	}
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatal(err)
	}
	return body(res)
}

// The section above the matrix (data-hook="account-email") reports
// each of the three states a person's address can be in, and the
// matrix's own email column says it has nowhere to send while there is
// no confirmed address (data-hook="email-unset") rather than offering
// a switch that cannot work.
func TestAccountEmailSectionHooksByState(t *testing.T) {
	srv, accts, _ := emailForge(t)

	page := body(as(t, srv, "eve", "GET", "/me/notifications", nil))
	if !strings.Contains(page, `data-hook="account-email"`) {
		t.Fatal("no account-email section")
	}
	if !strings.Contains(page, "No address set.") {
		t.Errorf("the unset state is not reported:\n%s", page)
	}
	if !strings.Contains(page, `data-hook="email-unset"`) {
		t.Error("the matrix's email column does not say it is unset")
	}

	res := as(t, srv, "eve", "POST", "/me/email", url.Values{"email": {"[email protected]"}})
	body(res)
	if res.StatusCode != http.StatusSeeOther {
		t.Fatalf("setting an address = %d", res.StatusCode)
	}
	page = followFlash(t, srv, "eve", "/me/notifications", res)
	if !strings.Contains(page, "Waiting for confirmation of [email protected].") {
		t.Errorf("the pending state is not reported:\n%s", page)
	}
	if !strings.Contains(page, `data-hook="email-unset"`) {
		t.Error("the matrix should still say unset — nothing is confirmed yet")
	}

	// The confirmation link's token, as the fake store minted it (the
	// real one only ever leaves the forge inside the mailed link).
	var token string
	for tok, account := range accts.emailTokens {
		if account == "eve" {
			token = tok
		}
	}
	if token == "" {
		t.Fatalf("no pending confirmation token recorded: %+v", accts.emailTokens)
	}
	res = as(t, srv, "eve", "GET", "/me/email/confirm?t="+token, nil)
	body(res)
	page = followFlash(t, srv, "eve", "/me/notifications", res)
	if !strings.Contains(page, "<strong>[email protected]</strong>") || !strings.Contains(page, "Confirmed.") {
		t.Errorf("the confirmed state is not reported:\n%s", page)
	}
	if strings.Contains(page, `data-hook="email-unset"`) {
		t.Error("the matrix still says unset after confirmation")
	}

	a, _ := accts.AccountByName("eve")
	if a.Email != "[email protected]" || a.EmailPending != "" {
		t.Fatalf("account state after confirm = %+v", a)
	}
}

// The fleet People row carries a correction control (data-hook
// "person-email"), and posting it reaches account.email.set — which
// stores the address CONFIRMED at once, unlike the self-serve path.
func TestFleetPeopleRowEmailControl(t *testing.T) {
	srv, accts, mail := emailForge(t)

	page := body(as(t, srv, "root", "GET", "/fleet/people", nil))
	if !strings.Contains(page, `data-hook="person-email"`) {
		t.Fatal("no person-email control on the People row")
	}
	if !strings.Contains(page, `action="/fleet/people/email"`) {
		t.Error("the control does not post to /fleet/people/email")
	}

	res := as(t, srv, "root", "POST", "/fleet/people/email",
		url.Values{"name": {"eve"}, "email": {"[email protected]"}})
	body(res)
	if res.StatusCode != http.StatusSeeOther {
		t.Fatalf("admin set = %d", res.StatusCode)
	}
	a, _ := accts.AccountByName("eve")
	if a.Email != "[email protected]" {
		t.Fatalf("account state after admin set = %+v", a)
	}
	if !mail.to("[email protected]") {
		t.Errorf("the account was not told: %v", mail.sent)
	}
}