added some tests. mostly written by claude code

This commit is contained in:
2026-03-03 08:44:47 +01:00
parent 163790092e
commit d6db7e7c8f
8 changed files with 1120 additions and 12 deletions

296
api/handlers/auth_test.go Normal file
View 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
View 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
View File

@@ -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

View File

@@ -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
View 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
View 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")
}
}

View File

@@ -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 {

View 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")
}
}