2026-03-14 20:52:11 +01:00
|
|
|
package api_test
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"bytes"
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"testing"
|
|
|
|
|
"testing/fstest"
|
|
|
|
|
"time"
|
|
|
|
|
|
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
|
|
|
"github.com/owncord/server/api"
|
|
|
|
|
"github.com/owncord/server/auth"
|
|
|
|
|
"github.com/owncord/server/db"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// newAuthTestDB builds an in-memory DB with the full schema needed for auth tests.
|
|
|
|
|
func newAuthTestDB(t *testing.T) *db.DB {
|
|
|
|
|
t.Helper()
|
|
|
|
|
database, err := db.Open(":memory:")
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("db.Open: %v", err)
|
|
|
|
|
}
|
2026-03-17 08:09:52 +01:00
|
|
|
t.Cleanup(func() { _ = database.Close() })
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
migrFS := fstest.MapFS{
|
|
|
|
|
"001_schema.sql": {Data: apiTestSchema},
|
|
|
|
|
}
|
|
|
|
|
if err := db.MigrateFS(database, migrFS); err != nil {
|
|
|
|
|
t.Fatalf("MigrateFS: %v", err)
|
|
|
|
|
}
|
|
|
|
|
return database
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// buildAuthRouter returns a chi router with auth routes mounted on /api/v1/auth.
|
|
|
|
|
func buildAuthRouter(database *db.DB, limiter *auth.RateLimiter) http.Handler {
|
|
|
|
|
r := chi.NewRouter()
|
|
|
|
|
api.MountAuthRoutes(r, database, limiter)
|
|
|
|
|
return r
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// postJSON is a test helper that POSTs JSON to the given router.
|
2026-03-17 04:11:04 +01:00
|
|
|
func postJSON(t *testing.T, router http.Handler, path string, body any) *httptest.ResponseRecorder {
|
2026-03-14 20:52:11 +01:00
|
|
|
t.Helper()
|
|
|
|
|
raw, _ := json.Marshal(body)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rr, req)
|
|
|
|
|
return rr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// postJSONWithToken posts with an Authorization header.
|
2026-03-17 04:11:04 +01:00
|
|
|
func postJSONWithToken(t *testing.T, router http.Handler, path, token string, body any) *httptest.ResponseRecorder {
|
2026-03-14 20:52:11 +01:00
|
|
|
t.Helper()
|
|
|
|
|
raw, _ := json.Marshal(body)
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(raw))
|
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rr, req)
|
|
|
|
|
return rr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// getWithToken performs a GET with an Authorization header.
|
|
|
|
|
func getWithToken(t *testing.T, router http.Handler, path, token string) *httptest.ResponseRecorder {
|
|
|
|
|
t.Helper()
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, path, nil)
|
|
|
|
|
req.Header.Set("Authorization", "Bearer "+token)
|
|
|
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rr, req)
|
|
|
|
|
return rr
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Register tests ───────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestRegister_Success(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
// Create an invite first.
|
|
|
|
|
ownerID, _ := database.CreateUser("owner", "hash", 1)
|
|
|
|
|
code, _ := database.CreateInvite(ownerID, 1, nil)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
"invite_code": code,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusCreated {
|
|
|
|
|
t.Errorf("Register status = %d, want 201; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 04:11:04 +01:00
|
|
|
var resp map[string]any
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
2026-03-14 20:52:11 +01:00
|
|
|
if resp["token"] == nil {
|
|
|
|
|
t.Error("Register response missing token")
|
|
|
|
|
}
|
|
|
|
|
if resp["user"] == nil {
|
|
|
|
|
t.Error("Register response missing user")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_InvalidInvite(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
"invite_code": "bogus",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("Register invalid invite status = %d, want 400", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_WeakPassword(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
ownerID, _ := database.CreateUser("owner2", "hash", 1)
|
|
|
|
|
code, _ := database.CreateInvite(ownerID, 1, nil)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
"password": "short",
|
|
|
|
|
"invite_code": code,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("Register weak password status = %d, want 400", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_InviteUsedUp(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
ownerID, _ := database.CreateUser("owner3", "hash", 1)
|
|
|
|
|
code, _ := database.CreateInvite(ownerID, 1, nil) // max 1 use
|
|
|
|
|
|
|
|
|
|
// First registration should succeed.
|
|
|
|
|
postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "user1",
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
"invite_code": code,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// Second should fail — invite exhausted.
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "user2",
|
|
|
|
|
"password": "securePass2",
|
|
|
|
|
"invite_code": code,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("Register exhausted invite status = %d, want 400", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_MissingFields(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{})
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("Register missing fields status = %d, want 400", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_ErrorNeverRevealUsername(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "someone",
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
"invite_code": "bogus",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
body := rr.Body.String()
|
|
|
|
|
// Must not hint that the username doesn't exist or the invite is invalid specifically
|
|
|
|
|
if contains(body, "username") && contains(body, "taken") {
|
|
|
|
|
t.Error("Register error message reveals username status")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Login tests ──────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestLogin_Success(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("correctPass1")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateUser("loginuser", hash, 4)
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "loginuser",
|
|
|
|
|
"password": "correctPass1",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("Login status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 04:11:04 +01:00
|
|
|
var resp map[string]any
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
2026-03-14 20:52:11 +01:00
|
|
|
if resp["token"] == nil {
|
|
|
|
|
t.Error("Login response missing token")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogin_WrongPassword(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("correctPass1")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateUser("loginuser2", hash, 4)
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "loginuser2",
|
|
|
|
|
"password": "wrongpassword",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Errorf("Login wrong password status = %d, want 401", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogin_UnknownUser(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "nobody",
|
|
|
|
|
"password": "anypass123",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Errorf("Login unknown user status = %d, want 401", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogin_GenericErrorOnBadCredentials(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "nobody",
|
|
|
|
|
"password": "anypass123",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
body := rr.Body.String()
|
|
|
|
|
// The response must never reveal whether the user exists
|
|
|
|
|
if contains(body, "user not found") || contains(body, "does not exist") {
|
|
|
|
|
t.Errorf("Login error reveals user existence: %s", body)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogin_BannedUser(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("correctPass1")
|
|
|
|
|
id, _ := database.CreateUser("banned", hash, 4)
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = database.BanUser(id, "violated rules", nil)
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "banned",
|
|
|
|
|
"password": "correctPass1",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusForbidden {
|
|
|
|
|
t.Errorf("Login banned user status = %d, want 403", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogin_MissingFields(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{})
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("Login missing fields status = %d, want 400", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Logout tests ─────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestLogout_Success(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("correctPass1")
|
|
|
|
|
uid, _ := database.CreateUser("logoutuser", hash, 4)
|
|
|
|
|
token, _ := auth.GenerateToken()
|
|
|
|
|
tokenHash := auth.HashToken(token)
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, tokenHash, "test", "127.0.0.1")
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
rr := postJSONWithToken(t, router, "/api/v1/auth/logout", token, nil)
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusNoContent {
|
|
|
|
|
t.Errorf("Logout status = %d, want 204", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Session should be gone.
|
|
|
|
|
sess, _ := database.GetSessionByTokenHash(tokenHash)
|
|
|
|
|
if sess != nil {
|
|
|
|
|
t.Error("Session still exists after logout")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestLogout_NoAuth(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/logout", nil)
|
|
|
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rr, req)
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Errorf("Logout no auth status = %d, want 401", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Me tests ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestMe_Success(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("correctPass1")
|
|
|
|
|
uid, _ := database.CreateUser("meuser", hash, 4)
|
|
|
|
|
token, _ := auth.GenerateToken()
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateSession(uid, auth.HashToken(token), "test", "127.0.0.1")
|
2026-03-14 20:52:11 +01:00
|
|
|
|
|
|
|
|
rr := getWithToken(t, router, "/api/v1/auth/me", token)
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("Me status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-17 04:11:04 +01:00
|
|
|
var resp map[string]any
|
2026-03-17 08:09:52 +01:00
|
|
|
_ = json.NewDecoder(rr.Body).Decode(&resp)
|
2026-03-14 20:52:11 +01:00
|
|
|
if resp["id"] == nil {
|
|
|
|
|
t.Error("Me response missing id")
|
|
|
|
|
}
|
|
|
|
|
if resp["username"] != "meuser" {
|
|
|
|
|
t.Errorf("Me username = %v, want meuser", resp["username"])
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestMe_NoAuth(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/me", nil)
|
|
|
|
|
req.RemoteAddr = "127.0.0.1:9999"
|
|
|
|
|
rr := httptest.NewRecorder()
|
|
|
|
|
router.ServeHTTP(rr, req)
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Errorf("Me no auth status = %d, want 401", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-15 07:07:59 +01:00
|
|
|
// ─── Fix 2.5: Password trim fix ───────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
// TestLogin_PasswordWithLeadingSpaceIsPreserved verifies that a password with
|
|
|
|
|
// leading whitespace is NOT trimmed, so a user who set " securePass1" can log
|
|
|
|
|
// in with " securePass1" and NOT with "securePass1".
|
|
|
|
|
func TestLogin_PasswordWithLeadingSpaceIsPreserved(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
// Hash the password WITH the leading space — this is what was registered.
|
|
|
|
|
hash, _ := auth.HashPassword(" securePass1")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateUser("spacepassuser", hash, 4)
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
// Login with the exact same password (including space) must succeed.
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "spacepassuser",
|
|
|
|
|
"password": " securePass1",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("Login space-prefixed password status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLogin_PasswordWithLeadingSpaceTrimmedFails verifies that logging in with
|
|
|
|
|
// the trimmed version of a space-prefixed password correctly fails.
|
|
|
|
|
func TestLogin_PasswordWithLeadingSpaceTrimmedFails(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
// Register with password that has a leading space.
|
|
|
|
|
hash, _ := auth.HashPassword(" securePass1")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateUser("spacepassuser2", hash, 4)
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
// Login without the leading space must fail.
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "spacepassuser2",
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusUnauthorized {
|
|
|
|
|
t.Errorf("Login trimmed space password status = %d, want 401; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLogin_PasswordWithTrailingSpaceIsPreserved verifies that a password with
|
|
|
|
|
// trailing whitespace is NOT trimmed.
|
|
|
|
|
func TestLogin_PasswordWithTrailingSpaceIsPreserved(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("securePass1 ")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateUser("trailingspaceuser", hash, 4)
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": "trailingspaceuser",
|
|
|
|
|
"password": "securePass1 ",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("Login trailing-space password status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// TestLogin_UsernameIsStillTrimmed verifies that the username IS still trimmed
|
|
|
|
|
// (only the password trim was removed).
|
|
|
|
|
func TestLogin_UsernameIsStillTrimmed(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
hash, _ := auth.HashPassword("correctPass1")
|
2026-03-17 08:09:52 +01:00
|
|
|
_, _ = database.CreateUser("trimuser", hash, 4)
|
2026-03-15 07:07:59 +01:00
|
|
|
|
|
|
|
|
// Username with surrounding spaces should resolve to "trimuser".
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/login", map[string]string{
|
|
|
|
|
"username": " trimuser ",
|
|
|
|
|
"password": "correctPass1",
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusOK {
|
|
|
|
|
t.Errorf("Login space-padded username status = %d, want 200; body = %s", rr.Code, rr.Body.String())
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-14 20:52:11 +01:00
|
|
|
// ─── Rate limiting integration test ──────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func TestRegister_RateLimit(t *testing.T) {
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
ownerID, _ := database.CreateUser("rl_owner", "hash", 1)
|
|
|
|
|
|
|
|
|
|
// Attempt register 4 times (limit=3) — 4th should be rate-limited.
|
|
|
|
|
var lastCode int
|
2026-03-17 04:11:04 +01:00
|
|
|
for i := range 4 {
|
2026-03-14 20:52:11 +01:00
|
|
|
code, _ := database.CreateInvite(ownerID, 1, nil)
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "rl_user" + string(rune('0'+i)),
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
"invite_code": code,
|
|
|
|
|
})
|
|
|
|
|
lastCode = rr.Code
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if lastCode != http.StatusTooManyRequests {
|
|
|
|
|
t.Errorf("Register rate limit: last attempt status = %d, want 429", lastCode)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
func contains(s, sub string) bool {
|
|
|
|
|
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func containsStr(s, sub string) bool {
|
|
|
|
|
for i := 0; i <= len(s)-len(sub); i++ {
|
|
|
|
|
if s[i:i+len(sub)] == sub {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// expiredInviteDB creates a DB with an already-expired invite.
|
|
|
|
|
func expiredInviteDB(t *testing.T) (*db.DB, string) {
|
|
|
|
|
t.Helper()
|
|
|
|
|
database := newAuthTestDB(t)
|
|
|
|
|
ownerID, _ := database.CreateUser("expowner", "hash", 1)
|
|
|
|
|
past := time.Now().Add(-time.Hour)
|
|
|
|
|
code, _ := database.CreateInvite(ownerID, 0, &past)
|
|
|
|
|
return database, code
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestRegister_ExpiredInvite(t *testing.T) {
|
|
|
|
|
database, code := expiredInviteDB(t)
|
|
|
|
|
limiter := auth.NewRateLimiter()
|
|
|
|
|
router := buildAuthRouter(database, limiter)
|
|
|
|
|
|
|
|
|
|
rr := postJSON(t, router, "/api/v1/auth/register", map[string]string{
|
|
|
|
|
"username": "newuser",
|
|
|
|
|
"password": "securePass1",
|
|
|
|
|
"invite_code": code,
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
if rr.Code != http.StatusBadRequest {
|
|
|
|
|
t.Errorf("Register expired invite status = %d, want 400", rr.Code)
|
|
|
|
|
}
|
|
|
|
|
}
|