• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

umputun / stash / 20473316693

23 Dec 2025 10:47PM UTC coverage: 83.53% (+0.04%) from 83.488%
20473316693

push

github

umputun
test(e2e): add secrets vault UI tests

- lock icon visibility for secret keys
- permission enforcement (user without secrets access)
- card view lock icon display
- scoped secrets access verification

3043 of 3643 relevant lines covered (83.53%)

83.05 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

77.92
/app/server/web/handler.go
1
// Package web provides HTTP handlers for the web UI.
2
package web
3

4
import (
5
        "context"
6
        "embed"
7
        "encoding/base64"
8
        "fmt"
9
        "html/template"
10
        "io/fs"
11
        "net/http"
12
        "net/url"
13
        "sort"
14
        "strconv"
15
        "strings"
16
        "time"
17
        "unicode/utf8"
18

19
        "github.com/go-pkgz/routegroup"
20

21
        "github.com/umputun/stash/app/enum"
22
        "github.com/umputun/stash/app/git"
23
        "github.com/umputun/stash/app/server/internal/cookie"
24
        "github.com/umputun/stash/app/store"
25
)
26

27
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
28
//go:generate moq -out mocks/validator.go -pkg mocks -skip-ensure -fmt goimports . Validator
29
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
30
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
31

32
//go:embed static
33
var staticFS embed.FS
34

35
//go:embed templates
36
var templatesFS embed.FS
37

38
// StaticFS returns the embedded static filesystem for external use.
39
func StaticFS() (fs.FS, error) {
×
40
        sub, err := fs.Sub(staticFS, "static")
×
41
        if err != nil {
×
42
                return nil, fmt.Errorf("failed to get static sub-filesystem: %w", err)
×
43
        }
×
44
        return sub, nil
×
45
}
46

47
// KVStore defines the interface for key-value storage operations.
48
type KVStore interface {
49
        GetWithFormat(ctx context.Context, key string) ([]byte, string, error)
50
        GetInfo(ctx context.Context, key string) (store.KeyInfo, error)
51
        Set(ctx context.Context, key string, value []byte, format string) error
52
        SetWithVersion(ctx context.Context, key string, value []byte, format string, expectedVersion time.Time) error
53
        Delete(ctx context.Context, key string) error
54
        List(ctx context.Context, filter enum.SecretsFilter) ([]store.KeyInfo, error)
55
        SecretsEnabled() bool
56
}
57

58
// Validator defines the interface for format validation.
59
type Validator interface {
60
        Validate(format string, value []byte) error
61
        IsValidFormat(format string) bool
62
        SupportedFormats() []string
63
}
64

65
// AuthProvider defines the interface for authentication operations.
66
type AuthProvider interface {
67
        Enabled() bool
68
        GetSessionUser(ctx context.Context, token string) (string, bool)
69
        FilterUserKeys(username string, keys []string) []string
70
        CheckUserPermission(username, key string, write bool) bool
71
        UserCanWrite(username string) bool
72

73
        IsValidUser(username, password string) bool
74
        CreateSession(ctx context.Context, username string) (string, error)
75
        InvalidateSession(ctx context.Context, token string)
76
        LoginTTL() time.Duration
77
}
78

79
// GitService defines the interface for git operations.
80
type GitService interface {
81
        Commit(req git.CommitRequest) error
82
        Delete(key string, author git.Author) error
83
        History(key string, limit int) ([]git.HistoryEntry, error)
84
        GetRevision(key string, rev string) ([]byte, string, error)
85
}
86

87
// Config holds web handler configuration.
88
type Config struct {
89
        BaseURL  string
90
        PageSize int
91
}
92

93
// Handler handles web UI requests.
94
type Handler struct {
95
        store       KVStore
96
        validator   Validator
97
        auth        AuthProvider
98
        highlighter *Highlighter
99
        tmpl        *template.Template
100
        baseURL     string
101
        pageSize    int
102
        git         GitService
103
}
104

