Add separate production, testserver, and demo deployments
CI / test (push) Failing after 10s

This commit is contained in:
2026-09-14 19:58:31 +02:00
parent b87aa0aa17
commit d06ff4a94a
37 changed files with 1293 additions and 121 deletions
+1
View File
@@ -25,6 +25,7 @@ func (app *application) showAdminEnvironmentHandler(w http.ResponseWriter, r *ht
{Component: "API", Name: "GARDOMATIC_API_HOST", Value: app.config.Host},
{Component: "API", Name: "GARDOMATIC_API_PORT", Value: fmt.Sprint(app.config.Port)},
{Component: "API", Name: "GARDOMATIC_WEB_BASE_URL", Value: app.config.WebBaseURL},
{Component: "API", Name: "GARDOMATIC_DEMO_ACCOUNT_EMAIL", Value: app.config.DemoAccountEmail},
{Component: "API", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.Session.CookieName},
{Component: "API", Name: "GARDOMATIC_SESSION_LIFETIME", Value: app.config.Session.Lifetime.String()},
{Component: "API", Name: "GARDOMATIC_SESSION_IDLE_TIMEOUT", Value: app.config.Session.IdleTimeout.String()},
+10 -9
View File
@@ -28,15 +28,16 @@ var (
// Config contains API server, database, session, mail, and security settings.
type Config struct {
Host string
Port int
Env string
WebBaseURL string
DB DatabaseConfig
Limiter LimiterConfig
Session SessionConfig
Mail mailer.Config
Cors CORSConfig
Host string
Port int
Env string
WebBaseURL string
DemoAccountEmail string
DB DatabaseConfig
Limiter LimiterConfig
Session SessionConfig
Mail mailer.Config
Cors CORSConfig
}
// DatabaseConfig controls the PostgreSQL connection pool.
+1 -1
View File
@@ -76,7 +76,7 @@ func TestShowAdminEnvironmentMasksSecrets(t *testing.T) {
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} {
for _, name := range []string{"GARDOMATIC_DB_DSN", "GARDOMATIC_DEMO_ACCOUNT_EMAIL", "GARDOMATIC_SMTP_PASSWORD", "GARDOMATIC_SMTP_SENDER"} {
found := false
for _, variable := range result.Variables {
found = found || variable.Name == name
+82
View File
@@ -0,0 +1,82 @@
package api
import (
"bytes"
"net/http"
"net/http/httptest"
"strings"
"testing"
"gardomatic.kleiax.de/internal/storage"
)
type demoAccountTokenUserModel struct {
sessionTestUserModel
}
func (m demoAccountTokenUserModel) GetForToken(string, string) (storage.User, error) {
return m.user, nil
}
func TestRequireMutableAccountProtectsConfiguredDemoUser(t *testing.T) {
app := &application{config: Config{DemoAccountEmail: "demo@example.com"}}
called := false
handler := app.requireMutableAccount(func(w http.ResponseWriter, _ *http.Request) {
called = true
w.WriteHeader(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPatch, "/v1/account", nil)
request = app.contextSetAuthenticatedUser(request, storage.User{Email: "Demo@Example.com"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusForbidden || called {
t.Fatalf("protected account: got status %d and called=%t, want 403 and called=false", response.Code, called)
}
}
func TestRequireMutableAccountAllowsOtherUsers(t *testing.T) {
app := &application{config: Config{DemoAccountEmail: "demo@example.com"}}
handler := app.requireMutableAccount(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
})
request := httptest.NewRequest(http.MethodPatch, "/v1/account", nil)
request = app.contextSetAuthenticatedUser(request, storage.User{Email: "alice@example.com"})
response := httptest.NewRecorder()
handler.ServeHTTP(response, request)
if response.Code != http.StatusNoContent {
t.Fatalf("ordinary account: got status %d, want %d", response.Code, http.StatusNoContent)
}
}
func TestPasswordResetCannotChangeProtectedDemoAccount(t *testing.T) {
app := &application{
config: Config{DemoAccountEmail: "demo@example.com"},
models: storage.Models{Users: demoAccountTokenUserModel{sessionTestUserModel: sessionTestUserModel{
user: storage.User{ID: 42, Email: "demo@example.com", Activated: true},
}}},
}
t.Run("request token", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPost, "/v1/tokens/password-reset", bytes.NewBufferString(`{"email":"DEMO@example.com"}`))
response := httptest.NewRecorder()
app.createPasswordResetTokenHandler(response, request)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "shared demo account") {
t.Fatalf("got status %d and body %q, want protected-account response", response.Code, response.Body.String())
}
})
t.Run("use existing token", func(t *testing.T) {
request := httptest.NewRequest(http.MethodPut, "/v1/users/password", bytes.NewBufferString(`{"password":"a-new-demo-password","token":"abcdefghijklmnopqrstuvwxyz"}`))
response := httptest.NewRecorder()
app.updateUserPasswordHandler(response, request)
if response.Code != http.StatusForbidden || !strings.Contains(response.Body.String(), "shared demo account") {
t.Fatalf("got status %d and body %q, want protected-account response", response.Code, response.Body.String())
}
})
}
+5
View File
@@ -93,6 +93,11 @@ func (app *application) permissionDeniedResponse(w http.ResponseWriter, r *http.
app.errorResponse(w, r, http.StatusForbidden, message)
}
func (app *application) demoAccountProtectedResponse(w http.ResponseWriter, r *http.Request) {
message := "the shared demo account cannot be changed"
app.errorResponse(w, r, http.StatusForbidden, message)
}
func (app *application) untrustedOriginResponse(w http.ResponseWriter, r *http.Request) {
message := "the request origin is not trusted"
app.errorResponse(w, r, http.StatusForbidden, message)
+20
View File
@@ -164,6 +164,26 @@ func (app *application) requireActivatedUser(next http.HandlerFunc) http.Handler
})
}
func (app *application) requireMutableAccount(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, found := app.contextGetAuthenticatedUser(r)
if !found {
app.authenticationRequiredResponse(w, r)
return
}
if app.isProtectedDemoAccount(user.Email) {
app.demoAccountProtectedResponse(w, r)
return
}
next.ServeHTTP(w, r)
})
}
func (app *application) isProtectedDemoAccount(email string) bool {
return app.config.DemoAccountEmail != "" && strings.EqualFold(strings.TrimSpace(email), app.config.DemoAccountEmail)
}
func (app *application) requireGardenMember(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authenticatedUser, found := app.contextGetAuthenticatedUser(r)
+5 -5
View File
@@ -19,12 +19,12 @@ func (app *application) routes() http.Handler {
router.HandlerFunc(http.MethodPost, "/v1/users", app.registerUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/activated", app.activateUserHandler)
router.HandlerFunc(http.MethodPut, "/v1/users/password", app.updateUserPasswordHandler)
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.updateAccountProfileHandler))
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.updateAccountPasswordHandler))
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requestAccountEmailChangeHandler))
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.confirmAccountEmailHandler))
router.HandlerFunc(http.MethodPatch, "/v1/account", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountProfileHandler)))
router.HandlerFunc(http.MethodPut, "/v1/account/password", app.requireActivatedUser(app.requireMutableAccount(app.updateAccountPasswordHandler)))
router.HandlerFunc(http.MethodPost, "/v1/account/email", app.requireActivatedUser(app.requireMutableAccount(app.requestAccountEmailChangeHandler)))
router.HandlerFunc(http.MethodPost, "/v1/account/email/confirm", app.requireActivatedUser(app.requireMutableAccount(app.confirmAccountEmailHandler)))
router.HandlerFunc(http.MethodGet, "/v1/account/sessions", app.requireActivatedUser(app.listAccountSessionsHandler))
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.deleteAccountSessionHandler))
router.HandlerFunc(http.MethodDelete, "/v1/account/sessions/:sessionID", app.requireActivatedUser(app.requireMutableAccount(app.deleteAccountSessionHandler)))
router.HandlerFunc(http.MethodGet, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.listAdminUsersHandler)))
router.HandlerFunc(http.MethodPost, "/v1/admin/users", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.inviteAdminUserHandler)))
router.HandlerFunc(http.MethodPatch, "/v1/admin/users/:id", app.requireActivatedUser(app.requireApplicationPermission(storage.ApplicationPermissionUsersManage, app.updateAdminUserRoleHandler)))
+4
View File
@@ -83,6 +83,10 @@ func (app *application) createPasswordResetTokenHandler(w http.ResponseWriter, r
app.failedValidationResponse(w, r, v.Errors)
return
}
if app.isProtectedDemoAccount(input.Email) {
app.demoAccountProtectedResponse(w, r)
return
}
user, err := app.models.Users.GetByEmail(input.Email)
if err != nil {
+4
View File
@@ -177,6 +177,10 @@ func (app *application) updateUserPasswordHandler(w http.ResponseWriter, r *http
}
return
}
if app.isProtectedDemoAccount(user.Email) {
app.demoAccountProtectedResponse(w, r)
return
}
err = user.Password.Set(input.Password)
if err != nil {
+10 -5
View File
@@ -141,15 +141,20 @@ func (app *application) accountEmailConfirmPost(w http.ResponseWriter, r *http.R
func (app *application) renderAccount(w http.ResponseWriter, r *http.Request, form accountForm, status int) {
data := app.newTemplateData(r)
data.Form = form
if user, ok := userFromContext(r.Context()); ok {
data.AccountProtected = app.config.DemoAccountEmail != "" && strings.EqualFold(strings.TrimSpace(user.Email), app.config.DemoAccountEmail)
}
if !app.loadOptionalGarden(w, r, data) {
return
}
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
if !data.AccountProtected {
sessions, _, err := client.FromContext(r.Context()).AccountSessions(r.Context())
if err != nil {
app.handleAPIError(w, r, err)
return
}
data.AccountSessions = sessions
}
data.AccountSessions = sessions
app.render(w, status, "account.tmpl", data)
}
func (app *application) copyAccountError(form *accountForm, err error) {
+1
View File
@@ -204,6 +204,7 @@ func (app *application) webEnvironmentVariables() []client.EnvironmentVariable {
{Component: "Web", Name: "GARDOMATIC_WEB_HOST", Value: app.config.Host},
{Component: "Web", Name: "GARDOMATIC_WEB_PORT", Value: fmt.Sprint(app.config.Port)},
{Component: "Web", Name: "GARDOMATIC_API_BASE_URL", Value: app.config.APIBaseURL},
{Component: "Web", Name: "GARDOMATIC_DEMO_ACCOUNT_EMAIL", Value: app.config.DemoAccountEmail},
{Component: "Web", Name: "GARDOMATIC_SESSION_COOKIE_NAME", Value: app.config.SessionCookieName},
{Component: "Web", Name: "GARDOMATIC_COOKIE_SECURE", Value: fmt.Sprint(app.config.CookieSecure)},
}
+4 -1
View File
@@ -47,7 +47,10 @@ type CommonTemplateData struct {
}
// AccountTemplateData contains account-management page data.
type AccountTemplateData struct{ AccountSessions []client.AccountSession }
type AccountTemplateData struct {
AccountSessions []client.AccountSession
AccountProtected bool
}
// AdminTemplateData contains application-administration page data.
type AdminTemplateData struct {
@@ -1,8 +1,10 @@
{{define "title"}}Benutzerkonto{{end}}
{{define "main"}}
<header class='page-heading'><div><p class='eyebrow'>Persönlich</p><h2>Benutzerkonto</h2></div></header>{{$form:=.Form}}{{with $form.Message}}<p class='form-message'>{{.}}</p>{{end}}
{{if .AccountProtected}}<section class='panel'><h3>Gemeinsames Demokonto</h3><p>E-Mail-Adresse, Passwort, Profil und aktive Sitzungen sind geschützt, damit alle Besucher weiterhin Zugang zur Demo haben. Die Garteninhalte kannst du frei ausprobieren; sie werden jede Nacht zurückgesetzt.</p></section>{{else}}
<div class='account-grid'><section class='panel'><h3>Profil</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.profile") "garden" .ID}}{{else}}{{webPath "account.profile"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='name'>Name</label><input id='name' name='name' value='{{$form.Name}}' required>{{with index $form.Errors "name"}}<p class='field-error'>{{.}}</p>{{end}}<label for='color'>Farbe für deine Einträge</label><input id='color' name='color' type='color' value='{{$form.Color}}' required><p class='muted'>Diese Farbe wird verwendet, um deine Tagebucheinträge schnell zu erkennen.</p>{{with index $form.Errors "color"}}<p class='field-error'>{{.}}</p>{{end}}<button>Profil speichern</button></form></section>
<section class='panel'><h3>E-Mail-Adresse</h3><p>Aktuell: {{.CurrentUser.Email}}</p><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.email") "garden" .ID}}{{else}}{{webPath "account.email"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='email'>Neue E-Mail-Adresse</label><input id='email' type='email' name='email' value='{{$form.Email}}' required><label for='email-password'>Aktuelles Passwort</label><input id='email-password' type='password' name='current_password' required>{{with index $form.Errors "email"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Bestätigung senden</button></form></section>
<section class='panel'><h3>Passwort</h3><form method='POST' action='{{with .Garden}}{{pathWithQuery (webPath "account.password") "garden" .ID}}{{else}}{{webPath "account.password"}}{{end}}'><input type='hidden' name='csrf_token' value='{{.CSRFToken}}'><label for='current-password'>Aktuelles Passwort</label><input id='current-password' type='password' name='current_password' required><label for='new-password'>Neues Passwort</label><input id='new-password' type='password' name='new_password' minlength='8' required>{{with index $form.Errors "new_password"}}<p class='field-error'>{{.}}</p>{{end}}<label for='confirm-password'>Neues Passwort bestätigen</label><input id='confirm-password' type='password' name='new_password_confirmation' required>{{with index $form.Errors "new_password_confirmation"}}<p class='field-error'>{{.}}</p>{{end}}{{with index $form.Errors "current_password"}}<p class='field-error'>{{.}}</p>{{end}}<button>Passwort ändern</button></form></section>
<section class='panel'><h3>Aktive Sitzungen</h3>{{if .AccountSessions}}{{range .AccountSessions}}{{$session := .}}<div class='member-row'><div><strong>{{if .Current}}Dieses Gerät{{else}}Weitere Sitzung{{end}}</strong><br><span class='muted'>Angemeldet: {{humanDate .CreatedAt}} · gültig bis {{humanDate .ExpiresAt}}</span></div><form action='{{with $.Garden}}{{pathWithQuery (webPath "account.session.delete" $session.ID) "garden" .ID}}{{else}}{{webPath "account.session.delete" .ID}}{{end}}' method='POST'><input type='hidden' name='csrf_token' value='{{$.CSRFToken}}'><button class='danger'>Widerrufen</button></form></div>{{end}}{{else}}<p>Keine aktiven Sitzungen gefunden.</p>{{end}}</section></div>
{{end}}
{{end}}
+21
View File
@@ -128,6 +128,27 @@ func TestGardenContextIsKeptInAccountAndSettingsLinks(t *testing.T) {
}
}
func TestProtectedDemoAccountHidesAccountMutationForms(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Demo", Email: "demo@example.com", Activated: true}
data := &templateData{
commonTemplateData: commonTemplateData{IsAuthenticated: true, IsActivated: true, CurrentUser: &user, Form: accountForm{Errors: map[string]string{}}},
accountTemplateData: accountTemplateData{AccountProtected: true},
}
response := httptest.NewRecorder()
app.render(response, http.StatusOK, "account.tmpl", data)
body := response.Body.String()
if !strings.Contains(body, "Gemeinsames Demokonto") {
t.Fatalf("protected account notice is missing: %s", body)
}
for _, forbidden := range []string{"action='/account/profile'", "action='/account/password'", "action='/account/email'", "Widerrufen"} {
if strings.Contains(body, forbidden) {
t.Errorf("protected account still offers %q: %s", forbidden, body)
}
}
}
func TestAdminTemplateKeepsSelectedGardenInNavigationAndForms(t *testing.T) {
app := newTestApplication(t)
user := client.User{ID: 1, Name: "Alice", Activated: true, Permissions: []string{"roles:manage"}}
+2 -1
View File
@@ -15,8 +15,8 @@ import (
"syscall"
"time"
"gardomatic.kleiax.de/lib/client"
"gardomatic.kleiax.de/internal/vcs"
"gardomatic.kleiax.de/lib/client"
"github.com/go-playground/form/v4"
)
@@ -30,6 +30,7 @@ type Config struct {
Port int
Env string
APIBaseURL string
DemoAccountEmail string
SessionCookieName string
CookieSecure bool
}