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