package api
import (
"bytes"
"context"
"encoding/json"
"log"
"strings"
"testing"
"time"
"craftdesign.group/quarry/internal/ops"
)
// resetToken is what resetAccounts mints, so a test can look for it in
// every place it must not be.
var resetToken = strings.Repeat("ab", 32)
// resetAccounts is fakeAccounts with the invite/reset half of
// AccountAdmin, counting the tokens it mints.
type resetAccounts struct {
*fakeAccounts
minted int
}
func (f *resetAccounts) InviteAccount(string, string, bool) (string, error) { return "", nil }
func (f *resetAccounts) InviteFor(string) (string, bool) { return "", false }
func (f *resetAccounts) RedeemInvite(string, string) (string, error) { return "", nil }
func (f *resetAccounts) ResetPassword(string) (string, error) {
f.minted++
return resetToken, nil
}
func (f *resetAccounts) TokenKind(string) string { return "reset" }
func (f *resetAccounts) SetDisabled(string, bool) error { return nil }
func (f *resetAccounts) Sessions(string) ([]SessionInfo, error) { return nil, nil }
func (f *resetAccounts) EndSession(string, string) error { return nil }
func (f *resetAccounts) EndSessions(string) error { return nil }
// textMail records the plain part too: who asked and when is data the
// owner is owed, so the test reads it.
type textMail struct{ sent []struct{ To, Text string } }
func (m *textMail) Send(_ context.Context, to, _, text, _ string) (string, error) {
m.sent = append(m.sent, struct{ To, Text string }{to, text})
return "id", nil
}
func resetForge(t *testing.T) (*ops.Registry, *resetAccounts, *textMail, *recordedActs) {
t.Helper()
accts := &resetAccounts{fakeAccounts: newFakeAccounts()}
_ = accts.AddAccount("root", "root-password", true)
_ = accts.AddAccount("jo", "jos-password", false)
_ = accts.SetEmail("jo", "[email protected]")
_ = accts.AddAccount("nomail", "nomails-password", false)
mail, acts := &textMail{}, &recordedActs{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Mail: mail, Acts: acts,
Origin: "https://forge.example"}); err != nil {
t.Fatal(err)
}
return r, accts, mail, acts
}
// No door lets a site admin choose somebody else's password: the op
// that did is gone, so there is nothing to call.
func TestAnAdminCannotSetAnotherAccountsPassword(t *testing.T) {
r, accts, _, _ := resetForge(t)
if _, ok := r.Lookup("account.passwd"); ok {
t.Fatal("FAIL: account.passwd is still registered")
}
ctx := ops.WithUser(context.Background(), "root")
if _, err := r.Invoke(ctx, "account.passwd", map[string]any{"name": "jo", "password": "chosen-by-root"}); err == nil {
t.Fatal("FAIL: account.passwd answered")
}
if accts.pw["jo"] != "jos-password" {
t.Fatal("FAIL: jo's password changed")
}
t.Log("ok: no op sets another account's password")
}
// The one thing an admin may do: ask for a link to go to the owner's
// own confirmed address. One email, naming the admin and the time; the
// token is in no response, no log line and no receipt.
func TestAnAdminResetMailsTheOwnerAndHandsBackNoToken(t *testing.T) {
r, accts, mail, acts := resetForge(t)
var logged bytes.Buffer
prev := log.Writer()
log.SetOutput(&logged)
defer log.SetOutput(prev)
before := time.Now().UTC()
ctx := ops.WithUser(context.Background(), "root")
out, err := r.Invoke(ctx, "account.reset", accountResetIn{Name: "jo"})
if err != nil {
t.Fatal(err)
}
after := time.Now().UTC()
if len(mail.sent) != 1 || mail.sent[0].To != "[email protected]" {
t.Fatalf("FAIL: want one mail to [email protected], got %+v", mail.sent)
}
text := mail.sent[0].Text
if !strings.Contains(text, "root") {
t.Error("FAIL: the email does not name the admin who asked")
}
if !strings.Contains(text, before.Format(resetStamp)) && !strings.Contains(text, after.Format(resetStamp)) {
t.Error("FAIL: the email does not say when it was asked for")
}
// The link is built on the forge's own origin, never one the caller
// passed: a link to somewhere else would hand the token to whoever
// runs that somewhere else.
if !strings.Contains(text, "https://forge.example/invite/"+resetToken) {
t.Error("FAIL: the link is not on the forge's own origin")
}
wire, _ := json.Marshal(out)
if strings.Contains(string(wire), resetToken) || strings.Contains(string(wire), "/invite/") {
t.Errorf("FAIL: the response carries the token: %s", wire)
}
if strings.Contains(logged.String(), resetToken) {
t.Error("FAIL: the token reached the log")
}
if len(acts.acts) != 1 {
t.Fatalf("FAIL: want one receipt, got %+v", acts.acts)
}
got := acts.acts[0]
if got.Actor != "root" || got.Op != "account.reset" || got.Owner != "jo" {
t.Errorf("FAIL: receipt = %+v", got)
}
if strings.Contains(got.Actor+got.Op+got.Repo+got.Owner, resetToken) {
t.Error("FAIL: the token is in the receipt")
}
if accts.minted != 1 {
t.Errorf("FAIL: minted %d tokens", accts.minted)
}
t.Log("ok: one mail to the owner, naming root and the time; no token handed back, logged or receipted")
}
// An account with no confirmed address cannot be reset from the forge
// at all — and nothing is minted for it, since a token nobody was sent
// is a token waiting to leak. A pending address is not confirmed.
func TestAnAdminResetRefusesAnAccountWithNoConfirmedAddress(t *testing.T) {
r, accts, mail, acts := resetForge(t)
_, _ = accts.SetPendingEmail("nomail", "[email protected]")
ctx := ops.WithUser(context.Background(), "root")
if _, err := r.Invoke(ctx, "account.reset", accountResetIn{Name: "nomail"}); err == nil {
t.Fatal("FAIL: a reset was accepted for an account with no confirmed address")
}
if accts.minted != 0 || len(mail.sent) != 0 || len(acts.acts) != 0 {
t.Fatalf("FAIL: refused reset still minted=%d mailed=%d receipted=%d", accts.minted, len(mail.sent), len(acts.acts))
}
t.Log("ok: refused, nothing minted, nothing sent")
}
// With no mail plane the link has nowhere to go but back to the admin,
// which is the thing this op no longer does.
func TestAnAdminResetRefusesWithNoMail(t *testing.T) {
accts := &resetAccounts{fakeAccounts: newFakeAccounts()}
_ = accts.AddAccount("root", "root-password", true)
_ = accts.AddAccount("jo", "jos-password", false)
_ = accts.SetEmail("jo", "[email protected]")
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Origin: "https://forge.example"}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "root")
if _, err := r.Invoke(ctx, "account.reset", accountResetIn{Name: "jo"}); err == nil {
t.Fatal("FAIL: a reset was accepted with no way to send it")
}
if accts.minted != 0 {
t.Fatal("FAIL: a token was minted that nobody could be sent")
}
}
// An admin is still an account: their OWN password changes the way
// everybody's does, given the current one.
func TestAnAdminStillChangesTheirOwnPassword(t *testing.T) {
r, accts, _, _ := resetForge(t)
ctx := ops.WithUser(context.Background(), "root")
if _, err := r.Invoke(ctx, "me.passwd", mePasswdIn{Current: "root-password", New: "a-new-root-password"}); err != nil {
t.Fatal(err)
}
if accts.pw["root"] != "a-new-root-password" {
t.Fatal("FAIL: root's own password did not change")
}
t.Log("ok: an admin changes their own password through me.passwd")
}
// The bypass #319 closes: an admin points the account's address at
// their own inbox, then asks for a reset. The address stays pending, so
// the reset goes to the OLD confirmed address — and the old address was
// told who did it and when.
func TestAnAdminCannotRedirectAResetToTheirOwnInbox(t *testing.T) {
r, accts, mail, acts := resetForge(t)
ctx := ops.WithUser(context.Background(), "root")
before := time.Now().UTC()
if _, err := r.Invoke(ctx, "account.email.set", accountEmailSetIn{Name: "jo", Email: "[email protected]"}); err != nil {
t.Fatal(err)
}
after := time.Now().UTC()
if a, _ := accts.AccountByName("jo"); a.Email != "[email protected]" || a.EmailPending != "[email protected]" {
t.Fatalf("FAIL: account state after admin set = %+v", a)
}
var told bool
for _, m := range mail.sent {
if m.To == "[email protected]" && strings.Contains(m.Text, "root") &&
(strings.Contains(m.Text, before.Format(resetStamp)) || strings.Contains(m.Text, after.Format(resetStamp))) {
told = true
}
}
if !told {
t.Errorf("FAIL: the confirmed address was not told who and when: %+v", mail.sent)
}
if len(acts.acts) != 1 || acts.acts[0].Actor != "root" || acts.acts[0].Op != "account.email.set" || acts.acts[0].Owner != "jo" {
t.Errorf("FAIL: receipt = %+v", acts.acts)
}
mail.sent = nil
if _, err := r.Invoke(ctx, "account.reset", accountResetIn{Name: "jo"}); err != nil {
t.Fatal(err)
}
if len(mail.sent) != 1 || mail.sent[0].To != "[email protected]" {
t.Fatalf("FAIL: the reset went to %+v, not jo's confirmed address", mail.sent)
}
t.Log("ok: the reset still goes to jo's confirmed address")
}
// With no confirmed address to fall back on, the pending one an admin
// set does not make the account resettable.
func TestAPendingAddressDoesNotMakeAnAccountResettable(t *testing.T) {
r, accts, mail, _ := resetForge(t)
ctx := ops.WithUser(context.Background(), "root")
if _, err := r.Invoke(ctx, "account.email.set", accountEmailSetIn{Name: "nomail", Email: "[email protected]"}); err != nil {
t.Fatal(err)
}
mail.sent = nil
if _, err := r.Invoke(ctx, "account.reset", accountResetIn{Name: "nomail"}); err == nil {
t.Fatal("FAIL: a reset was accepted on an admin-set pending address")
}
if accts.minted != 0 || len(mail.sent) != 0 {
t.Fatalf("FAIL: minted=%d mailed=%+v", accts.minted, mail.sent)
}
t.Log("ok: refused")
}
// The code reaches whoever holds the new inbox — here, the admin. It
// still confirms nothing except from the owner's own session.
func TestTheCodeConfirmsOnlyFromTheOwnersSession(t *testing.T) {
r, accts, _, _ := resetForge(t)
rootCtx := ops.WithUser(context.Background(), "root")
if _, err := r.Invoke(rootCtx, "account.email.set", accountEmailSetIn{Name: "jo", Email: "[email protected]"}); err != nil {
t.Fatal(err)
}
code := accts.pending["jo"]
for _, ctx := range []context.Context{rootCtx, context.Background()} {
if _, err := r.Invoke(ctx, "me.email.confirm", meEmailConfirmIn{Code: code}); err == nil {
t.Errorf("FAIL: the code confirmed from a session that is not jo's (%q)", ops.User(ctx))
}
}
if a, _ := accts.AccountByName("jo"); a.Email != "[email protected]" {
t.Fatalf("FAIL: jo's confirmed address moved to %q", a.Email)
}
if a, _ := accts.AccountByName("root"); a.Email == "[email protected]" {
t.Fatal("FAIL: the code confirmed an address on root's own account")
}
if _, err := r.Invoke(ops.WithUser(context.Background(), "jo"), "me.email.confirm", meEmailConfirmIn{Code: code}); err != nil {
t.Fatalf("FAIL: jo, signed in, could not confirm: %v", err)
}
t.Log("ok: only jo's own session confirms")
}