Compare commits

...

6 Commits

Author SHA1 Message Date
agent-company 2ea20da5ef test: add 43 integration tests for all HTTP handlers
Add comprehensive integration test suite using httptest with a mock
Gitea API server. Tests cover GET and POST handlers for dashboard,
issues, pulls, issue/PR detail, create issue, state changes, comments,
labels, assignees, reviews, and settings. Both regular and HTMX
request paths are tested. Includes TestMain to set working directory
to project root for template loading.

Covers issues: #140 #139 #138 #137 #136 #135 #134 #133 #124 #118
#113 #111 #110

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 18:12:53 +00:00
AI-Manager 77c8e92e38 Merge pull request 'test: unit tests for SubmitReview and ApplyLabel client methods' (#130) from feature/unit-tests-submit-review-apply-label-127 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 15:03:40 +00:00
AI-Manager 2566e14bef Merge pull request 'chore: extract settings template to HTML file' (#129) from feature/extract-settings-template-126 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 15:03:35 +00:00
AI-Manager b0747c0239 Merge pull request 'feat: wire GITEA_TOKEN env var as auth fallback' (#128) from feature/gitea-token-fallback-125 into master
Build and Push / test (push) Has been cancelled
Build and Push / build (push) Has been cancelled
2026-03-28 15:03:23 +00:00
agent-company eeea1b6475 chore: extract inline settings template to internal/templates/settings.html
Move the large inline HTML template from settings.go into a separate
file at internal/templates/settings.html, matching the project convention
used by all other handlers. The template is now loaded at render time
via template.ParseFiles, consistent with dashboard, issues, etc.

Closes leeworks-agents/gitea-mobile#126

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:06:25 +00:00
agent-company feae2e19a1 feat: wire GITEA_TOKEN env var as auth fallback for single-user deployments
Update Auth middleware to accept a fallbackToken parameter. When no
per-user cookie token is present and GITEA_TOKEN is set in the
environment, the middleware uses the env token instead of redirecting
to /settings. Cookie tokens still take precedence over the fallback.

Add three new unit tests covering: fallback used when no cookie,
cookie takes precedence over fallback, and redirect when neither is set.

Closes leeworks-agents/gitea-mobile#125

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-28 13:04:55 +00:00
6 changed files with 1251 additions and 93 deletions
+1 -1
View File
@@ -33,7 +33,7 @@ func main() {
// Apply middleware chain: logging -> auth. // Apply middleware chain: logging -> auth.
var handler http.Handler = mux var handler http.Handler = mux
handler = middleware.Auth(cfg.SessionSecret)(handler) handler = middleware.Auth(cfg.SessionSecret, cfg.GiteaToken)(handler)
handler = middleware.Logging()(handler) handler = middleware.Logging()(handler)
slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL) slog.Info("server starting", "addr", cfg.ListenAddr, "gitea_url", cfg.GiteaURL)
File diff suppressed because it is too large Load Diff
+17 -87
View File
@@ -2,6 +2,7 @@ package handlers
import ( import (
"html/template" "html/template"
"log/slog"
"net/http" "net/http"
"strings" "strings"
@@ -9,89 +10,7 @@ import (
"gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware" "gitea.leeworks.dev/0xwheatyz/gitea-mobile/internal/middleware"
) )
var settingsTemplate = template.Must(template.New("settings").Parse(`<!DOCTYPE html> const settingsTemplatePath = "internal/templates/settings.html"
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Settings — Gitea Mobile</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem;
}
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.5rem; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5rem; font-size: 1rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #e6edf3; margin-bottom: 1rem;
}
input:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600;
background: #238636; color: #fff; border: none; border-radius: 6px;
cursor: pointer;
}
button:active { background: #2ea043; }
.message {
padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem;
}
.message.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
.message.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
.message.info { background: #0c1d2e; border: 1px solid #1f6feb; color: #58a6ff; }
.hint { font-size: 0.75rem; color: #8b949e; margin-top: 0.25rem; margin-bottom: 1rem; }
.status { font-size: 0.875rem; color: #8b949e; }
.status .connected { color: #3fb950; }
.logout-btn {
background: #21262d; border: 1px solid #30363d; margin-top: 0.5rem;
}
.logout-btn:active { background: #30363d; }
</style>
</head>
<body>
<h1>Settings</h1>
{{if .Message}}
<div class="message {{.MessageType}}">{{.Message}}</div>
{{end}}
{{if .HasToken}}
<div class="card">
<p class="status">Status: <span class="connected">Connected</span></p>
<p class="hint">A Gitea API token is configured.</p>
<form method="POST" action="/settings">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Remove Token</button>
</form>
</div>
{{end}}
<div class="card">
<form method="POST" action="/settings">
<input type="hidden" name="action" value="save">
<label for="token">Gitea API Token</label>
<input type="password" id="token" name="token" placeholder="Enter your Gitea API token" required>
<p class="hint">Generate a token at your Gitea instance under Settings &rarr; Applications.</p>
<button type="submit">{{if .HasToken}}Update Token{{else}}Save Token{{end}}</button>
</form>
</div>
{{if .HasToken}}
<p style="text-align:center; margin-top:1rem;">
<a href="/" style="color:#58a6ff; text-decoration:none;">Back to Dashboard</a>
</p>
{{end}}
</body>
</html>`))
// SettingsHandler handles GET and POST requests for the settings page. // SettingsHandler handles GET and POST requests for the settings page.
type SettingsHandler struct { type SettingsHandler struct {
@@ -126,8 +45,7 @@ func (h *SettingsHandler) handleGet(w http.ResponseWriter, r *http.Request) {
} }
data := settingsData{HasToken: hasToken} data := settingsData{HasToken: hasToken}
w.Header().Set("Content-Type", "text/html; charset=utf-8") h.renderSettings(w, data)
settingsTemplate.Execute(w, data)
} }
func (h *SettingsHandler) handlePost(w http.ResponseWriter, r *http.Request) { func (h *SettingsHandler) handlePost(w http.ResponseWriter, r *http.Request) {
@@ -172,6 +90,18 @@ func (h *SettingsHandler) renderWithMessage(w http.ResponseWriter, r *http.Reque
Message: msg, Message: msg,
MessageType: msgType, MessageType: msgType,
} }
w.Header().Set("Content-Type", "text/html; charset=utf-8") h.renderSettings(w, data)
settingsTemplate.Execute(w, data) }
func (h *SettingsHandler) renderSettings(w http.ResponseWriter, data settingsData) {
tmpl, err := template.ParseFiles(settingsTemplatePath)
if err != nil {
slog.Error("failed to parse settings template", "error", err)
http.Error(w, "template error", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := tmpl.Execute(w, data); err != nil {
slog.Error("failed to execute settings template", "error", err)
}
} }
+11 -1
View File
@@ -23,9 +23,12 @@ func TokenFromContext(ctx context.Context) string {
} }
// Auth returns middleware that checks for a valid token cookie. // Auth returns middleware that checks for a valid token cookie.
// If no cookie token is found and fallbackToken is non-empty, the fallback
// token is used instead (useful for single-user or service-account deployments
// where GITEA_TOKEN is set in the environment).
// Unauthenticated requests are redirected to the settings page. // Unauthenticated requests are redirected to the settings page.
// The /health, /settings, and /static/ paths are exempt from auth. // The /health, /settings, and /static/ paths are exempt from auth.
func Auth(sessionSecret string) func(http.Handler) http.Handler { func Auth(sessionSecret, fallbackToken string) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler { return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Skip auth for exempt paths. // Skip auth for exempt paths.
@@ -37,6 +40,13 @@ func Auth(sessionSecret string) func(http.Handler) http.Handler {
token, err := auth.GetToken(r, sessionSecret) token, err := auth.GetToken(r, sessionSecret)
if err != nil || token == "" { if err != nil || token == "" {
// Fall back to environment token if available.
if fallbackToken != "" {
slog.Debug("using fallback token from environment", "path", path)
ctx := context.WithValue(r.Context(), TokenContextKey, fallbackToken)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err) slog.Debug("unauthenticated request, redirecting to settings", "path", path, "error", err)
http.Redirect(w, r, "/settings", http.StatusSeeOther) http.Redirect(w, r, "/settings", http.StatusSeeOther)
return return
+73 -4
View File
@@ -11,7 +11,7 @@ import (
const testSecret = "test-secret-that-is-at-least-32-chars-long" const testSecret = "test-secret-that-is-at-least-32-chars-long"
func TestAuth_HealthBypass(t *testing.T) { func TestAuth_HealthBypass(t *testing.T) {
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
@@ -25,7 +25,7 @@ func TestAuth_HealthBypass(t *testing.T) {
} }
func TestAuth_SettingsBypass(t *testing.T) { func TestAuth_SettingsBypass(t *testing.T) {
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
@@ -39,7 +39,7 @@ func TestAuth_SettingsBypass(t *testing.T) {
} }
func TestAuth_RedirectWithoutToken(t *testing.T) { func TestAuth_RedirectWithoutToken(t *testing.T) {
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
})) }))
@@ -57,7 +57,7 @@ func TestAuth_RedirectWithoutToken(t *testing.T) {
func TestAuth_PassWithToken(t *testing.T) { func TestAuth_PassWithToken(t *testing.T) {
called := false called := false
handler := Auth(testSecret)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true called = true
token := TokenFromContext(r.Context()) token := TokenFromContext(r.Context())
if token != "my-token" { if token != "my-token" {
@@ -83,3 +83,72 @@ func TestAuth_PassWithToken(t *testing.T) {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK) t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
} }
} }
func TestAuth_FallbackToken_UsedWhenNoCookie(t *testing.T) {
called := false
handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "env-fallback-token" {
t.Errorf("token = %q, want %q", token, "env-fallback-token")
}
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler was not called with fallback token")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_FallbackToken_CookieTakesPrecedence(t *testing.T) {
called := false
handler := Auth(testSecret, "env-fallback-token")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called = true
token := TokenFromContext(r.Context())
if token != "cookie-token" {
t.Errorf("token = %q, want %q (cookie should take precedence over fallback)", token, "cookie-token")
}
w.WriteHeader(http.StatusOK)
}))
// Set a cookie token.
cookieW := httptest.NewRecorder()
auth.SetTokenCookie(cookieW, "cookie-token", testSecret, false)
cookie := cookieW.Result().Cookies()[0]
req := httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(cookie)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if !called {
t.Error("next handler was not called")
}
if w.Code != http.StatusOK {
t.Errorf("status = %d, want %d", w.Code, http.StatusOK)
}
}
func TestAuth_NoFallbackToken_RedirectsWithoutCookie(t *testing.T) {
handler := Auth(testSecret, "")(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}))
req := httptest.NewRequest(http.MethodGet, "/issues", nil)
w := httptest.NewRecorder()
handler.ServeHTTP(w, req)
if w.Code != http.StatusSeeOther {
t.Errorf("status = %d, want %d", w.Code, http.StatusSeeOther)
}
if loc := w.Header().Get("Location"); loc != "/settings" {
t.Errorf("Location = %q, want %q", loc, "/settings")
}
}
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<title>Settings — Gitea Mobile</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #0d1117; color: #e6edf3;
padding: 1rem;
padding-top: max(1rem, env(safe-area-inset-top));
}
h1 { font-size: 1.5rem; margin-bottom: 1rem; }
.card {
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
padding: 1rem; margin-bottom: 1rem;
}
label { display: block; font-size: 0.875rem; color: #8b949e; margin-bottom: 0.5rem; }
input[type="text"], input[type="password"] {
width: 100%; padding: 0.5rem; font-size: 1rem;
background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
color: #e6edf3; margin-bottom: 1rem;
}
input:focus { outline: none; border-color: #58a6ff; }
button {
width: 100%; padding: 0.75rem; font-size: 1rem; font-weight: 600;
background: #238636; color: #fff; border: none; border-radius: 6px;
cursor: pointer;
}
button:active { background: #2ea043; }
.message {
padding: 0.75rem; border-radius: 6px; margin-bottom: 1rem;
font-size: 0.875rem;
}
.message.success { background: #0d2818; border: 1px solid #238636; color: #3fb950; }
.message.error { background: #2d1117; border: 1px solid #da3633; color: #f85149; }
.message.info { background: #0c1d2e; border: 1px solid #1f6feb; color: #58a6ff; }
.hint { font-size: 0.75rem; color: #8b949e; margin-top: 0.25rem; margin-bottom: 1rem; }
.status { font-size: 0.875rem; color: #8b949e; }
.status .connected { color: #3fb950; }
.logout-btn {
background: #21262d; border: 1px solid #30363d; margin-top: 0.5rem;
}
.logout-btn:active { background: #30363d; }
</style>
</head>
<body>
<h1>Settings</h1>
{{if .Message}}
<div class="message {{.MessageType}}">{{.Message}}</div>
{{end}}
{{if .HasToken}}
<div class="card">
<p class="status">Status: <span class="connected">Connected</span></p>
<p class="hint">A Gitea API token is configured.</p>
<form method="POST" action="/settings">
<input type="hidden" name="action" value="logout">
<button type="submit" class="logout-btn">Remove Token</button>
</form>
</div>
{{end}}
<div class="card">
<form method="POST" action="/settings">
<input type="hidden" name="action" value="save">
<label for="token">Gitea API Token</label>
<input type="password" id="token" name="token" placeholder="Enter your Gitea API token" required>
<p class="hint">Generate a token at your Gitea instance under Settings &rarr; Applications.</p>
<button type="submit">{{if .HasToken}}Update Token{{else}}Save Token{{end}}</button>
</form>
</div>
{{if .HasToken}}
<p style="text-align:center; margin-top:1rem;">
<a href="/" style="color:#58a6ff; text-decoration:none;">Back to Dashboard</a>
</p>
{{end}}
</body>
</html>