The Stage-1 onboarding path: an invited user opens their emailed link, sets a password, and Tapir creates their Dex local-password account so they can log in. Mounted on root OUTSIDE Auth.Middleware — the visitor has no Dex session yet; the token in the path is the capability. handleInviteForm previews the token (no consume) and shows the form, or a clear "expired / already used" page. handleInviteSubmit validates the password BEFORE consuming the token (a typo is retryable), then claims the invite exactly once, bcrypt-hashes (cost 12), and creates the Dex account — mapping ErrPasswordExists -> "log in instead" and ErrForbidden -> "contact the administrator". Off-cluster (App.Dex nil) it degrades to a "deployed-only" message without burning the token. On success it sets an account_created flash and redirects to /auth/login. Welcome sub-text now states access is invite-only. Handlers depend on narrow ports (InvitationStore, DexPasswordCreator) so tests use fakes; cmdServe wires the store + an in-cluster dex.PasswordClient. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
186 lines
6.1 KiB
Go
186 lines
6.1 KiB
Go
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")
|
|
}
|