Files
llmgw/internal/web/reset_test.go
T
Ilker ManapandClaude Opus 5.5 66f702d5c1 A forgotten password is set through a one-time link
/forgot mails a link to set a new password when the cluster's mail settings
say how - SMTP relay, sender, and the public base_url the link points at,
never the request's Host - and says the same whether or not the address has
an account. /reset sets it once, closing every session. Links are kept by
their hash in password_resets, good for an hour, a newer one replacing an
older. Without mail, /forgot says to ask an operator, and llmgw users
reset-link makes the link to hand over. Single sign-on accounts get none.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-07 13:20:11 +02:00

133 lines
5.1 KiB
Go

package web
import (
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"regexp"
"strings"
"testing"
"time"
"gitea.mynodes.xyz/ilker/llmgw/internal/account"
"gitea.mynodes.xyz/ilker/llmgw/internal/store"
)
type sentMail struct{ to, subject, body string }
type fakeMailer struct{ sent []sentMail }
func (*fakeMailer) Enabled() bool { return true }
func (m *fakeMailer) Send(_ context.Context, to, subject, body string) error {
m.sent = append(m.sent, sentMail{to, subject, body})
return nil
}
func TestAForgottenPasswordIsSetThroughAMailedLink(t *testing.T) {
ctx := context.Background()
st, err := store.OpenSQLite(ctx, filepath.Join(t.TempDir(), "w.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
hash, _ := account.HashPassword("an old long password")
if err := st.CreateUser(ctx, store.User{ID: "usr-a", Email: "ayse@example.com", Name: "Ayşe",
PasswordHash: hash, State: store.UserActive, CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
svc := account.New(st, account.DefaultConfig(), slog.New(slog.DiscardHandler))
p := NewAccountPages(svc, st, st, "v0", slog.New(slog.DiscardHandler))
mailer := &fakeMailer{}
p.SetPasswordReset(st, mailer, "https://llm.example.com/")
mux := http.NewServeMux()
p.Register(mux)
formToken := func(path string) *http.Cookie {
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
for _, c := range rec.Result().Cookies() {
if c.Name == loginCSRFCookie {
return c
}
}
t.Fatalf("%s carries no form token", path)
return nil
}
post := func(path string, form url.Values, c *http.Cookie, host string) string {
form.Set("csrf", c.Value)
req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode()))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Host = host
req.AddCookie(c)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, req)
return rec.Body.String()
}
// Asked from a Host somebody chose: the link still points at the
// gateway's own address.
c := formToken("/forgot")
sentPage := post("/forgot", url.Values{"email": {"ayse@example.com"}}, c, "evil.example")
if len(mailer.sent) != 1 || !strings.Contains(mailer.sent[0].body, "https://llm.example.com/reset?token=") ||
strings.Contains(mailer.sent[0].body, "evil.example") || mailer.sent[0].to != "ayse@example.com" {
t.Fatalf("mail: %+v", mailer.sent)
}
// An address with no account gets the same page, and no mail.
nobody := post("/forgot", url.Values{"email": {"nobody@example.com"}}, formToken("/forgot"), "llm.example.com")
if len(mailer.sent) != 1 || !strings.Contains(nobody, "on its way") || !strings.Contains(sentPage, "on its way") {
t.Errorf("an unknown address was told apart, or mailed: %d sent", len(mailer.sent))
}
token := regexp.MustCompile(`token=([^\s]+)`).FindStringSubmatch(mailer.sent[0].body)[1]
token, _ = url.QueryUnescape(token)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/reset?token="+url.QueryEscape(token), nil))
if rec.Header().Get("Referrer-Policy") != "no-referrer" || !strings.Contains(rec.Body.String(), `name="confirm"`) {
t.Fatalf("the reset form: %s", rec.Body.String())
}
mismatch := post("/reset", url.Values{"token": {token}, "password": {"a brand new password"},
"confirm": {"not the same"}}, formToken("/reset?token="+url.QueryEscape(token)), "llm.example.com")
if !strings.Contains(mismatch, "not the same") {
t.Errorf("two different passwords: %s", mismatch)
}
done := post("/reset", url.Values{"token": {token}, "password": {"a brand new password"},
"confirm": {"a brand new password"}}, formToken("/reset?token="+url.QueryEscape(token)), "llm.example.com")
if !strings.Contains(done, "Your password is set") {
t.Fatalf("setting it: %s", done)
}
if _, _, err := svc.SignIn(ctx, "ayse@example.com", "a brand new password"); err != nil {
t.Errorf("the new password does not sign in: %v", err)
}
again := post("/reset", url.Values{"token": {token}, "password": {"yet another password"},
"confirm": {"yet another password"}}, formToken("/reset?token="+url.QueryEscape(token)), "llm.example.com")
if !strings.Contains(again, "does not work") {
t.Errorf("a link used twice: %s", again)
}
}
func TestWithoutMailForgottenSaysAskAnOperator(t *testing.T) {
st, err := store.OpenSQLite(context.Background(), filepath.Join(t.TempDir(), "w.db"))
if err != nil {
t.Fatal(err)
}
defer st.Close()
svc := account.New(st, account.DefaultConfig(), slog.New(slog.DiscardHandler))
p := NewAccountPages(svc, st, st, "v0", slog.New(slog.DiscardHandler))
p.SetPasswordReset(st, nil, "")
mux := http.NewServeMux()
p.Register(mux)
rec := httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/forgot", nil))
if !strings.Contains(rec.Body.String(), "llmgw users reset-link") {
t.Errorf("%s", rec.Body.String())
}
rec = httptest.NewRecorder()
mux.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/login", nil))
if !strings.Contains(rec.Body.String(), `href="/forgot"`) {
t.Error("sign-in does not link to /forgot")
}
}