Initial commit: Custom Start Page application with authentication and DynamoDB storage

This commit is contained in:
2026-02-18 22:06:43 -05:00
commit 7175ff14ba
47 changed files with 7592 additions and 0 deletions

View File

@@ -0,0 +1,170 @@
package handlers
import (
"html/template"
"log"
"net/http"
"path/filepath"
"custom-start-page/internal/auth"
)
// AuthHandler handles authentication-related HTTP requests
type AuthHandler struct {
oauthService *auth.OAuthService
userService *auth.UserService
sessionStore SessionStore
templates *template.Template
}
// SessionStore manages user sessions
type SessionStore interface {
CreateSession(w http.ResponseWriter, r *http.Request, userID string) error
GetUserID(r *http.Request) (string, error)
DestroySession(w http.ResponseWriter, r *http.Request) error
}
// NewAuthHandler creates a new auth handler
func NewAuthHandler(oauthService *auth.OAuthService, userService *auth.UserService, sessionStore SessionStore) *AuthHandler {
return NewAuthHandlerWithTemplates(oauthService, userService, sessionStore, nil)
}
// NewAuthHandlerWithTemplates creates a new auth handler with custom templates
func NewAuthHandlerWithTemplates(oauthService *auth.OAuthService, userService *auth.UserService, sessionStore SessionStore, templates *template.Template) *AuthHandler {
if templates == nil {
// Parse templates
templates = template.Must(template.ParseGlob(filepath.Join("templates", "*.html")))
template.Must(templates.ParseGlob(filepath.Join("templates", "layouts", "*.html")))
}
return &AuthHandler{
oauthService: oauthService,
userService: userService,
sessionStore: sessionStore,
templates: templates,
}
}
// HandleOAuthInitiate initiates the OAuth flow
// GET /auth/oauth/:provider
func (h *AuthHandler) HandleOAuthInitiate(w http.ResponseWriter, r *http.Request) {
// Extract provider from URL path
provider := r.PathValue("provider")
if provider == "" {
http.Error(w, "Provider not specified", http.StatusBadRequest)
return
}
// Generate OAuth redirect URL
redirectURL, err := h.oauthService.InitiateOAuth(provider)
if err != nil {
log.Printf("Failed to initiate OAuth: %v", err)
http.Error(w, "Failed to initiate OAuth", http.StatusInternalServerError)
return
}
// Redirect to OAuth provider
http.Redirect(w, r, redirectURL, http.StatusTemporaryRedirect)
}
// HandleOAuthCallback handles the OAuth callback
// GET /auth/callback/:provider
func (h *AuthHandler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
// Extract provider from URL path
provider := r.PathValue("provider")
if provider == "" {
http.Error(w, "Provider not specified", http.StatusBadRequest)
return
}
// Get code and state from query parameters
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
if code == "" {
// Check for error from OAuth provider
if errMsg := r.URL.Query().Get("error"); errMsg != "" {
log.Printf("OAuth error: %s", errMsg)
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusTemporaryRedirect)
return
}
http.Error(w, "Authorization code not provided", http.StatusBadRequest)
return
}
if state == "" {
http.Error(w, "State parameter not provided", http.StatusBadRequest)
return
}
// Exchange code for token
token, err := h.oauthService.HandleOAuthCallback(r.Context(), provider, code, state)
if err != nil {
log.Printf("Failed to handle OAuth callback: %v", err)
http.Redirect(w, r, "/login?error=oauth_failed", http.StatusTemporaryRedirect)
return
}
// Get or create user from OAuth provider
user, err := h.userService.GetOrCreateUserFromGoogle(r.Context(), token, h.oauthService.GetGoogleConfig())
if err != nil {
log.Printf("Failed to get or create user: %v", err)
http.Redirect(w, r, "/login?error=user_creation_failed", http.StatusTemporaryRedirect)
return
}
// Create session
if err := h.sessionStore.CreateSession(w, r, user.ID); err != nil {
log.Printf("Failed to create session: %v", err)
http.Error(w, "Failed to create session", http.StatusInternalServerError)
return
}
// Redirect to dashboard
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
}
// HandleLogout logs out the user
// POST /logout
func (h *AuthHandler) HandleLogout(w http.ResponseWriter, r *http.Request) {
if err := h.sessionStore.DestroySession(w, r); err != nil {
log.Printf("Failed to destroy session: %v", err)
}
// Redirect to login page
http.Redirect(w, r, "/login", http.StatusTemporaryRedirect)
}
// HandleLogin displays the login page
// GET /login
func (h *AuthHandler) HandleLogin(w http.ResponseWriter, r *http.Request) {
// Check if user is already logged in
if userID, err := h.sessionStore.GetUserID(r); err == nil && userID != "" {
http.Redirect(w, r, "/dashboard", http.StatusTemporaryRedirect)
return
}
// Get error message if any
errorMsg := ""
if errParam := r.URL.Query().Get("error"); errParam != "" {
switch errParam {
case "oauth_failed":
errorMsg = "Authentication failed. Please try again."
case "user_creation_failed":
errorMsg = "Failed to create user account. Please try again."
default:
errorMsg = "An error occurred. Please try again."
}
}
// Render login template
data := map[string]interface{}{
"Error": errorMsg,
"OAuthProviders": []map[string]string{}, // Empty for now, can be extended
}
if err := h.templates.ExecuteTemplate(w, "login.html", data); err != nil {
log.Printf("Failed to render login template: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,159 @@
package handlers
import (
"html/template"
"net/http"
"net/http/httptest"
"testing"
"custom-start-page/internal/auth"
)
// MockSessionStore is a mock implementation of SessionStore for testing
type MockSessionStore struct {
userID string
shouldError bool
}
func (m *MockSessionStore) CreateSession(w http.ResponseWriter, r *http.Request, userID string) error {
m.userID = userID
return nil
}
func (m *MockSessionStore) GetUserID(r *http.Request) (string, error) {
if m.shouldError {
return "", http.ErrNoCookie
}
return m.userID, nil
}
func (m *MockSessionStore) DestroySession(w http.ResponseWriter, r *http.Request) error {
m.userID = ""
return nil
}
func (m *MockSessionStore) ValidateSession(r *http.Request) bool {
return m.userID != ""
}
// createMockTemplate creates a simple mock template for testing
func createMockTemplate() *template.Template {
tmpl := template.New("login.html")
template.Must(tmpl.Parse(`<!DOCTYPE html><html><body>{{if .Error}}<div>{{.Error}}</div>{{end}}<a href="/auth/oauth/google">Login</a></body></html>`))
return tmpl
}
// TestHandleLogin_UnauthenticatedUser tests that unauthenticated users see the login page
func TestHandleLogin_UnauthenticatedUser(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{shouldError: true}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil) // nil repo for this test
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogin(w, req)
// Assert
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check that response contains login page elements
body := w.Body.String()
if body == "" {
t.Error("Expected non-empty response body")
}
}
// TestHandleLogin_AuthenticatedUser tests that authenticated users are redirected to dashboard
func TestHandleLogin_AuthenticatedUser(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogin(w, req)
// Assert
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/dashboard" {
t.Errorf("Expected redirect to /dashboard, got %s", location)
}
}
// TestHandleLogin_WithError tests that error messages are displayed
func TestHandleLogin_WithError(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{shouldError: true}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request with error parameter
req := httptest.NewRequest(http.MethodGet, "/login?error=oauth_failed", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogin(w, req)
// Assert
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check that response contains error message
body := w.Body.String()
if body == "" {
t.Error("Expected non-empty response body")
}
}
// TestHandleLogout tests that logout destroys session and redirects to login
func TestHandleLogout(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
handler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleLogout(w, req)
// Assert
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
// Verify session was destroyed
if mockSessionStore.userID != "" {
t.Error("Expected session to be destroyed")
}
}

View File

@@ -0,0 +1,58 @@
package handlers
import (
"html/template"
"log"
"net/http"
"path/filepath"
"custom-start-page/internal/middleware"
)
// DashboardHandler handles dashboard-related HTTP requests
type DashboardHandler struct {
templates *template.Template
}
// NewDashboardHandler creates a new dashboard handler
func NewDashboardHandler() *DashboardHandler {
// Parse templates
templates := template.Must(template.ParseGlob(filepath.Join("templates", "*.html")))
template.Must(templates.ParseGlob(filepath.Join("templates", "layouts", "*.html")))
return &DashboardHandler{
templates: templates,
}
}
// HandleDashboard displays the dashboard page
// GET /dashboard
func (h *DashboardHandler) HandleDashboard(w http.ResponseWriter, r *http.Request) {
// Get user ID from context (set by auth middleware)
userID, ok := middleware.GetUserIDFromContext(r.Context())
if !ok {
http.Error(w, "User ID not found in context", http.StatusInternalServerError)
return
}
// TODO: Fetch user's pages from database
// For now, we'll use mock data
pages := []map[string]interface{}{
{
"ID": "default-page",
"Name": "Home",
"Active": true,
},
}
// Render dashboard template
data := map[string]interface{}{
"UserID": userID,
"Pages": pages,
}
if err := h.templates.ExecuteTemplate(w, "dashboard.html", data); err != nil {
log.Printf("Failed to render dashboard template: %v", err)
http.Error(w, "Internal server error", http.StatusInternalServerError)
}
}

View File

@@ -0,0 +1,68 @@
package handlers
import (
"context"
"html/template"
"net/http"
"net/http/httptest"
"testing"
"custom-start-page/internal/middleware"
)
// createMockDashboardTemplate creates a simple mock template for testing
func createMockDashboardTemplate() *template.Template {
tmpl := template.New("dashboard.html")
template.Must(tmpl.Parse(`<!DOCTYPE html><html><body><h1>Dashboard</h1><div>User: {{.UserID}}</div></body></html>`))
return tmpl
}
// TestHandleDashboard_WithAuthenticatedUser tests that authenticated users see the dashboard
func TestHandleDashboard_WithAuthenticatedUser(t *testing.T) {
// Setup
mockTemplate := createMockDashboardTemplate()
handler := &DashboardHandler{
templates: mockTemplate,
}
// Create request with user ID in context
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
ctx := context.WithValue(req.Context(), middleware.GetUserIDContextKey(), "test-user-123")
req = req.WithContext(ctx)
w := httptest.NewRecorder()
// Execute
handler.HandleDashboard(w, req)
// Assert
if w.Code != http.StatusOK {
t.Errorf("Expected status 200, got %d", w.Code)
}
// Check that response contains dashboard content
body := w.Body.String()
if body == "" {
t.Error("Expected non-empty response body")
}
}
// TestHandleDashboard_WithoutUserID tests that requests without user ID fail
func TestHandleDashboard_WithoutUserID(t *testing.T) {
// Setup
mockTemplate := createMockDashboardTemplate()
handler := &DashboardHandler{
templates: mockTemplate,
}
// Create request without user ID in context
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
w := httptest.NewRecorder()
// Execute
handler.HandleDashboard(w, req)
// Assert
if w.Code != http.StatusInternalServerError {
t.Errorf("Expected status 500, got %d", w.Code)
}
}

View File

@@ -0,0 +1,102 @@
package handlers
import (
"net/http"
"net/http/httptest"
"testing"
"custom-start-page/internal/auth"
"custom-start-page/internal/middleware"
)
// TestRedirectFlow_UnauthenticatedToLogin tests that unauthenticated users are redirected to login
func TestRedirectFlow_UnauthenticatedToLogin(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{shouldError: true}
// Create middleware
requireAuth := middleware.RequireAuth(mockSessionStore)
// Create dashboard handler
mockDashboardTemplate := createMockDashboardTemplate()
dashboardHandler := &DashboardHandler{templates: mockDashboardTemplate}
// Wrap dashboard handler with auth middleware
protectedHandler := requireAuth(http.HandlerFunc(dashboardHandler.HandleDashboard))
// Create request to dashboard
req := httptest.NewRequest(http.MethodGet, "/dashboard", nil)
w := httptest.NewRecorder()
// Execute
protectedHandler.ServeHTTP(w, req)
// Assert - should redirect to login
if w.Code != http.StatusSeeOther {
t.Errorf("Expected status 303, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
}
// TestRedirectFlow_AuthenticatedToDashboard tests that authenticated users accessing login are redirected to dashboard
func TestRedirectFlow_AuthenticatedToDashboard(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
authHandler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create request to login page
req := httptest.NewRequest(http.MethodGet, "/login", nil)
w := httptest.NewRecorder()
// Execute
authHandler.HandleLogin(w, req)
// Assert - should redirect to dashboard
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/dashboard" {
t.Errorf("Expected redirect to /dashboard, got %s", location)
}
}
// TestRedirectFlow_LogoutToLogin tests that logout redirects to login
func TestRedirectFlow_LogoutToLogin(t *testing.T) {
// Setup
mockSessionStore := &MockSessionStore{userID: "test-user-123"}
oauthService := auth.NewOAuthService("test-client-id", "test-secret", "http://localhost/callback", auth.NewMemoryStateStore())
userService := auth.NewUserService(nil)
mockTemplate := createMockTemplate()
authHandler := NewAuthHandlerWithTemplates(oauthService, userService, mockSessionStore, mockTemplate)
// Create logout request
req := httptest.NewRequest(http.MethodPost, "/logout", nil)
w := httptest.NewRecorder()
// Execute
authHandler.HandleLogout(w, req)
// Assert - should redirect to login
if w.Code != http.StatusTemporaryRedirect {
t.Errorf("Expected status 307, got %d", w.Code)
}
location := w.Header().Get("Location")
if location != "/login" {
t.Errorf("Expected redirect to /login, got %s", location)
}
// Verify session was destroyed
if mockSessionStore.userID != "" {
t.Error("Expected session to be destroyed after logout")
}
}