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
}
// 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", 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")
}
if strings.Contains(page, `data-hook="email-confirm-form"`) {
t.Error("the code field is drawn with nothing pending")
}
res := as(t, srv, "eve", "POST", "/me/email", url.Values{"email": {"[email protected]"}})
page = body(res)
if res.StatusCode != http.StatusOK {
t.Fatalf("setting an address = %d", res.StatusCode)
}
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")
}
if !strings.Contains(page, `data-hook="email-confirm-form"`) {
t.Error("the code field is not drawn while an address is pending")
}
// The confirmation code, as the fake store minted it (the real one
// only ever leaves the forge inside the mailed message).
code, ok := accts.emailTokens["eve"]
if !ok {
t.Fatalf("no pending confirmation code recorded: %+v", accts.emailTokens)
}
res = as(t, srv, "eve", "POST", "/me/email/confirm", url.Values{"code": {code}})
page = body(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")
}
if strings.Contains(page, `data-hook="email-confirm-form"`) {
t.Error("the code field is still drawn with nothing pending")
}
a, _ := accts.AccountByName("eve")
if a.Email != "[email protected]" || a.EmailPending != "" {
t.Fatalf("account state after confirm = %+v", a)
}
}
// A wrong code refuses and shows the refusal beside the email form.
func TestAccountEmailConfirmRefusesWrongCode(t *testing.T) {
srv, _, _ := emailForge(t)
as(t, srv, "eve", "POST", "/me/email", url.Values{"email": {"[email protected]"}})
res := as(t, srv, "eve", "POST", "/me/email/confirm", url.Values{"code": {"000000"}})
page := body(res)
if !strings.Contains(page, `data-hook="account-email"`) {
t.Fatal("no account-email section")
}
if !strings.Contains(page, "not valid any more") {
t.Errorf("the wrong code is not refused visibly:\n%s", page)
}
}
// 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)
}
}