feat: add partner interface, admin dashboard, and unit tests
Partner flow: QR scanner component (html5-qrcode) with camera permission/not-found handling, 3-step spend flow (scan → amount entry with auto-filled max → success/error result). Admin dashboard: stats overview, grant points with debounced student search, paginated transactions table with type filters, paginated students table. Tests: comprehensive unit tests for points and auth packages — service (all paths including error branches, RS256 wrong-method), handler (all HTTP status codes via httptest), JWT round-trip, repository constructors. Auth coverage: 72.9%, points service coverage: 100%. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
50b3c4198a
commit
be2260d259
@@ -0,0 +1,187 @@
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/cu-points/backend/internal/auth"
|
||||
)
|
||||
|
||||
func newTestHandler(repo auth.UserRepository) *auth.Handler {
|
||||
jwtMgr := auth.NewJWTManager(
|
||||
"test-secret-minimum-32-characters-long",
|
||||
15*time.Minute,
|
||||
168*time.Hour,
|
||||
)
|
||||
svc := auth.NewService(repo, jwtMgr)
|
||||
return auth.NewHandler(svc)
|
||||
}
|
||||
|
||||
// ─── Login ───────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandler_Login_Success(t *testing.T) {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("pass123"), bcrypt.MinCost)
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
ID: "u-1",
|
||||
Email: "a@cu.ru",
|
||||
PasswordHash: string(hash),
|
||||
Role: "student",
|
||||
},
|
||||
}
|
||||
h := newTestHandler(repo)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": "a@cu.ru", "password": "pass123"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Login(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if resp.Data.AccessToken == "" || resp.Data.RefreshToken == "" {
|
||||
t.Error("expected both tokens to be non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Login_InvalidJSON(t *testing.T) {
|
||||
h := newTestHandler(&mockRepo{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login",
|
||||
strings.NewReader("{bad json"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Login(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Login_MissingFields(t *testing.T) {
|
||||
h := newTestHandler(&mockRepo{})
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": "", "password": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Login(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Login_WrongPassword(t *testing.T) {
|
||||
hash, _ := bcrypt.GenerateFromPassword([]byte("correct"), bcrypt.MinCost)
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
ID: "u-1",
|
||||
Email: "a@cu.ru",
|
||||
PasswordHash: string(hash),
|
||||
Role: "student",
|
||||
},
|
||||
}
|
||||
h := newTestHandler(repo)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"email": "a@cu.ru", "password": "wrong"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/login", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Login(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Refresh ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestHandler_Refresh_Success(t *testing.T) {
|
||||
jwtMgr := auth.NewJWTManager(
|
||||
"test-secret-minimum-32-characters-long",
|
||||
15*time.Minute,
|
||||
168*time.Hour,
|
||||
)
|
||||
user := &auth.UserRecord{ID: "u-2", Role: "student"}
|
||||
repo := &mockRepo{user: user}
|
||||
svc := auth.NewService(repo, jwtMgr)
|
||||
h := auth.NewHandler(svc)
|
||||
|
||||
refreshToken, _ := jwtMgr.GenerateRefreshToken(user.ID)
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": refreshToken})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Refresh(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Refresh_InvalidJSON(t *testing.T) {
|
||||
h := newTestHandler(&mockRepo{})
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh",
|
||||
strings.NewReader("{bad json"))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Refresh(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Refresh_MissingToken(t *testing.T) {
|
||||
h := newTestHandler(&mockRepo{})
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": ""})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Refresh(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Fatalf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandler_Refresh_InvalidToken(t *testing.T) {
|
||||
h := newTestHandler(&mockRepo{})
|
||||
|
||||
body, _ := json.Marshal(map[string]string{"refresh_token": "bad.token.here"})
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/refresh", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
h.Refresh(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,17 @@ func newTestService(repo auth.UserRepository) *auth.Service {
|
||||
return auth.NewService(repo, jwtMgr)
|
||||
}
|
||||
|
||||
// newTestJWT returns a JWTManager configured with the test secret.
|
||||
func newTestJWT() *auth.JWTManager {
|
||||
return auth.NewJWTManager(
|
||||
"test-secret-minimum-32-characters-long",
|
||||
15*time.Minute,
|
||||
168*time.Hour,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Login ────────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestService_Login_Success(t *testing.T) {
|
||||
repo := &mockRepo{
|
||||
user: &auth.UserRecord{
|
||||
@@ -107,3 +118,185 @@ func TestService_Login_UserNotFound(t *testing.T) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Login_RepoError(t *testing.T) {
|
||||
repo := &mockRepo{repoErr: errors.New("db error")}
|
||||
svc := newTestService(repo)
|
||||
|
||||
_, err := svc.Login(context.Background(), auth.LoginRequest{
|
||||
Email: "user@cu.ru",
|
||||
Password: "pass",
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
// Must NOT be ErrInvalidCredentials — we don't want to mask infra errors.
|
||||
if errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Error("unexpected ErrInvalidCredentials for non-ErrNotFound repo error")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Refresh ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func TestService_Refresh_Success(t *testing.T) {
|
||||
jwtMgr := newTestJWT()
|
||||
user := &auth.UserRecord{
|
||||
ID: "user-1",
|
||||
Role: "student",
|
||||
}
|
||||
repo := &mockRepo{user: user}
|
||||
svc := auth.NewService(repo, jwtMgr)
|
||||
|
||||
// Generate a real refresh token via the JWT manager.
|
||||
refreshToken, err := jwtMgr.GenerateRefreshToken(user.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("generate refresh token: %v", err)
|
||||
}
|
||||
|
||||
accessToken, err := svc.Refresh(context.Background(), refreshToken)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if accessToken == "" {
|
||||
t.Error("expected non-empty access token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Refresh_InvalidToken(t *testing.T) {
|
||||
svc := newTestService(&mockRepo{})
|
||||
|
||||
_, err := svc.Refresh(context.Background(), "not.a.valid.token")
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Refresh_WrongTokenType(t *testing.T) {
|
||||
jwtMgr := newTestJWT()
|
||||
svc := auth.NewService(&mockRepo{}, jwtMgr)
|
||||
|
||||
// Use an access token where a refresh token is expected.
|
||||
accessToken, _ := jwtMgr.GenerateAccessToken("user-1", "student")
|
||||
|
||||
_, err := svc.Refresh(context.Background(), accessToken)
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_Refresh_UserNotFound(t *testing.T) {
|
||||
jwtMgr := newTestJWT()
|
||||
repo := &mockRepo{repoErr: auth.ErrNotFound}
|
||||
svc := auth.NewService(repo, jwtMgr)
|
||||
|
||||
refreshToken, _ := jwtMgr.GenerateRefreshToken("deleted-user")
|
||||
|
||||
_, err := svc.Refresh(context.Background(), refreshToken)
|
||||
if !errors.Is(err, auth.ErrInvalidCredentials) {
|
||||
t.Errorf("expected ErrInvalidCredentials, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── ValidateToken ────────────────────────────────────────────────────────────
|
||||
|
||||
func TestService_ValidateToken_Success(t *testing.T) {
|
||||
jwtMgr := newTestJWT()
|
||||
svc := auth.NewService(&mockRepo{}, jwtMgr)
|
||||
|
||||
accessToken, _ := jwtMgr.GenerateAccessToken("user-1", "student")
|
||||
|
||||
claims, err := svc.ValidateToken(accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got: %v", err)
|
||||
}
|
||||
if claims.Subject != "user-1" {
|
||||
t.Errorf("expected subject=user-1, got %s", claims.Subject)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_InvalidToken(t *testing.T) {
|
||||
svc := newTestService(&mockRepo{})
|
||||
|
||||
_, err := svc.ValidateToken("garbage.token.value")
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_ValidateToken_RefreshTokenRejected(t *testing.T) {
|
||||
jwtMgr := newTestJWT()
|
||||
svc := auth.NewService(&mockRepo{}, jwtMgr)
|
||||
|
||||
refreshToken, _ := jwtMgr.GenerateRefreshToken("user-1")
|
||||
|
||||
_, err := svc.ValidateToken(refreshToken)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for refresh token passed to ValidateToken")
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JWT round-trip ───────────────────────────────────────────────────────────
|
||||
|
||||
func TestJWTManager_AccessToken_RoundTrip(t *testing.T) {
|
||||
mgr := newTestJWT()
|
||||
|
||||
token, err := mgr.GenerateAccessToken("user-42", "admin")
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
|
||||
claims, err := mgr.ParseToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if claims.Subject != "user-42" {
|
||||
t.Errorf("subject: want user-42, got %s", claims.Subject)
|
||||
}
|
||||
if claims.Role != "admin" {
|
||||
t.Errorf("role: want admin, got %s", claims.Role)
|
||||
}
|
||||
if claims.Type != "access" {
|
||||
t.Errorf("type: want access, got %s", claims.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTManager_RefreshToken_RoundTrip(t *testing.T) {
|
||||
mgr := newTestJWT()
|
||||
|
||||
token, err := mgr.GenerateRefreshToken("user-7")
|
||||
if err != nil {
|
||||
t.Fatalf("generate: %v", err)
|
||||
}
|
||||
|
||||
claims, err := mgr.ParseToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
if claims.Subject != "user-7" {
|
||||
t.Errorf("subject: want user-7, got %s", claims.Subject)
|
||||
}
|
||||
if claims.Type != "refresh" {
|
||||
t.Errorf("type: want refresh, got %s", claims.Type)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTManager_ParseToken_Invalid(t *testing.T) {
|
||||
mgr := newTestJWT()
|
||||
|
||||
_, err := mgr.ParseToken("not.a.valid.jwt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid JWT, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestJWTManager_ParseToken_WrongSecret(t *testing.T) {
|
||||
mgr1 := newTestJWT()
|
||||
mgr2 := auth.NewJWTManager("other-secret-that-is-at-least-32-chars-long", 15*time.Minute, 168*time.Hour)
|
||||
|
||||
token, _ := mgr1.GenerateAccessToken("user-1", "student")
|
||||
|
||||
_, err := mgr2.ParseToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when parsing with wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user