diff --git a/api/handlers/auth_test.go b/api/handlers/auth_test.go new file mode 100644 index 0000000..64bd405 --- /dev/null +++ b/api/handlers/auth_test.go @@ -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) + } +} diff --git a/api/middleware/auth_test.go b/api/middleware/auth_test.go new file mode 100644 index 0000000..9474c6f --- /dev/null +++ b/api/middleware/auth_test.go @@ -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) + } +} diff --git a/main.go b/main.go index 1add16b..81898f6 100644 --- a/main.go +++ b/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 diff --git a/pkg/api/client.go b/pkg/api/client.go index 8c4573d..2f3307c 100644 --- a/pkg/api/client.go +++ b/pkg/api/client.go @@ -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"` diff --git a/pkg/auth/jwt_test.go b/pkg/auth/jwt_test.go new file mode 100644 index 0000000..079654f --- /dev/null +++ b/pkg/auth/jwt_test.go @@ -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 +} diff --git a/pkg/auth/ssh_auth_test.go b/pkg/auth/ssh_auth_test.go new file mode 100644 index 0000000..c889856 --- /dev/null +++ b/pkg/auth/ssh_auth_test.go @@ -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") + } +} diff --git a/pkg/tui/model.go b/pkg/tui/model.go index c304cb2..46ae806 100644 --- a/pkg/tui/model.go +++ b/pkg/tui/model.go @@ -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 { diff --git a/pkg/validate/validate_test.go b/pkg/validate/validate_test.go new file mode 100644 index 0000000..88d06dd --- /dev/null +++ b/pkg/validate/validate_test.go @@ -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") + } +}