eb92f64e93
Mobile fixes: - Add click toggle handler for hamburger menu (was hover-only) - Menu now opens/closes on tap and closes when clicking outside - Keep hover support for desktop iPad fixes: - Sidebar content now visible on touch devices (901-1280px) - Added (hover: hover) media query to prevent hide-on-hover on tablets Security improvements: - Replace exec.CommandContext with go-git library for git operations - Add path traversal and command injection prevention - Fix race condition in template hot reload - Add environment-based cookie Secure flag Code quality: - Add constants.go for magic numbers - Remove unused code (ParsePreferenceToggleRequest, DomainError) - Add FOUC prevention with inline critical CSS - Add Makefile dev/run/clean targets - Fix README git clone URL - Add doc/DECISIONS.md for architectural decisions Tests: - Add hamburger menu click toggle tests - Add iPad sidebar visibility tests - Update security tests for go-git implementation - Add cookie Secure flag tests
207 lines
5.6 KiB
Go
207 lines
5.6 KiB
Go
package handlers
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
// ==============================================================================
|
|
// SECURITY TESTS for go-git Implementation
|
|
// ==============================================================================
|
|
// These tests verify that the path validation and git operations are secure.
|
|
// The implementation uses go-git (pure Go) instead of exec.CommandContext
|
|
// to eliminate shell command injection risks.
|
|
// ==============================================================================
|
|
|
|
// TestValidateRepoPath tests the security validation for repository paths
|
|
func TestValidateRepoPath(t *testing.T) {
|
|
// Get project root (two levels up from handlers directory)
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get working directory: %v", err)
|
|
}
|
|
// Navigate to project root
|
|
projectRoot := filepath.Join(cwd, "..", "..")
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
shouldErr bool
|
|
errMsg string
|
|
}{
|
|
{
|
|
name: "Valid path within project",
|
|
path: projectRoot,
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "Valid subdirectory",
|
|
path: filepath.Join(projectRoot, "data"),
|
|
shouldErr: false,
|
|
},
|
|
{
|
|
name: "Path traversal attack - parent directory",
|
|
path: "../../../etc/passwd",
|
|
shouldErr: true,
|
|
errMsg: "repository path outside project directory",
|
|
},
|
|
{
|
|
name: "Path traversal attack - absolute path",
|
|
path: "/etc/passwd",
|
|
shouldErr: true,
|
|
errMsg: "repository path outside project directory",
|
|
},
|
|
{
|
|
name: "Command injection attempt - pipe",
|
|
path: "data | cat /etc/passwd",
|
|
shouldErr: true,
|
|
errMsg: "path does not exist",
|
|
},
|
|
{
|
|
name: "Command injection attempt - semicolon",
|
|
path: "data; rm -rf /",
|
|
shouldErr: true,
|
|
errMsg: "path does not exist",
|
|
},
|
|
{
|
|
name: "Command injection attempt - backticks",
|
|
path: "data`whoami`",
|
|
shouldErr: true,
|
|
errMsg: "path does not exist",
|
|
},
|
|
{
|
|
name: "Non-existent path",
|
|
path: filepath.Join(projectRoot, "nonexistent-directory-12345"),
|
|
shouldErr: true,
|
|
errMsg: "path does not exist",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := validateRepoPath(tt.path)
|
|
|
|
if tt.shouldErr {
|
|
if err == nil {
|
|
t.Errorf("Expected error for path %q, got nil", tt.path)
|
|
return
|
|
}
|
|
if tt.errMsg != "" && err.Error() != "" {
|
|
// Check if error message contains expected substring
|
|
if !contains(err.Error(), tt.errMsg) {
|
|
t.Errorf("Expected error containing %q, got %q", tt.errMsg, err.Error())
|
|
}
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Errorf("Unexpected error for valid path %q: %v", tt.path, err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetGitRepoFirstCommitDate_SecurityValidation tests that malicious paths are rejected
|
|
func TestGetGitRepoFirstCommitDate_SecurityValidation(t *testing.T) {
|
|
maliciousPaths := []string{
|
|
"../../../etc/passwd",
|
|
"/etc/passwd",
|
|
"data | cat /etc/passwd",
|
|
"data; whoami",
|
|
"data`id`",
|
|
"$(whoami)",
|
|
}
|
|
|
|
for _, path := range maliciousPaths {
|
|
t.Run("Reject_"+path, func(t *testing.T) {
|
|
// Should return empty string (safe rejection)
|
|
result := getGitRepoFirstCommitDate(path)
|
|
if result != "" {
|
|
t.Errorf("Expected empty result for malicious path %q, got %q", path, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGetGitRepoFirstCommitDate_NonGitRepo tests that non-git directories return empty
|
|
func TestGetGitRepoFirstCommitDate_NonGitRepo(t *testing.T) {
|
|
// Create a temporary directory that exists but is not a git repo
|
|
tempDir := t.TempDir()
|
|
|
|
// This should fail gracefully (not panic)
|
|
result := getGitRepoFirstCommitDate(tempDir)
|
|
|
|
// Should return empty string for non-git repos
|
|
if result != "" {
|
|
t.Errorf("Expected empty result for non-git directory, got %q", result)
|
|
}
|
|
}
|
|
|
|
// TestGetGitRepoFirstCommitDate_ValidRepo tests the happy path with the current repo
|
|
func TestGetGitRepoFirstCommitDate_ValidRepo(t *testing.T) {
|
|
// Get project root (should be a valid git repo)
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
t.Fatalf("Failed to get working directory: %v", err)
|
|
}
|
|
projectRoot := filepath.Join(cwd, "..", "..")
|
|
|
|
// This should return a valid date
|
|
result := getGitRepoFirstCommitDate(projectRoot)
|
|
|
|
// Should return a date in YYYY-MM format
|
|
if result == "" {
|
|
t.Log("Warning: No commit date returned (repo might not have commits)")
|
|
return
|
|
}
|
|
|
|
// Validate format: YYYY-MM
|
|
if len(result) != 7 || result[4] != '-' {
|
|
t.Errorf("Expected date in YYYY-MM format, got %q", result)
|
|
}
|
|
|
|
// Year should be between 2020 and 2030 (reasonable range)
|
|
year := result[:4]
|
|
if year < "2020" || year > "2030" {
|
|
t.Errorf("Year %s seems unreasonable for project start date", year)
|
|
}
|
|
|
|
t.Logf("First commit date: %s", result)
|
|
}
|
|
|
|
// TestFindProjectRoot tests the project root detection
|
|
func TestFindProjectRoot(t *testing.T) {
|
|
root, err := findProjectRoot()
|
|
if err != nil {
|
|
t.Fatalf("Failed to find project root: %v", err)
|
|
}
|
|
|
|
// Verify .git directory exists
|
|
gitPath := filepath.Join(root, ".git")
|
|
info, err := os.Stat(gitPath)
|
|
if err != nil {
|
|
t.Errorf("Expected .git directory at %s, got error: %v", gitPath, err)
|
|
}
|
|
if !info.IsDir() {
|
|
t.Errorf("Expected .git to be a directory at %s", gitPath)
|
|
}
|
|
|
|
t.Logf("Project root: %s", root)
|
|
}
|
|
|
|
// Helper function to check if a string contains a substring
|
|
func contains(s, substr string) bool {
|
|
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
|
(len(s) > 0 && len(substr) > 0 && stringContains(s, substr)))
|
|
}
|
|
|
|
func stringContains(s, substr string) bool {
|
|
for i := 0; i <= len(s)-len(substr); i++ {
|
|
if s[i:i+len(substr)] == substr {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|