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 {