package api
import (
"context"
"testing"
"craftdesign.group/quarry/internal/ops"
)
// fakeAccounts is the minimal AccountStore + AccountEmail this
// package's tests need: an in-memory roster, grants, and the
// pending/confirm token bookkeeping email.go's ops drive.
type fakeAccounts struct {
accounts map[string]Account
grants map[string]map[string]string // account -> repo -> role
pending map[string]string // token -> account
addr map[string]string // token -> address
}
func newFakeAccounts() *fakeAccounts {
return &fakeAccounts{
accounts: map[string]Account{},
grants: map[string]map[string]string{},
pending: map[string]string{},
addr: map[string]string{},
}
}
func (f *fakeAccounts) Accounts() ([]Account, error) {
var out []Account
for _, a := range f.accounts {
out = append(out, a)
}
return out, nil
}
func (f *fakeAccounts) AddAccount(name, password string, admin bool) error {
f.accounts[name] = Account{Name: name, Admin: admin}
return nil
}
func (f *fakeAccounts) SetPassword(name, password string) error { return nil }
func (f *fakeAccounts) DeleteAccount(name string) error { delete(f.accounts, name); return nil }
func (f *fakeAccounts) Grant(account, repo, role string) error {
if f.grants[account] == nil {
f.grants[account] = map[string]string{}
}
f.grants[account][repo] = role
return nil
}
func (f *fakeAccounts) Revoke(account, repo string) error {
delete(f.grants[account], repo)
return nil
}
func (f *fakeAccounts) GrantsFor(account string) (map[string]string, error) {
return f.grants[account], nil
}
// The confirmed-address half (AccountEmail).
func (f *fakeAccounts) AccountByName(name string) (Account, bool) {
a, ok := f.accounts[name]
return a, ok
}
func (f *fakeAccounts) SetEmail(name, email string) error {
a := f.accounts[name]
a.Name = name
a.Email = email
f.accounts[name] = a
return nil
}
func (f *fakeAccounts) SetPendingEmail(name, address string) (string, error) {
token := name + ":" + address
f.pending[token] = name
f.addr[token] = address
a := f.accounts[name]
a.Name = name
a.EmailPending = address
f.accounts[name] = a
return token, nil
}
func (f *fakeAccounts) EmailTokenAccount(token string) (string, bool) {
acct, ok := f.pending[token]
return acct, ok
}
func (f *fakeAccounts) ConfirmEmail(token string) (string, string, bool, error) {
acct, ok := f.pending[token]
if !ok {
return "", "", false, nil
}
address := f.addr[token]
a := f.accounts[acct]
a.Email, a.EmailPending = address, ""
f.accounts[acct] = a
delete(f.pending, token)
delete(f.addr, token)
return acct, address, true, nil
}
func (f *fakeAccounts) ClearEmail(name string) error {
a := f.accounts[name]
a.Email, a.EmailPending = "", ""
f.accounts[name] = a
return nil
}
// sentMail is one message fakeMail recorded.
type sentMail struct{ To, Subject string }
// fakeMail is a MailPlane that records what it was asked to send —
// the recipient and that a send happened, never the copy (RFC 0011
// tests already follow this rule; account mail is no exception).
type fakeMail struct{ sent []sentMail }
func (f *fakeMail) Send(ctx context.Context, to, subject, text, html string) (string, error) {
f.sent = append(f.sent, sentMail{To: to, Subject: subject})
return "id", nil
}
func (f *fakeMail) to(address string) bool {
for _, m := range f.sent {
if m.To == address {
return true
}
}
return false
}
// me.email.set sends the confirmation to the NEW address only — the
// address quarry currently mails (the old, confirmed one, if any)
// hears nothing until the new one is actually confirmed.
func TestEmailSetMailsTheNewAddressNotTheOld(t *testing.T) {
accts := newFakeAccounts()
accts.accounts["dan"] = Account{Name: "dan", Email: "[email protected]"}
mail := &fakeMail{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Mail: mail}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
out, err := r.Invoke(ctx, "me.email.set", meEmailSetIn{Email: "[email protected]", Base: "https://forge.example"})
if err != nil {
t.Fatal(err)
}
set := out.(meEmailSetOut)
if !set.Sent || set.Pending != "[email protected]" {
t.Fatalf("me.email.set = %+v", set)
}
if !mail.to("[email protected]") {
t.Fatalf("the new address was not mailed: %+v", mail.sent)
}
if mail.to("[email protected]") {
t.Fatalf("the old, still-confirmed address was mailed: %+v", mail.sent)
}
a, _ := accts.AccountByName("dan")
if a.EmailPending != "[email protected]" || a.Email != "[email protected]" {
t.Fatalf("account state after set = %+v", a)
}
}
// me.email.confirm promotes the pending address and tells the address
// being LEFT, transactionally, that it is no longer the one in use.
func TestEmailConfirmPromotesAndTellsTheOldAddress(t *testing.T) {
accts := newFakeAccounts()
accts.accounts["dan"] = Account{Name: "dan", Email: "[email protected]"}
mail := &fakeMail{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Mail: mail}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
setOut, err := r.Invoke(ctx, "me.email.set", meEmailSetIn{Email: "[email protected]"})
if err != nil {
t.Fatal(err)
}
_ = setOut
// The token itself never leaves the store in this harness (it went
// to the fake mailer, not back to the caller) — read it the way
// ConfirmEmail itself would resolve it, off the fake's own map.
var token string
for tok, acct := range accts.pending {
if acct == "dan" {
token = tok
}
}
if token == "" {
t.Fatal("no pending token recorded")
}
confirmOut, err := r.Invoke(ctx, "me.email.confirm", meEmailConfirmIn{Token: token})
if err != nil {
t.Fatal(err)
}
if got := confirmOut.(meEmailConfirmOut).Email; got != "[email protected]" {
t.Fatalf("confirm reported %q", got)
}
a, _ := accts.AccountByName("dan")
if a.Email != "[email protected]" || a.EmailPending != "" {
t.Fatalf("account state after confirm = %+v", a)
}
if !mail.to("[email protected]") {
t.Fatalf("the superseded address was not told: %+v", mail.sent)
}
// Confirming again must refuse: the token is spent.
if _, err := r.Invoke(ctx, "me.email.confirm", meEmailConfirmIn{Token: token}); err == nil {
t.Fatal("a spent confirmation token was accepted twice")
}
}
// A token confirmed from a different signed-in account must refuse —
// and must not spend the real owner's token in the process.
func TestEmailConfirmRefusesAnotherAccountsToken(t *testing.T) {
accts := newFakeAccounts()
accts.accounts["dan"] = Account{Name: "dan"}
accts.accounts["eve"] = Account{Name: "eve"}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts}); err != nil {
t.Fatal(err)
}
danCtx := ops.WithUser(context.Background(), "dan")
if _, err := r.Invoke(danCtx, "me.email.set", meEmailSetIn{Email: "[email protected]"}); err != nil {
t.Fatal(err)
}
var token string
for tok, acct := range accts.pending {
if acct == "dan" {
token = tok
}
}
eveCtx := ops.WithUser(context.Background(), "eve")
if _, err := r.Invoke(eveCtx, "me.email.confirm", meEmailConfirmIn{Token: token}); err == nil {
t.Fatal("eve confirmed dan's token")
}
// dan's own token must still be live.
if _, err := r.Invoke(danCtx, "me.email.confirm", meEmailConfirmIn{Token: token}); err != nil {
t.Fatalf("dan's own token was burned by eve's attempt: %v", err)
}
}
// me.email.clear blanks both halves.
func TestEmailClear(t *testing.T) {
accts := newFakeAccounts()
accts.accounts["dan"] = Account{Name: "dan", Email: "[email protected]", EmailPending: "[email protected]"}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
if _, err := r.Invoke(ctx, "me.email.clear", meEmailClearIn{}); err != nil {
t.Fatal(err)
}
a, _ := accts.AccountByName("dan")
if a.Email != "" || a.EmailPending != "" {
t.Fatalf("account state after clear = %+v", a)
}
}
// account.email.set is the admin path: stored confirmed at once, and
// the account is told at that address.
func TestAccountEmailSetIsAdminAndTellsTheAccount(t *testing.T) {
accts := newFakeAccounts()
accts.accounts["dan"] = Account{Name: "dan"}
mail := &fakeMail{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Mail: mail}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "admin")
if _, err := r.Invoke(ctx, "account.email.set", accountEmailSetIn{Name: "dan", Email: "[email protected]"}); err != nil {
t.Fatal(err)
}
a, _ := accts.AccountByName("dan")
if a.Email != "[email protected]" {
t.Fatalf("account state after admin set = %+v", a)
}
if !mail.to("[email protected]") {
t.Fatalf("the account was not told: %+v", mail.sent)
}
}
// Without a mail plane, every one of these still answers correctly —
// no error, the write still lands — and simply sends nothing.
func TestNoMailPlaneMeansNoMailAndNoError(t *testing.T) {
accts := newFakeAccounts()
accts.accounts["dan"] = Account{Name: "dan"}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts}); err != nil { // no Mail
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "dan")
out, err := r.Invoke(ctx, "me.email.set", meEmailSetIn{Email: "[email protected]"})
if err != nil {
t.Fatal(err)
}
if out.(meEmailSetOut).Sent {
t.Fatal("Sent = true with no mail plane configured")
}
}