package web_test import ( "context" "net/http" "net/http/httptest" "net/url" "strings" "testing" "time" "github.com/jackc/pgx/v5/pgxpool" "github.com/stretchr/testify/require" "golang.org/x/crypto/bcrypt" "gitea.d-ma.be/mathias/tapir/internal/adapters/dex" "gitea.d-ma.be/mathias/tapir/internal/adapters/store" "gitea.d-ma.be/mathias/tapir/internal/web" ) // fakeDex captures the CreatePassword call and returns a canned error. type fakeDex struct { called bool email, hash, userID string err error } func (f *fakeDex) CreatePassword(_ context.Context, email, hash, userID string) error { f.called = true f.email, f.hash, f.userID = email, hash, userID return f.err } func resetInvites(t *testing.T, p *pgxpool.Pool) { t.Helper() _, err := p.Exec(context.Background(), `TRUNCATE invitations`) require.NoError(t, err) } // inviteMux mounts only the two public invite routes against app, so PathValue // ("token") is populated exactly as in production without the full Router/auth. func inviteMux(app *web.App) http.Handler { mux := http.NewServeMux() mux.HandleFunc("GET /invite/{token}", app.HandleInviteFormForTest) mux.HandleFunc("POST /invite/{token}", app.HandleInviteSubmitForTest) return mux } func newInvite(t *testing.T, st *store.Store, email string, ttl time.Duration) string { t.Helper() token, err := st.CreateInvitation(context.Background(), email, ttl) require.NoError(t, err) return token } func TestInviteFormValidToken(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "invitee@example.com", time.Hour) app := &web.App{Invitations: st, Dex: &fakeDex{}} rr := httptest.NewRecorder() inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/"+token, nil)) require.Equal(t, http.StatusOK, rr.Code) body := rr.Body.String() require.Contains(t, body, "invitee@example.com") require.Contains(t, body, "Create my account") } func TestInviteFormInvalidToken(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) app := &web.App{Invitations: st, Dex: &fakeDex{}} rr := httptest.NewRecorder() inviteMux(app).ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/invite/nope", nil)) require.Equal(t, http.StatusOK, rr.Code) require.Contains(t, rr.Body.String(), "no longer valid") } func postInvite(app *web.App, token string, form url.Values) *httptest.ResponseRecorder { rr := httptest.NewRecorder() req := httptest.NewRequest(http.MethodPost, "/invite/"+token, strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") inviteMux(app).ServeHTTP(rr, req) return rr } func TestInviteSubmitPasswordMismatch(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "a@example.com", time.Hour) fd := &fakeDex{} app := &web.App{Invitations: st, Dex: fd} rr := postInvite(app, token, url.Values{"password": {"longenough1"}, "password_confirm": {"different1"}}) require.Equal(t, http.StatusBadRequest, rr.Code) require.Contains(t, rr.Body.String(), "do not match") require.False(t, fd.called) // Token not consumed — still claimable. _, err := st.PeekInvitation(context.Background(), token) require.NoError(t, err) } func TestInviteSubmitShortPassword(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "a@example.com", time.Hour) fd := &fakeDex{} app := &web.App{Invitations: st, Dex: fd} rr := postInvite(app, token, url.Values{"password": {"short"}, "password_confirm": {"short"}}) require.Equal(t, http.StatusBadRequest, rr.Code) require.Contains(t, rr.Body.String(), "at least 8") require.False(t, fd.called) } func TestInviteSubmitValidCreatesAccount(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "new@example.com", time.Hour) fd := &fakeDex{} app := &web.App{Invitations: st, Dex: fd} rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) require.Equal(t, http.StatusSeeOther, rr.Code) require.Equal(t, "/auth/login", rr.Header().Get("Location")) require.True(t, fd.called) require.Equal(t, "new@example.com", fd.email) require.NotEmpty(t, fd.userID) // The handler hands Dex a real bcrypt hash of the chosen password. require.NoError(t, bcrypt.CompareHashAndPassword([]byte(fd.hash), []byte("correcthorse"))) // Flash queued for the post-login page. require.Contains(t, rr.Header().Get("Set-Cookie"), "tapir_flash=account_created") // Token consumed — a second claim fails. _, err := st.ClaimInvitation(context.Background(), token) require.ErrorIs(t, err, store.ErrNotFound) } func TestInviteSubmitDevModeNoDex(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "dev@example.com", time.Hour) app := &web.App{Invitations: st, Dex: nil} // not in-cluster rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) require.Equal(t, http.StatusOK, rr.Code) require.Contains(t, rr.Body.String(), "deployed environment") // Token preserved so it still works once deployed. _, err := st.PeekInvitation(context.Background(), token) require.NoError(t, err) } func TestInviteSubmitPasswordExists(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "dup@example.com", time.Hour) app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrPasswordExists}} rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) require.Equal(t, http.StatusOK, rr.Code) require.Contains(t, rr.Body.String(), "already exists") } func TestInviteSubmitForbidden(t *testing.T) { st, p := newStore(t), rawPool(t) resetInvites(t, p) token := newInvite(t, st, "x@example.com", time.Hour) app := &web.App{Invitations: st, Dex: &fakeDex{err: dex.ErrForbidden}} rr := postInvite(app, token, url.Values{"password": {"correcthorse"}, "password_confirm": {"correcthorse"}}) require.Equal(t, http.StatusOK, rr.Code) require.Contains(t, rr.Body.String(), "administrator") }