105
// New creates a new web handler.
106
func New(st KVStore, auth AuthProvider, val Validator, gs GitService, cfg Config) (*Handler, error) {
72✔
107
        tmpl, err := parseTemplates()
72✔
108
        if err != nil {
72✔
109
                return nil, fmt.Errorf("failed to parse templates: %w", err)
×
110
        }
×
111

112
        return &Handler{
72✔
113
                store:       st,
72✔
114
                validator:   val,
72✔
115
                auth:        auth,
72✔
116
                highlighter: NewHighlighter(),
72✔
117
                tmpl:        tmpl,
72✔
118
                baseURL:     cfg.BaseURL,
72✔
119
                pageSize:    cfg.PageSize,
72✔
120
                git:         gs,
72✔
121
        }, nil
72✔
122
}
123

124
// Register registers web UI routes on the given router.
125
func (h *Handler) Register(r *routegroup.Bundle) {
×
126
        r.HandleFunc("GET /{$}", h.handleIndex)
×
127
        r.HandleFunc("GET /web/keys", h.handleKeyList)
×
128
        r.HandleFunc("GET /web/keys/new", h.handleKeyNew)
×
129
        r.HandleFunc("GET /web/keys/view/{key...}", h.handleKeyView)
×
130
        r.HandleFunc("GET /web/keys/edit/{key...}", h.handleKeyEdit)
×
131
        r.HandleFunc("GET /web/keys/history/{key...}", h.handleKeyHistory)
×
132
        r.HandleFunc("GET /web/keys/revision/{key...}", h.handleKeyRevision)
×
133
        r.HandleFunc("POST /web/keys/restore/{key...}", h.handleKeyRestore)
×
134
        r.HandleFunc("POST /web/keys", h.handleKeyCreate)
×
135
        r.HandleFunc("PUT /web/keys/{key...}", h.handleKeyUpdate)
×
136
        r.HandleFunc("DELETE /web/keys/{key...}", h.handleKeyDelete)
×
137
        r.HandleFunc("POST /web/theme", h.handleThemeToggle)
×
138
        r.HandleFunc("POST /web/view-mode", h.handleViewModeToggle)
×
139
        r.HandleFunc("POST /web/sort", h.handleSortToggle)
×
140
}
×
141

142
// RegisterAuth registers auth routes (login/logout) on the given router.
143
func (h *Handler) RegisterAuth(r *routegroup.Bundle) {
×
144
        r.HandleFunc("GET /login", h.handleLoginForm)
×
145
        r.HandleFunc("POST /logout", h.handleLogout)
×
146
}
×
147

148
// RegisterLogin registers the login POST handler with custom middleware.
149
func (h *Handler) RegisterLogin(r *routegroup.Bundle, middleware func(http.Handler) http.Handler) {
×
150
        r.Handle("POST /login", middleware(http.HandlerFunc(h.handleLogin)))
×
151
}
×
152

153
// templateFuncs returns custom template functions.
154
func templateFuncs() template.FuncMap {
72✔
155
        // sortModeLabel returns a human-readable label for the sort mode.
72✔
156
        sortModeLabel := func(mode enum.SortMode) string {
91✔
157
                s := mode.String()
19✔
158
                return strings.ToUpper(s[:1]) + s[1:]
19✔
159
        }
19✔
160

161
        return template.FuncMap{
72✔
162
                "formatTime": func(t time.Time) string {
93✔
163
                        return t.Format("2006-01-02 15:04")
21✔
164
                },
21✔
165
                "formatSize": func(size int) string {
10✔
166
                        if size < 1024 {
20✔
167
                                return strconv.Itoa(size) + " B"
10✔
168
                        }
10✔
169
                        if size < 1024*1024 {
×
170
                                return strconv.FormatFloat(float64(size)/1024, 'f', 1, 64) + " KB"
×
171
                        }
×
172
                        return strconv.FormatFloat(float64(size)/(1024*1024), 'f', 1, 64) + " MB"
×
173
                },
174
                "urlEncode":     url.PathEscape,
175
                "sortModeLabel": sortModeLabel,
176
                "add":           func(a, b int) int { return a + b },
4✔
177
                "sub":           func(a, b int) int { return a - b },
2✔
178
        }
179
}
180

