package installapi import ( "bytes" "encoding/json" "errors" "fmt" "net/http" "net/http/httptest" "regexp" "strings" "sync" "testing" "github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/mailer" ) type fakeAccounts struct { mu sync.Mutex users map[string]string // email -> hash } func (f *fakeAccounts) EmailExists(e string) (bool, error) { _, ok := f.users[e]; return ok, nil } func (f *fakeAccounts) HashPassword(p string) (string, error) { return "h:" + strings.Repeat("*", len(p)), nil } func (f *fakeAccounts) CreateAccount(n, e, h string) (string, error) { f.mu.Lock() defer f.mu.Unlock() f.users[e] = h return "u-" + e, nil } func (f *fakeAccounts) Authenticate(e, p, totp string) (string, error) { if e == "twofa@x.com" && totp == "" { return "", ErrTOTPRequired } if _, ok := f.users[e]; ok && p == "CorrectHorse9" { return "u-" + e, nil } return "", ErrInvalidCredentials } func (f *fakeAccounts) MintToken(uid, n string, c map[string]string) (string, error) { if uid == "" { return "", errors.New("x") } return "xVerae-TEST" + strings.Repeat("a", 20), nil } type capture struct { mu sync.Mutex msgs []mailer.Message } func (c *capture) Send(m mailer.Message) error { c.mu.Lock() c.msgs = append(c.msgs, m) c.mu.Unlock() return nil } func call(t *testing.T, h http.Handler, path string, body any) (int, map[string]any) { b, _ := json.Marshal(body) r := httptest.NewRequest("POST", path, bytes.NewReader(b)) w := httptest.NewRecorder() h.ServeHTTP(w, r) var out map[string]any _ = json.Unmarshal(w.Body.Bytes(), &out) return w.Code, out } var codeRe = regexp.MustCompile(`(\d{3}) (\d{3})`) func TestRegisterVerifyLogin(t *testing.T) { acc := &fakeAccounts{users: map[string]string{"old@x.com": "h"}} mc := &capture{} var logs bytes.Buffer s := New(acc, mc) s.Log = func(f string, a ...any) { logs.WriteString(strings.TrimSpace(sprintf(f, a...)) + "\n") } h := s.Handler() if st, out := call(t, h, "/install/register", map[string]any{"name": "G", "email": "new@x.com", "password": "short"}); st != 400 || out["error"] != "weak_password" { t.Fatalf("weak: %d %v", st, out) } st, out := call(t, h, "/install/register", map[string]any{"name": "George", "email": "new@x.com", "password": "CorrectHorse9"}) if st != 202 { t.Fatalf("register %d %v", st, out) } pid := out["pendingId"].(string) m := codeRe.FindStringSubmatch(mc.msgs[0].Text) code := m[1] + m[2] if st, out := call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": "000000"}); st != 400 || out["attemptsLeft"].(float64) != 4 { if code != "000000" { t.Fatalf("bad code: %d %v", st, out) } } st, out = call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": code, "tokenName": "PC"}) if st != 201 || !strings.HasPrefix(out["token"].(string), "xVerae-") { t.Fatalf("verify %d %v", st, out) } if st, _ := call(t, h, "/install/verify", map[string]any{"pendingId": pid, "code": code}); st != 410 { t.Fatal("code must be single use") } // existing email: 202 + "already have an account" email, verify never succeeds st, out = call(t, h, "/install/register", map[string]any{"name": "X", "email": "old@x.com", "password": "CorrectHorse9"}) if st != 202 || !strings.Contains(mc.msgs[1].Subject, "already have") { t.Fatalf("exists: %d %v", st, out) } // login if st, out := call(t, h, "/install/login", map[string]any{"email": "new@x.com", "password": "CorrectHorse9"}); st != 201 || out["token"] == nil { t.Fatalf("login %d %v", st, out) } if st, out := call(t, h, "/install/login", map[string]any{"email": "twofa@x.com", "password": "x"}); st != 401 || out["error"] != "totp_required" { t.Fatalf("totp %d %v", st, out) } // resend cooldown → 429 st, out = call(t, h, "/install/register", map[string]any{"name": "R", "email": "r@x.com", "password": "CorrectHorse9"}) if st, out := call(t, h, "/install/resend", map[string]any{"pendingId": out["pendingId"]}); st != 429 || out["retryAfterSeconds"] == nil { t.Fatalf("resend %d %v", st, out) } if strings.Contains(logs.String(), "CorrectHorse9") || strings.Contains(logs.String(), "new@x.com") || strings.Contains(logs.String(), code) { t.Fatalf("secret/PII in logs:\n%s", logs.String()) } } func sprintf(f string, a ...any) string { return fmt.Sprintf(f, a...) }