Skip to content
quarry Artifact [d737033ca0]
Login

Artifact [d737033ca0]

Artifact d737033ca098cf05ed6fcfdbc657d87d3496db3b0b3ed4873a893af37164d890:


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