181
// parseTemplates parses all templates from embedded filesystem.
182
func parseTemplates() (*template.Template, error) {
72✔
183
        tmpl := template.New("").Funcs(templateFuncs())
72✔
184

72✔
185
        // parse base template
72✔
186
        baseContent, err := templatesFS.ReadFile("templates/base.html")
72✔
187
        if err != nil {
72✔
188
                return nil, fmt.Errorf("read base.html: %w", err)
×
189
        }
×
190
        tmpl, err = tmpl.Parse(string(baseContent))
72✔
191
        if err != nil {
72✔
192
                return nil, fmt.Errorf("parse base.html: %w", err)
×
193
        }
×
194

195
        // parse login template
196
        loginContent, err := templatesFS.ReadFile("templates/login.html")
72✔
197
        if err != nil {
72✔
198
                return nil, fmt.Errorf("read login.html: %w", err)
×
199
        }
×
200
        _, err = tmpl.New("login.html").Parse(string(loginContent))
72✔
201
        if err != nil {
72✔
202
                return nil, fmt.Errorf("parse login.html: %w", err)
×
203
        }
×
204

205
        // parse index template
206
        indexContent, err := templatesFS.ReadFile("templates/index.html")
72✔
207
        if err != nil {
72✔
208
                return nil, fmt.Errorf("read index.html: %w", err)
×
209
        }
×
210
        _, err = tmpl.New("index.html").Parse(string(indexContent))
72✔
211
        if err != nil {
72✔
212
                return nil, fmt.Errorf("parse index.html: %w", err)
×
213
        }
×
214

215
        // parse partials
216
        partials := []string{"keys-table", "form", "view", "history", "revision"}
72✔
217
        for _, name := range partials {
432✔
218
                content, readErr := templatesFS.ReadFile("templates/partials/" + name + ".html")
360✔
219
                if readErr != nil {
360✔
220
                        return nil, fmt.Errorf("read partial %s: %w", name, readErr)
×
221
                }
×
222
                _, parseErr := tmpl.New(name).Parse(string(content))
360✔
223
                if parseErr != nil {
360✔
224
                        return nil, fmt.Errorf("parse partial %s: %w", name, parseErr)
×
225
                }
×
226
        }
227

228
        return tmpl, nil
72✔
229
}
230

231
// keyWithPermission wraps KeyInfo with per-key write permission.
232
type keyWithPermission struct {
233
        store.KeyInfo
234
        CanWrite bool // user has write permission for this specific key
235
}
236

237
// templateData holds data passed to templates.
238
type templateData struct {
239
        Keys           []keyWithPermission
240
        Key            string
241
        Value          string
242
        HighlightedVal template.HTML // syntax-highlighted value for view modal
243
        Format         string        // format type (text, json, yaml, etc.)
244
        Formats        []string      // available format options
245
        IsBinary       bool
246
        IsNew          bool
247
        Theme          enum.Theme
248
        ViewMode       enum.ViewMode
249
        SortMode       enum.SortMode
250
        Search         string
251
        Error          string
252
        CanForce       bool // allow force submit despite error (for validation errors, not conflicts)
253
        AuthEnabled    bool
254
        BaseURL        string
255
        ModalWidth     int
256
        TextareaHeight int
257
        CanWrite       bool   // user has write permission (for showing edit controls)
258
        Username       string // current logged-in username
259

260
        // conflict detection fields
261
        UpdatedAt       int64  // unix timestamp when key was loaded (for optimistic locking)
262
        Conflict        bool   // true when a conflict was detected on save
263
        ServerValue     string // current server value (shown during conflict)
264
        ServerFormat    string // current server format (shown during conflict)
265
        ServerUpdatedAt int64  // server's updated_at timestamp (for retry after conflict)
266

267
        // pagination fields
268
        Page       int  // current page (1-based)
269
        TotalPages int  // total number of pages
270
        TotalKeys  int  // total keys after filtering (before pagination)
271
        HasPrev    bool // has previous page
272
        HasNext    bool // has next page
273

274
        // history fields
275
        GitEnabled bool               // git integration enabled
276
        History    []git.HistoryEntry // commit history entries
277
        RevHash    string             // specific revision hash being viewed
278
}
279

