package api
import (
"context"
"encoding/json"
"reflect"
"strings"
"testing"
"craftdesign.group/quarry/internal/ops"
)
var inviteToken = strings.Repeat("cd", 32)
type inviteAccounts struct{ *resetAccounts }
func (f inviteAccounts) InviteAccount(string, string, bool) (string, error) { return inviteToken, nil }
// #319: an invite that went to the invitee's own address is theirs.
// The admin learns that it went, and does not get a copy of the link.
func TestAnEmailedInviteIsNotHandedToTheAdmin(t *testing.T) {
accts := inviteAccounts{&resetAccounts{fakeAccounts: newFakeAccounts()}}
_ = accts.AddAccount("root", "root-password", true)
mail := &textMail{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Mail: mail, Origin: "https://forge.example"}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "root")
out, err := r.Invoke(ctx, "account.invite", accountInviteIn{Name: "bob", Email: "[email protected]"})
if err != nil {
t.Fatal(err)
}
if len(mail.sent) != 1 || !strings.Contains(mail.sent[0].Text, inviteToken) {
t.Fatalf("FAIL: the invitee was not mailed the link: %+v", mail.sent)
}
wire, _ := json.Marshal(out)
var got map[string]any
_ = json.Unmarshal(wire, &got)
if got["emailed"] != true {
t.Errorf("FAIL: emailed = %v", got["emailed"])
}
if strings.Contains(string(wire), inviteToken) {
t.Errorf("FAIL: the emailed invite's token came back to the admin: %s", wire)
}
// With no address there is nobody else to send it to: the admin
// hands it over, and it only works while the account is unclaimed.
out, err = r.Invoke(ctx, "account.invite", accountInviteIn{Name: "carol"})
if err != nil {
t.Fatal(err)
}
wire, _ = json.Marshal(out)
got = nil
_ = json.Unmarshal(wire, &got)
if got["link"] != "https://forge.example/invite/"+inviteToken {
t.Errorf("FAIL: an invite with no address did not return its link: %s", wire)
}
t.Log("ok: emailed invite withholds the link; unaddressed invite returns it")
}
// #319: the invite link is built on the forge's own configured origin,
// never on one the caller names. An admin who could choose the domain
// would send a genuine invite email whose link collects the invitee's
// new password on the admin's own site.
func TestAnInviteLinkIsNeverOnTheCallersDomain(t *testing.T) {
const evil = "https://evil.example"
for _, origin := range []string{"https://forge.example", ""} {
accts := inviteAccounts{&resetAccounts{fakeAccounts: newFakeAccounts()}}
_ = accts.AddAccount("root", "root-password", true)
mail := &textMail{}
r := ops.NewRegistry()
if err := RegisterAll(r, Deps{Version: "v", Accounts: accts, Mail: mail, Origin: origin}); err != nil {
t.Fatal(err)
}
ctx := ops.WithUser(context.Background(), "root")
// Decoded the way the HTTP codec does, so a "base" the caller
// sends reaches the op exactly as it would over the wire.
desc, _ := r.Lookup("account.invite")
in := desc.NewIn()
if err := json.Unmarshal([]byte(`{"name":"bob","email":"[email protected]","base":"`+evil+`"}`), in); err != nil {
t.Fatal(err)
}
out, err := r.Invoke(ctx, "account.invite", reflect.ValueOf(in).Elem().Interface())
if err != nil {
t.Fatalf("origin=%q: %v", origin, err)
}
wire, _ := json.Marshal(out)
if strings.Contains(string(wire), evil) {
t.Errorf("FAIL (origin=%q): the returned link is on the caller's domain: %s", origin, wire)
}
for _, m := range mail.sent {
if strings.Contains(m.Text, evil) {
t.Errorf("FAIL (origin=%q): the invite email links to the caller's domain", origin)
}
}
if origin != "" && (len(mail.sent) != 1 || !strings.Contains(mail.sent[0].Text, origin+"/invite/"+inviteToken)) {
t.Errorf("FAIL (origin=%q): the invite did not go out on the forge's origin: %+v", origin, mail.sent)
}
if origin == "" && len(mail.sent) != 0 {
t.Errorf("FAIL: an invite was emailed with no origin to build its link on")
}
}
t.Log("ok: invite links are on the forge's origin or not emailed")
}