mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 18:08:45 +01:00
added some tests. mostly written by claude code
This commit is contained in:
296
api/handlers/auth_test.go
Normal file
296
api/handlers/auth_test.go
Normal file
@@ -0,0 +1,296 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
const testAuthSecret = "test-auth-handler-secret"
|
||||
const testClientSecret = "test-client-secret"
|
||||
|
||||
// setupAuthHandlerTestDB creates a temp DB with migrated tables and seeded products.
|
||||
// Returns the DB filename, a JWTManager, and an AuthHandler.
|
||||
func setupAuthHandlerTestDB(t *testing.T) (string, *auth.JWTManager, *AuthHandler) {
|
||||
t.Helper()
|
||||
testDB := "test_auth.db"
|
||||
os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
if err := database.Seed(db); err != nil {
|
||||
t.Fatalf("failed to seed: %v", err)
|
||||
}
|
||||
|
||||
mgr := auth.NewJWTManager(testAuthSecret, 30*time.Minute)
|
||||
handler := NewAuthHandler(mgr, testClientSecret)
|
||||
|
||||
return testDB, mgr, handler
|
||||
}
|
||||
|
||||
// createAuthTestUser inserts a user into the DB and returns it.
|
||||
func createAuthTestUser(t *testing.T, fingerprint string) models.User {
|
||||
t.Helper()
|
||||
db := database.GetDB()
|
||||
user := models.User{
|
||||
SSHKeyFingerprint: fingerprint,
|
||||
SSHPublicKey: "ssh-ed25519 AAAA " + fingerprint,
|
||||
Name: "Test User",
|
||||
Email: "test@example.com",
|
||||
}
|
||||
if err := db.Create(&user).Error; err != nil {
|
||||
t.Fatalf("failed to create test user: %v", err)
|
||||
}
|
||||
return user
|
||||
}
|
||||
|
||||
// --- GetToken tests ---
|
||||
|
||||
func TestGetTokenSuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
user := createAuthTestUser(t, "SHA256:testfingerprint123")
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"fingerprint": user.SSHKeyFingerprint,
|
||||
"client_secret": testClientSecret,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetToken(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if !resp.Success {
|
||||
t.Error("expected success to be true")
|
||||
}
|
||||
if resp.Data.AccessToken == "" {
|
||||
t.Error("expected non-empty access_token")
|
||||
}
|
||||
if resp.Data.TokenType != "Bearer" {
|
||||
t.Errorf("expected token_type Bearer, got %s", resp.Data.TokenType)
|
||||
}
|
||||
if resp.Data.ExpiresIn != 1800 {
|
||||
t.Errorf("expected expires_in 1800, got %d", resp.Data.ExpiresIn)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenBadSecret(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"fingerprint": "SHA256:anything",
|
||||
"client_secret": "wrong-secret",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetToken(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenMissingFingerprint(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"client_secret": testClientSecret,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetToken(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenUnknownFingerprint(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"fingerprint": "SHA256:doesnotexist",
|
||||
"client_secret": testClientSecret,
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetToken(w, req)
|
||||
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTokenInvalidJSON(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/token", bytes.NewBufferString("{bad json"))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetToken(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- RegisterWithSSHKey tests ---
|
||||
|
||||
func TestRegisterWithSSHKeySuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"ssh_public_key": "ssh-ed25519 AAAA newkey",
|
||||
"ssh_key_fingerprint": "SHA256:brandnewfingerprint",
|
||||
"name": "New User",
|
||||
"email": "new@example.com",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RegisterWithSSHKey(w, req)
|
||||
|
||||
if w.Code != http.StatusCreated {
|
||||
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
createAuthTestUser(t, "SHA256:existingfp")
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"ssh_public_key": "ssh-ed25519 AAAA existingkey",
|
||||
"ssh_key_fingerprint": "SHA256:existingfp",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RegisterWithSSHKey(w, req)
|
||||
|
||||
if w.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWithSSHKeyMissingFields(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
body, _ := json.Marshal(map[string]string{
|
||||
"name": "No Key User",
|
||||
})
|
||||
req := httptest.NewRequest("POST", "/api/v1/auth/register", bytes.NewBuffer(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.RegisterWithSSHKey(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// --- GetUserBySSHKey tests ---
|
||||
|
||||
func TestGetUserBySSHKeySuccess(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
createAuthTestUser(t, "SHA256:lookupfp")
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:lookupfp", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetUserBySSHKey(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserBySSHKeyNotFound(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh?fingerprint=SHA256:nope", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetUserBySSHKey(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("expected 404, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserBySSHKeyMissingParam(t *testing.T) {
|
||||
testDB, _, handler := setupAuthHandlerTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
req := httptest.NewRequest("GET", "/api/v1/auth/ssh", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.GetUserBySSHKey(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
174
api/middleware/auth_test.go
Normal file
174
api/middleware/auth_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"terminalShop/pkg/auth"
|
||||
)
|
||||
|
||||
const testSecret = "middleware-test-secret"
|
||||
|
||||
func newTestJWTManager() *auth.JWTManager {
|
||||
return auth.NewJWTManager(testSecret, 30*time.Minute)
|
||||
}
|
||||
|
||||
// captureHandler is a dummy handler that records whether it was called
|
||||
// and what userID was in the context.
|
||||
type captureHandler struct {
|
||||
called bool
|
||||
userID uint
|
||||
}
|
||||
|
||||
func (h *captureHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
h.called = true
|
||||
h.userID = UserIDFromContext(r.Context())
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func TestAuthNoHeader(t *testing.T) {
|
||||
mgr := newTestJWTManager()
|
||||
capture := &captureHandler{}
|
||||
|
||||
handler := Auth(mgr)(capture)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if !capture.called {
|
||||
t.Fatal("expected handler to be called")
|
||||
}
|
||||
if capture.userID != 0 {
|
||||
t.Errorf("expected userID 0 (no auth), got %d", capture.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMalformedHeader(t *testing.T) {
|
||||
mgr := newTestJWTManager()
|
||||
capture := &captureHandler{}
|
||||
|
||||
handler := Auth(mgr)(capture)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Authorization", "NotBearer some-token")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if !capture.called {
|
||||
t.Fatal("expected handler to be called")
|
||||
}
|
||||
if capture.userID != 0 {
|
||||
t.Errorf("expected userID 0 for malformed header, got %d", capture.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthInvalidToken(t *testing.T) {
|
||||
mgr := newTestJWTManager()
|
||||
capture := &captureHandler{}
|
||||
|
||||
handler := Auth(mgr)(capture)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer totally-invalid-token")
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if !capture.called {
|
||||
t.Fatal("expected handler to be called (soft auth)")
|
||||
}
|
||||
if capture.userID != 0 {
|
||||
t.Errorf("expected userID 0 for invalid token, got %d", capture.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthValidToken(t *testing.T) {
|
||||
mgr := newTestJWTManager()
|
||||
capture := &captureHandler{}
|
||||
|
||||
handler := Auth(mgr)(capture)
|
||||
|
||||
token, err := mgr.GenerateToken(99, "test@example.com", "testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate token: %v", err)
|
||||
}
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if !capture.called {
|
||||
t.Fatal("expected handler to be called")
|
||||
}
|
||||
if capture.userID != 99 {
|
||||
t.Errorf("expected userID 99, got %d", capture.userID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthWithUser(t *testing.T) {
|
||||
capture := &captureHandler{}
|
||||
|
||||
handler := RequireAuth(capture)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
ctx := ContextWithUserID(req.Context(), 42)
|
||||
req = req.WithContext(ctx)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if !capture.called {
|
||||
t.Fatal("expected handler to be called when user is present")
|
||||
}
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequireAuthWithoutUser(t *testing.T) {
|
||||
capture := &captureHandler{}
|
||||
|
||||
handler := RequireAuth(capture)
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if capture.called {
|
||||
t.Fatal("expected handler to NOT be called when no user")
|
||||
}
|
||||
if w.Code != http.StatusUnauthorized {
|
||||
t.Errorf("expected 401, got %d", w.Code)
|
||||
}
|
||||
|
||||
var resp struct {
|
||||
Success bool `json:"success"`
|
||||
Error struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"error"`
|
||||
}
|
||||
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
if resp.Error.Code != "UNAUTHORIZED" {
|
||||
t.Errorf("expected error code UNAUTHORIZED, got %s", resp.Error.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDFromContextEmpty(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
if id := UserIDFromContext(ctx); id != 0 {
|
||||
t.Errorf("expected 0 from empty context, got %d", id)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUserIDFromContextSet(t *testing.T) {
|
||||
ctx := ContextWithUserID(context.Background(), 42)
|
||||
if id := UserIDFromContext(ctx); id != 42 {
|
||||
t.Errorf("expected 42, got %d", id)
|
||||
}
|
||||
}
|
||||
10
main.go
10
main.go
@@ -28,8 +28,9 @@ const (
|
||||
)
|
||||
|
||||
var (
|
||||
authService *auth.SSHAuthService
|
||||
apiURL string
|
||||
authService *auth.SSHAuthService
|
||||
apiURL string
|
||||
authFingerprintKey string
|
||||
)
|
||||
|
||||
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
@@ -57,7 +58,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
|
||||
}
|
||||
|
||||
// Create TUI model with user context (no registration screen needed!)
|
||||
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token, apiURL)
|
||||
m := tui.NewModelWithAuth(user, false, s.PublicKey(), token, apiURL, authFingerprintKey)
|
||||
return m, []tea.ProgramOption{tea.WithAltScreen()}
|
||||
}
|
||||
|
||||
@@ -87,8 +88,9 @@ func main() {
|
||||
// Initialize SSH auth service
|
||||
authService = auth.NewSSHAuthService(db, jwtManager)
|
||||
|
||||
// Set API URL from config for TUI connections
|
||||
// Set API URL and auth key from config for TUI connections
|
||||
apiURL = cfg.APIURL
|
||||
authFingerprintKey = cfg.AuthFingerprintKey
|
||||
log.Printf("TUI will connect to API at: %s", apiURL)
|
||||
|
||||
// Create SSH server
|
||||
|
||||
@@ -36,6 +36,66 @@ func (c *Client) doRequest(req *http.Request) (*http.Response, error) {
|
||||
return c.HTTPClient.Do(req)
|
||||
}
|
||||
|
||||
// TokenRequest represents a request to exchange SSH fingerprint for JWT.
|
||||
type TokenRequest struct {
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
ClientSecret string `json:"client_secret"`
|
||||
}
|
||||
|
||||
// TokenResponse represents the JWT token response.
|
||||
type TokenResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Data struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
} `json:"data"`
|
||||
Error *APIError `json:"error,omitempty"`
|
||||
}
|
||||
|
||||
// RefreshToken exchanges an SSH fingerprint and client secret for a new JWT.
|
||||
// It does not update c.Token — the caller is responsible for that.
|
||||
func (c *Client) RefreshToken(fingerprint, clientSecret string) (string, error) {
|
||||
url := fmt.Sprintf("%s/api/v1/auth/token", c.BaseURL)
|
||||
|
||||
reqBody := TokenRequest{
|
||||
Fingerprint: fingerprint,
|
||||
ClientSecret: clientSecret,
|
||||
}
|
||||
|
||||
jsonData, err := json.Marshal(reqBody)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to marshal token request: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to create token request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.HTTPClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("token refresh request failed: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("token refresh returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var tokenResp TokenResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
return "", fmt.Errorf("failed to decode token response: %w", err)
|
||||
}
|
||||
|
||||
if !tokenResp.Success || tokenResp.Data.AccessToken == "" {
|
||||
return "", fmt.Errorf("token refresh failed: no access token in response")
|
||||
}
|
||||
|
||||
return tokenResp.Data.AccessToken, nil
|
||||
}
|
||||
|
||||
// APIResponse represents the standard API response format
|
||||
type APIResponse struct {
|
||||
Success bool `json:"success"`
|
||||
|
||||
148
pkg/auth/jwt_test.go
Normal file
148
pkg/auth/jwt_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
const testSecret = "test-secret-key-for-jwt-testing"
|
||||
|
||||
func TestGenerateToken(t *testing.T) {
|
||||
mgr := NewJWTManager(testSecret, 30*time.Minute)
|
||||
|
||||
token, err := mgr.GenerateToken(1, "test@example.com", "testuser")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if token == "" {
|
||||
t.Fatal("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenRoundTrip(t *testing.T) {
|
||||
mgr := NewJWTManager(testSecret, 30*time.Minute)
|
||||
|
||||
token, err := mgr.GenerateToken(42, "user@test.com", "myuser")
|
||||
if err != nil {
|
||||
t.Fatalf("generate failed: %v", err)
|
||||
}
|
||||
|
||||
claims, err := mgr.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
|
||||
if claims.UserID != 42 {
|
||||
t.Errorf("expected UserID 42, got %d", claims.UserID)
|
||||
}
|
||||
if claims.Email != "user@test.com" {
|
||||
t.Errorf("expected Email user@test.com, got %s", claims.Email)
|
||||
}
|
||||
if claims.Username != "myuser" {
|
||||
t.Errorf("expected Username myuser, got %s", claims.Username)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenIssuer(t *testing.T) {
|
||||
mgr := NewJWTManager(testSecret, 30*time.Minute)
|
||||
|
||||
token, _ := mgr.GenerateToken(1, "", "")
|
||||
claims, err := mgr.ValidateToken(token)
|
||||
if err != nil {
|
||||
t.Fatalf("validate failed: %v", err)
|
||||
}
|
||||
|
||||
if claims.Issuer != "terminalshop" {
|
||||
t.Errorf("expected issuer terminalshop, got %s", claims.Issuer)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenExpired(t *testing.T) {
|
||||
// Token that expired 1 second ago
|
||||
mgr := NewJWTManager(testSecret, -1*time.Second)
|
||||
|
||||
token, err := mgr.GenerateToken(1, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("generate failed: %v", err)
|
||||
}
|
||||
|
||||
_, err = mgr.ValidateToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for expired token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenWrongSecret(t *testing.T) {
|
||||
generator := NewJWTManager("secret-a", 30*time.Minute)
|
||||
validator := NewJWTManager("secret-b", 30*time.Minute)
|
||||
|
||||
token, _ := generator.GenerateToken(1, "", "")
|
||||
|
||||
_, err := validator.ValidateToken(token)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when validating with wrong secret")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenMalformed(t *testing.T) {
|
||||
mgr := NewJWTManager(testSecret, 30*time.Minute)
|
||||
|
||||
_, err := mgr.ValidateToken("not.a.valid.jwt")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTokenEmptyString(t *testing.T) {
|
||||
mgr := NewJWTManager(testSecret, 30*time.Minute)
|
||||
|
||||
_, err := mgr.ValidateToken("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty token string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSSHKeyFingerprint(t *testing.T) {
|
||||
pubKey := generateTestSSHKey(t)
|
||||
|
||||
fp := GetSSHKeyFingerprint(pubKey)
|
||||
if !strings.HasPrefix(fp, "SHA256:") {
|
||||
t.Errorf("expected fingerprint to start with SHA256:, got %s", fp)
|
||||
}
|
||||
|
||||
// Should be deterministic
|
||||
fp2 := GetSSHKeyFingerprint(pubKey)
|
||||
if fp != fp2 {
|
||||
t.Errorf("fingerprint not deterministic: %s vs %s", fp, fp2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatSSHPublicKey(t *testing.T) {
|
||||
pubKey := generateTestSSHKey(t)
|
||||
|
||||
formatted := FormatSSHPublicKey(pubKey)
|
||||
if !strings.HasPrefix(formatted, "ssh-ed25519 ") {
|
||||
t.Errorf("expected formatted key to start with ssh-ed25519, got %s", formatted[:20])
|
||||
}
|
||||
}
|
||||
|
||||
// generateTestSSHKey creates an ed25519 SSH key pair for testing.
|
||||
func generateTestSSHKey(t *testing.T) gossh.PublicKey {
|
||||
t.Helper()
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate ed25519 key: %v", err)
|
||||
}
|
||||
|
||||
sshPub, err := gossh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to convert to ssh public key: %v", err)
|
||||
}
|
||||
|
||||
return sshPub
|
||||
}
|
||||
176
pkg/auth/ssh_auth_test.go
Normal file
176
pkg/auth/ssh_auth_test.go
Normal file
@@ -0,0 +1,176 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"crypto/ed25519"
|
||||
"crypto/rand"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"terminalShop/pkg/database"
|
||||
)
|
||||
|
||||
// setupAuthTestDB creates a temp DB with migrated tables.
|
||||
// Returns the DB filename for cleanup.
|
||||
func setupAuthTestDB(t *testing.T) string {
|
||||
t.Helper()
|
||||
testDB := "test_ssh_auth.db"
|
||||
os.Remove(testDB)
|
||||
database.ResetForTesting()
|
||||
|
||||
db, err := database.Connect(testDB)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
if err := database.Migrate(db); err != nil {
|
||||
t.Fatalf("failed to migrate: %v", err)
|
||||
}
|
||||
|
||||
return testDB
|
||||
}
|
||||
|
||||
// newTestSSHKey generates a fresh ed25519 SSH key for testing.
|
||||
func newTestSSHKey(t *testing.T) gossh.PublicKey {
|
||||
t.Helper()
|
||||
pub, _, err := ed25519.GenerateKey(rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to generate key: %v", err)
|
||||
}
|
||||
sshPub, err := gossh.NewPublicKey(pub)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to convert key: %v", err)
|
||||
}
|
||||
return sshPub
|
||||
}
|
||||
|
||||
func TestAuthenticateSSHKeyNewUser(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
mgr := NewJWTManager("test-secret", 30*time.Minute)
|
||||
svc := NewSSHAuthService(db, mgr)
|
||||
|
||||
key := newTestSSHKey(t)
|
||||
user, token, err := svc.AuthenticateSSHKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if user == nil {
|
||||
t.Fatal("expected non-nil user")
|
||||
}
|
||||
if user.ID == 0 {
|
||||
t.Error("expected user to have an ID assigned")
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthenticateSSHKeyExistingUser(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
mgr := NewJWTManager("test-secret", 30*time.Minute)
|
||||
svc := NewSSHAuthService(db, mgr)
|
||||
|
||||
key := newTestSSHKey(t)
|
||||
|
||||
// First call creates the user
|
||||
user1, _, _ := svc.AuthenticateSSHKey(key)
|
||||
|
||||
// Second call should find the same user
|
||||
user2, _, err := svc.AuthenticateSSHKey(key)
|
||||
if err != nil {
|
||||
t.Fatalf("second auth failed: %v", err)
|
||||
}
|
||||
if user1.ID != user2.ID {
|
||||
t.Errorf("expected same user ID, got %d and %d", user1.ID, user2.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWithSSHKey(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
mgr := NewJWTManager("test-secret", 30*time.Minute)
|
||||
svc := NewSSHAuthService(db, mgr)
|
||||
|
||||
key := newTestSSHKey(t)
|
||||
user, token, err := svc.RegisterWithSSHKey(key, "Coffee Fan", "coffee@test.com")
|
||||
if err != nil {
|
||||
t.Fatalf("expected no error, got %v", err)
|
||||
}
|
||||
if user.Name != "Coffee Fan" {
|
||||
t.Errorf("expected name Coffee Fan, got %s", user.Name)
|
||||
}
|
||||
if user.Email != "coffee@test.com" {
|
||||
t.Errorf("expected email coffee@test.com, got %s", user.Email)
|
||||
}
|
||||
if token == "" {
|
||||
t.Error("expected non-empty token")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegisterWithSSHKeyDuplicate(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
mgr := NewJWTManager("test-secret", 30*time.Minute)
|
||||
svc := NewSSHAuthService(db, mgr)
|
||||
|
||||
key := newTestSSHKey(t)
|
||||
|
||||
// First registration succeeds
|
||||
_, _, err := svc.RegisterWithSSHKey(key, "User One", "")
|
||||
if err != nil {
|
||||
t.Fatalf("first register failed: %v", err)
|
||||
}
|
||||
|
||||
// Second registration with same key should fail
|
||||
_, _, err = svc.RegisterWithSSHKey(key, "User Two", "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate SSH key registration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsSSHKeyRegistered(t *testing.T) {
|
||||
testDB := setupAuthTestDB(t)
|
||||
defer os.Remove(testDB)
|
||||
defer database.ResetForTesting()
|
||||
|
||||
db := database.GetDB()
|
||||
mgr := NewJWTManager("test-secret", 30*time.Minute)
|
||||
svc := NewSSHAuthService(db, mgr)
|
||||
|
||||
key := newTestSSHKey(t)
|
||||
|
||||
// Before registration
|
||||
registered, err := svc.IsSSHKeyRegistered(key)
|
||||
if err != nil {
|
||||
t.Fatalf("check failed: %v", err)
|
||||
}
|
||||
if registered {
|
||||
t.Error("expected key to not be registered yet")
|
||||
}
|
||||
|
||||
// After authentication (which auto-creates)
|
||||
svc.AuthenticateSSHKey(key)
|
||||
|
||||
registered, err = svc.IsSSHKeyRegistered(key)
|
||||
if err != nil {
|
||||
t.Fatalf("check failed: %v", err)
|
||||
}
|
||||
if !registered {
|
||||
t.Error("expected key to be registered after authentication")
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
gossh "golang.org/x/crypto/ssh"
|
||||
|
||||
"terminalShop/pkg/api"
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/models"
|
||||
)
|
||||
|
||||
@@ -39,11 +40,12 @@ const resizeDebounce = 50 * time.Millisecond
|
||||
|
||||
type Model struct {
|
||||
// User authentication
|
||||
User *models.User // Authenticated user (nil if not logged in)
|
||||
IsNewUser bool // True if user needs to register
|
||||
SSHPublicKey gossh.PublicKey // SSH public key for registration
|
||||
AccessToken string
|
||||
UsernameInput string // Input for username during registration
|
||||
User *models.User // Authenticated user (nil if not logged in)
|
||||
IsNewUser bool // True if user needs to register
|
||||
SSHPublicKey gossh.PublicKey // SSH public key for registration
|
||||
AccessToken string
|
||||
AuthFingerprintKey string // shared secret for /auth/token refresh
|
||||
UsernameInput string // Input for username during registration
|
||||
|
||||
// Shop state
|
||||
Username string
|
||||
@@ -166,8 +168,12 @@ func (m Model) fetchProductsCmd() tea.Msg {
|
||||
}
|
||||
|
||||
func (m Model) Init() tea.Cmd {
|
||||
// Fetch products and cart from API on startup
|
||||
return tea.Batch(m.fetchProductsCmd, m.fetchCartCmd)
|
||||
// Fetch products and cart from API on startup, and schedule token refresh
|
||||
cmds := []tea.Cmd{m.fetchProductsCmd, m.fetchCartCmd}
|
||||
if m.User != nil && m.AuthFingerprintKey != "" {
|
||||
cmds = append(cmds, m.scheduleTokenRefreshCmd())
|
||||
}
|
||||
return tea.Batch(cmds...)
|
||||
}
|
||||
|
||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
@@ -525,6 +531,21 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
}
|
||||
return m, nil
|
||||
|
||||
case tokenRefreshTickMsg:
|
||||
return m, m.refreshTokenCmd()
|
||||
|
||||
case TokenRefreshedMsg:
|
||||
if msg.Err != nil {
|
||||
log.Printf("[TUI] Token refresh failed, retrying in %v: %v", tokenRetryDuration, msg.Err)
|
||||
return m, tea.Tick(tokenRetryDuration, func(t time.Time) tea.Msg {
|
||||
return tokenRefreshTickMsg{}
|
||||
})
|
||||
}
|
||||
m.AccessToken = msg.Token
|
||||
m.APIClient.Token = msg.Token
|
||||
log.Println("[TUI] Token updated, next refresh in", tokenRefreshDuration)
|
||||
return m, m.scheduleTokenRefreshCmd()
|
||||
|
||||
case OrdersMsg:
|
||||
if msg.Err == nil {
|
||||
m.Orders = msg.Orders
|
||||
@@ -847,6 +868,48 @@ type CartFetchedMsg struct {
|
||||
Err error
|
||||
}
|
||||
|
||||
// tokenRefreshDuration is the interval between token refreshes.
|
||||
// Tokens expire after 30 minutes; refresh 5 minutes early.
|
||||
const tokenRefreshDuration = 25 * time.Minute
|
||||
|
||||
// tokenRetryDuration is the delay before retrying a failed token refresh.
|
||||
const tokenRetryDuration = 1 * time.Minute
|
||||
|
||||
// tokenRefreshTickMsg is sent by the background timer to trigger a token refresh.
|
||||
type tokenRefreshTickMsg struct{}
|
||||
|
||||
// TokenRefreshedMsg is sent when a token refresh attempt completes.
|
||||
type TokenRefreshedMsg struct {
|
||||
Token string
|
||||
Err error
|
||||
}
|
||||
|
||||
// scheduleTokenRefreshCmd returns a tea.Cmd that ticks after tokenRefreshDuration.
|
||||
func (m Model) scheduleTokenRefreshCmd() tea.Cmd {
|
||||
return tea.Tick(tokenRefreshDuration, func(t time.Time) tea.Msg {
|
||||
return tokenRefreshTickMsg{}
|
||||
})
|
||||
}
|
||||
|
||||
// refreshTokenCmd calls the /auth/token endpoint to get a fresh JWT.
|
||||
func (m Model) refreshTokenCmd() tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
if m.APIClient == nil || m.User == nil || m.SSHPublicKey == nil {
|
||||
return TokenRefreshedMsg{Err: fmt.Errorf("not authenticated, cannot refresh token")}
|
||||
}
|
||||
|
||||
fingerprint := auth.GetSSHKeyFingerprint(m.SSHPublicKey)
|
||||
token, err := m.APIClient.RefreshToken(fingerprint, m.AuthFingerprintKey)
|
||||
if err != nil {
|
||||
log.Printf("[TUI] Token refresh failed: %v", err)
|
||||
return TokenRefreshedMsg{Err: err}
|
||||
}
|
||||
|
||||
log.Println("[TUI] Token refreshed successfully")
|
||||
return TokenRefreshedMsg{Token: token}
|
||||
}
|
||||
}
|
||||
|
||||
// OrdersMsg is sent when order history is fetched from the API
|
||||
type OrdersMsg struct {
|
||||
Orders []models.Order
|
||||
@@ -1181,7 +1244,7 @@ func NewModel(username string) Model {
|
||||
}
|
||||
|
||||
// NewModelWithAuth creates a new model with user authentication context
|
||||
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string, apiURL string) Model {
|
||||
func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey, token string, apiURL string, authFingerprintKey string) Model {
|
||||
username := "guest"
|
||||
if user != nil {
|
||||
username = user.Name
|
||||
@@ -1192,6 +1255,7 @@ func NewModelWithAuth(user *models.User, isNewUser bool, pubKey gossh.PublicKey,
|
||||
m.IsNewUser = isNewUser
|
||||
m.SSHPublicKey = pubKey
|
||||
m.AccessToken = token
|
||||
m.AuthFingerprintKey = authFingerprintKey
|
||||
if apiURL != "" {
|
||||
m.APIClient = api.NewClient(apiURL, token)
|
||||
} else {
|
||||
|
||||
188
pkg/validate/validate_test.go
Normal file
188
pkg/validate/validate_test.go
Normal file
@@ -0,0 +1,188 @@
|
||||
package validate
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestWithinLenValid(t *testing.T) {
|
||||
v := WithinLen(3, 10, "username")
|
||||
if err := v("hello"); err != nil {
|
||||
t.Errorf("expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithinLenAtBoundaries(t *testing.T) {
|
||||
v := WithinLen(3, 5, "field")
|
||||
|
||||
// Exactly min length
|
||||
if err := v("abc"); err != nil {
|
||||
t.Errorf("min boundary: expected nil, got %v", err)
|
||||
}
|
||||
|
||||
// Exactly max length
|
||||
if err := v("abcde"); err != nil {
|
||||
t.Errorf("max boundary: expected nil, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithinLenTooShort(t *testing.T) {
|
||||
v := WithinLen(3, 10, "username")
|
||||
if err := v("ab"); err == nil {
|
||||
t.Error("expected error for string shorter than min")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithinLenTooLong(t *testing.T) {
|
||||
v := WithinLen(3, 5, "username")
|
||||
if err := v("abcdef"); err == nil {
|
||||
t.Error("expected error for string longer than max")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCcnValidatorValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"4111111111111111", // Visa test
|
||||
"5500000000000004", // Mastercard test
|
||||
"378282246310005", // Amex test
|
||||
}
|
||||
for _, cc := range valid {
|
||||
if err := CcnValidator(cc); err != nil {
|
||||
t.Errorf("expected %s to be valid, got %v", cc, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCcnValidatorInvalid(t *testing.T) {
|
||||
if err := CcnValidator("4111111111111112"); err == nil {
|
||||
t.Error("expected error for invalid Luhn number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCcnValidatorTooShort(t *testing.T) {
|
||||
if err := CcnValidator("411111"); err == nil {
|
||||
t.Error("expected error for card number shorter than 13 digits")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCcnValidatorWithSpaces(t *testing.T) {
|
||||
if err := CcnValidator("4111 1111 1111 1111"); err != nil {
|
||||
t.Errorf("expected valid with spaces, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDigitsValid(t *testing.T) {
|
||||
v := IsDigits("zip")
|
||||
if err := v("12345"); err != nil {
|
||||
t.Errorf("expected nil for all digits, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDigitsEmpty(t *testing.T) {
|
||||
v := IsDigits("zip")
|
||||
if err := v(""); err != nil {
|
||||
t.Errorf("expected nil for empty string, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsDigitsWithLetters(t *testing.T) {
|
||||
v := IsDigits("zip")
|
||||
if err := v("123abc"); err == nil {
|
||||
t.Error("expected error for string with letters")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustBeLenCorrect(t *testing.T) {
|
||||
v := MustBeLen(5, "zip")
|
||||
if err := v("12345"); err != nil {
|
||||
t.Errorf("expected nil for correct length, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustBeLenWrong(t *testing.T) {
|
||||
v := MustBeLen(5, "zip")
|
||||
if err := v("1234"); err == nil {
|
||||
t.Error("expected error for wrong length")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMustBeLenEmpty(t *testing.T) {
|
||||
v := MustBeLen(5, "zip")
|
||||
if err := v(""); err != nil {
|
||||
t.Errorf("expected nil for empty string (special case), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotEmptyValid(t *testing.T) {
|
||||
v := NotEmpty("name")
|
||||
if err := v("hello"); err != nil {
|
||||
t.Errorf("expected nil for non-empty string, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotEmptyInvalid(t *testing.T) {
|
||||
v := NotEmpty("name")
|
||||
if err := v(""); err == nil {
|
||||
t.Error("expected error for empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidatorValid(t *testing.T) {
|
||||
valid := []string{
|
||||
"user@example.com",
|
||||
"test.user@domain.org",
|
||||
"name+tag@gmail.com",
|
||||
}
|
||||
for _, email := range valid {
|
||||
if err := EmailValidator(email); err != nil {
|
||||
t.Errorf("expected %s to be valid, got %v", email, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmailValidatorInvalid(t *testing.T) {
|
||||
invalid := []string{
|
||||
"notanemail",
|
||||
"@missing.user",
|
||||
"",
|
||||
}
|
||||
for _, email := range invalid {
|
||||
if err := EmailValidator(email); err == nil {
|
||||
t.Errorf("expected error for %q", email)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeAllPass(t *testing.T) {
|
||||
v := Compose(
|
||||
NotEmpty("field"),
|
||||
WithinLen(1, 10, "field"),
|
||||
)
|
||||
if err := v("hello"); err != nil {
|
||||
t.Errorf("expected nil when all validators pass, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeFirstFails(t *testing.T) {
|
||||
v := Compose(
|
||||
NotEmpty("field"),
|
||||
WithinLen(1, 10, "field"),
|
||||
)
|
||||
err := v("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when first validator fails")
|
||||
}
|
||||
if err.Error() != "field cannot be empty" {
|
||||
t.Errorf("expected NotEmpty error message, got %q", err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeSecondFails(t *testing.T) {
|
||||
v := Compose(
|
||||
NotEmpty("field"),
|
||||
WithinLen(1, 3, "field"),
|
||||
)
|
||||
err := v("toolong")
|
||||
if err == nil {
|
||||
t.Fatal("expected error when second validator fails")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user