280
// getTheme returns the current theme from cookie, defaulting to system.
281
func (h *Handler) getTheme(r *http.Request) enum.Theme {
32✔
282
        if c, err := r.Cookie("theme"); err == nil {
37✔
283
                if theme, err := enum.ParseTheme(c.Value); err == nil {
9✔
284
                        return theme
4✔
285
                }
4✔
286
        }
287
        return enum.ThemeSystem
28✔
288
}
289

290
// getViewMode returns the current view mode from cookie, defaulting to grid.
291
func (h *Handler) getViewMode(r *http.Request) enum.ViewMode {
23✔
292
        if c, err := r.Cookie("view_mode"); err == nil {
30✔
293
                if mode, err := enum.ParseViewMode(c.Value); err == nil {
13✔
294
                        return mode
6✔
295
                }
6✔
296
        }
297
        return enum.ViewModeGrid
17✔
298
}
299

300
// getSortMode returns the current sort mode from cookie, defaulting to updated.
301
func (h *Handler) getSortMode(r *http.Request) enum.SortMode {
26✔
302
        if c, err := r.Cookie("sort_mode"); err == nil {
39✔
303
                if mode, err := enum.ParseSortMode(c.Value); err == nil {
25✔
304
                        return mode
12✔
305
                }
12✔
306
        }
307
        return enum.SortModeUpdated
14✔
308
}
309

310
// url returns a URL path with the base URL prefix.
311
func (h *Handler) url(path string) string {
7✔
312
        return h.baseURL + path
7✔
313
}
7✔
314

315
// cookiePath returns the path for cookies (base URL with trailing slash or "/").
316
func (h *Handler) cookiePath() string {
16✔
317
        if h.baseURL == "" {
30✔
318
                return "/"
14✔
319
        }
14✔
320
        return h.baseURL + "/"
2✔
321
}
322

323
// getCurrentUser returns the username from the session cookie, or empty string if not logged in.
324
func (h *Handler) getCurrentUser(r *http.Request) string {
60✔
325
        for _, cookieName := range cookie.SessionCookieNames {
180✔
326
                if c, err := r.Cookie(cookieName); err == nil {
124✔
327
                        if username, ok := h.auth.GetSessionUser(r.Context(), c.Value); ok {
6✔
328
                                return username
2✔
329
                        }
2✔
330
                }
331
        }
332
        return ""
58✔
333
}
334

335
// getIdentityForLog returns identity string for audit logging.
336
// returns "user:xxx" for authenticated users, "anonymous" otherwise.
337
func (h *Handler) getIdentityForLog(r *http.Request) string {
7✔
338
        if username := h.getCurrentUser(r); username != "" {
8✔
339
                return "user:" + username
1✔
340
        }
1✔
341
        return "anonymous"
6✔
342
}
343

344
// getAuthor returns git author for the given username.
345
func (h *Handler) getAuthor(username string) git.Author {
3✔
346
        if username == "" {
4✔
347
                return git.DefaultAuthor()
1✔
348
        }
1✔
349
        return git.Author{Name: username, Email: username + "@stash"}
2✔
350
}
351

352
// sortByMode sorts a slice by the given mode using a key accessor.
353
func (h *Handler) sortByMode(keys []keyWithPermission, mode enum.SortMode) {
20✔
354
        switch mode {
20✔
355
        case enum.SortModeKey:
2✔
356
                sort.Slice(keys, func(i, j int) bool {
5✔
357
                        return strings.ToLower(keys[i].Key) < strings.ToLower(keys[j].Key)
3✔
358
                })
3✔
359
        case enum.SortModeSize:
2✔
360
                sort.Slice(keys, func(i, j int) bool {
5✔
361
                        return keys[i].Size > keys[j].Size // largest first
3✔
362
                })
3✔
363
        case enum.SortModeCreated:
2✔
364
                sort.Slice(keys, func(i, j int) bool {
5✔
365
                        return keys[i].CreatedAt.After(keys[j].CreatedAt) // newest first
3✔
366
                })
3✔
367
        default: // updated
14✔
368
                sort.Slice(keys, func(i, j int) bool {
36✔
369
                        return keys[i].UpdatedAt.After(keys[j].UpdatedAt) // newest first
22✔
370
                })
22✔
371
        }
372
}
373

374
// valueForDisplay converts a byte slice to a display string, detecting binary content.
375
func (h *Handler) valueForDisplay(value []byte) (string, bool) {
7✔
376
        if !utf8.Valid(value) {
8✔
377
                return base64.StdEncoding.EncodeToString(value), true
1✔
378
        }
1✔
379
        return string(value), false
6✔
380
}
381

382
// valueFromForm converts form input back to bytes, handling binary encoding.
383
func (h *Handler) valueFromForm(value string, isBinary bool) ([]byte, error) {
13✔
384
        if isBinary {
17✔
385
                decoded, err := base64.StdEncoding.DecodeString(value)
4✔
386
                if err != nil {
7✔
387
                        return nil, fmt.Errorf("decode base64: %w", err)
3✔
388
                }
3✔
389
                return decoded, nil
1✔
390
        }
391
        return []byte(value), nil
9✔
392
}
393

394
// filterBySearch filters keys by search term.
395
func (h *Handler) filterBySearch(keys []keyWithPermission, search string) []keyWithPermission {
17✔
396
        if search == "" {
30✔
397
                return keys
13✔
398
        }
13✔
399
        search = strings.ToLower(search)
4✔
400
        var filtered []keyWithPermission
4✔
401
        for _, k := range keys {
15✔
402
                if strings.Contains(strings.ToLower(k.Key), search) {
16✔
403
                        filtered = append(filtered, k)
5✔
404
                }
5✔
405
        }
406
        return filtered
4✔
407
}
408

409
// paginateResult holds the result of pagination.
410
type paginateResult struct {
411
        keys       []keyWithPermission
412
        page       int
413
        totalPages int
414
        hasPrev    bool
415
        hasNext    bool
416
}
417

418
// paginate applies pagination to a slice of keys and returns pagination info.
419
// page is 1-based, pageSize is the max keys per page.
420
func (h *Handler) paginate(keys []keyWithPermission, page, pageSize int) paginateResult {
26✔
421
        total := len(keys)
26✔
422
        if pageSize <= 0 {
41✔
423
                return paginateResult{keys: keys, page: 1, totalPages: 1}
15✔
424
        }
15✔
425

426
        totalPages := (total + pageSize - 1) / pageSize
11✔
427
        if totalPages == 0 {
12✔
428
                totalPages = 1
1✔
429
        }
1✔
430
        if page < 1 {
13✔
431
                page = 1
2✔
432
        }
2✔
433
        if page > totalPages {
12✔
434
                page = totalPages
1✔
435
        }
1✔
436

437
        start := (page - 1) * pageSize
11✔
438
        end := start + pageSize
11✔
439
        if start >= total {
12✔
440
                return paginateResult{page: page, totalPages: totalPages, hasPrev: page > 1}
1✔
441
        }
1✔
442
        if end > total {
13✔
443
                end = total
3✔
444
        }
3✔
445
        return paginateResult{keys: keys[start:end], page: page, totalPages: totalPages, hasPrev: page > 1, hasNext: page < totalPages}
10✔
446
}
447

448
// filterKeysByPermission filters keys based on user permissions and wraps with write permission info.
449
func (h *Handler) filterKeysByPermission(username string, keys []store.KeyInfo) []keyWithPermission {
19✔
450
        keyNames := make([]string, len(keys))
19✔
451
        for i, k := range keys {
53✔
452
                keyNames[i] = k.Key
34✔
453
        }
34✔
454
        allowedKeys := h.auth.FilterUserKeys(username, keyNames)
19✔
455
        allowedSet := make(map[string]bool, len(allowedKeys))
19✔
456
        for _, k := range allowedKeys {
49✔
457
                allowedSet[k] = true
30✔
458
        }
30✔
459
        var filtered []keyWithPermission
19✔
460
        for _, k := range keys {
53✔
461
                if allowedSet[k.Key] {
64✔
462
                        filtered = append(filtered, keyWithPermission{
30✔
463
                                KeyInfo:  k,
30✔
464
                                CanWrite: h.auth.CheckUserPermission(username, k.Key, true),
30✔
465
                        })
30✔
466
                }
30✔
467
        }
468
        return filtered
19✔
469
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc