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

umputun / stash / 20687247496

04 Jan 2026 03:54AM UTC coverage: 83.261% (-0.8%) from 84.072%
20687247496

Pull #58

github

umputun
fix(sse): address code review findings

- skip SSE endpoints in audit middleware (long-lived connections)
- split shutdown timeout between SSE and HTTP server
- fix duplicate topic for empty key in keyToTopics
- rename Auth to AuthProvider for consistency
- remove "failed to" prefix from error messages
- add Subscribe methods documentation to client README
Pull Request #58: feat(sse): add real-time key change subscriptions via Server-Sent Events

306 of 397 new or added lines in 11 files covered. (77.08%)

3 existing lines in 2 files now uncovered.

4039 of 4851 relevant lines covered (83.26%)

76.51 hits per line

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

86.03
/app/server/api/handler.go
1
// Package api provides HTTP handlers for the KV API.
2
package api
3

4
import (
5
        "context"
6
        "encoding/base64"
7
        "errors"
8
        "io"
9
        "net/http"
10
        "strings"
11
        "time"
12

13
        log "github.com/go-pkgz/lgr"
14
        "github.com/go-pkgz/rest"
15
        "github.com/go-pkgz/routegroup"
16

17
        "github.com/umputun/stash/app/enum"
18
        "github.com/umputun/stash/app/git"
19
        "github.com/umputun/stash/app/store"
20
        "github.com/umputun/stash/lib/stash"
21
)
22

23
//go:generate moq -out mocks/kvstore.go -pkg mocks -skip-ensure -fmt goimports . KVStore
24
//go:generate moq -out mocks/authprovider.go -pkg mocks -skip-ensure -fmt goimports . AuthProvider
25
//go:generate moq -out mocks/formatvalidator.go -pkg mocks -skip-ensure -fmt goimports . FormatValidator
26
//go:generate moq -out mocks/gitservice.go -pkg mocks -skip-ensure -fmt goimports . GitService
27
//go:generate moq -out mocks/eventpublisher.go -pkg mocks -skip-ensure -fmt goimports . EventPublisher
28

29
// Handler handles API requests for /kv/* endpoints.
30
type Handler struct {
31
        Deps
32
}
33

34
// GitService defines the interface for git operations.
35
type GitService interface {
36
        Commit(req git.CommitRequest) error
37
        Delete(key string, author git.Author) error
38
        History(key string, limit int) ([]git.HistoryEntry, error)
39
        GetRevision(key string, rev string) ([]byte, string, error)
40
}
41

42
// KVStore defines the interface for key-value storage operations.
43
type KVStore interface {
44
        Get(ctx context.Context, key string) ([]byte, error)
45
        GetWithFormat(ctx context.Context, key string) ([]byte, string, error)
46
        Set(ctx context.Context, key string, value []byte, format string) (created bool, err error)
47
        Delete(ctx context.Context, key string) error
48
        List(ctx context.Context, filter enum.SecretsFilter) ([]store.KeyInfo, error)
49
        SecretsEnabled() bool
50
}
51

52
// AuthProvider defines the interface for authentication operations.
53
type AuthProvider interface {
54
        Enabled() bool
55
        FilterKeysForRequest(r *http.Request, keys []string) []string
56
        GetRequestActor(r *http.Request) (actorType, actorName string)
57
}
58

59
// FormatValidator defines the interface for format validation.
60
type FormatValidator interface {
61
        IsValidFormat(format string) bool
62
}
63

64
// EventPublisher defines the interface for publishing key change events.
65
type EventPublisher interface {
66
        Publish(key string, action enum.AuditAction)
67
}
68

69
// Deps holds dependencies for the API handler.
70
type Deps struct {
71
        Store     KVStore
72
        Auth      AuthProvider
73
        Validator FormatValidator
74
        Git       GitService     // optional
75
        Events    EventPublisher // optional
76
}
77

78
// New creates a new API handler.
79
func New(deps Deps) *Handler {
39✔
80
        return &Handler{Deps: deps}
39✔
81
}
39✔
82

83
// Register registers API routes on the given router.
84
func (h *Handler) Register(r *routegroup.Bundle) {
×
85
        r.HandleFunc("GET /{$}", h.handleList)                 // list keys (must be before {key...})
×
86
        r.HandleFunc("GET /history/{key...}", h.handleHistory) // get key history (before generic key)
×
87
        r.HandleFunc("GET /{key...}", h.handleGet)             // get specific key
×
88
        r.HandleFunc("PUT /{key...}", h.handleSet)             // set key
×
89
        r.HandleFunc("DELETE /{key...}", h.handleDelete)
×
90
}
×
91

92
// handleList returns all keys the caller has read access to.
93
// GET /kv?prefix=app/config (filter by prefix)
94
// GET /kv?filter=secrets (filter to secrets only)
95
// GET /kv?filter=keys (filter to non-secrets only)
96
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
7✔
97
        // parse secrets filter query param
7✔
98
        filter := enum.SecretsFilterAll
7✔
99
        if filterParam := r.URL.Query().Get("filter"); filterParam != "" {
10✔
100
                parsed, err := enum.ParseSecretsFilter(filterParam)
3✔
101
                if err != nil {
4✔
102
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "invalid filter parameter")
1✔
103
                        return
1✔
104
                }
1✔
105
                filter = parsed
2✔
106
        }
107

108
        keys, err := h.Store.List(r.Context(), filter)
6✔
109
        if err != nil {
7✔
110
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to list keys")
1✔
111
                return
1✔
112
        }
1✔
113

114
        // extract key names for filtering
115
        keyNames := make([]string, len(keys))
5✔
116
        for i, k := range keys {
14✔
117
                keyNames[i] = k.Key
9✔
118
        }
9✔
119

120
        // filter by auth permissions
121
        filteredNames := h.filterKeysByAuth(r, keyNames)
5✔
122
        if filteredNames == nil {
5✔
123
                // no valid auth, but this shouldn't happen since tokenAuth middleware already checked
×
124
                rest.SendErrorJSON(w, r, log.Default(), http.StatusUnauthorized, nil, "unauthorized")
×
125
                return
×
126
        }
×
127

128
        // convert filtered names back to KeyInfo slice
129
        nameSet := make(map[string]bool, len(filteredNames))
5✔
130
        for _, name := range filteredNames {
14✔
131
                nameSet[name] = true
9✔
132
        }
9✔
133
        filtered := make([]store.KeyInfo, 0, len(filteredNames))
5✔
134
        for _, k := range keys {
14✔
135
                if nameSet[k.Key] {
18✔
136
                        filtered = append(filtered, k)
9✔
137
                }
9✔
138
        }
139

140
        // filter by prefix if specified
141
        prefix := r.URL.Query().Get("prefix")
5✔
142
        if prefix != "" {
6✔
143
                var prefixed []store.KeyInfo
1✔
144
                for _, k := range filtered {
4✔
145
                        if strings.HasPrefix(k.Key, prefix) {
5✔
146
                                prefixed = append(prefixed, k)
2✔
147
                        }
2✔
148
                }
149
                filtered = prefixed
1✔
150
        }
151

152
        log.Printf("[DEBUG] list keys: %d found, %d after auth filter", len(keys), len(filtered))
5✔
153
        rest.RenderJSON(w, filtered)
5✔
154
}
155

156
// filterKeysByAuth filters keys based on the request's authentication.
157
// Returns nil if auth is required but caller has no valid credentials.
158
func (h *Handler) filterKeysByAuth(r *http.Request, keys []string) []string {
12✔
159
        if h.Auth == nil || !h.Auth.Enabled() {
21✔
160
                return keys
9✔
161
        }
9✔
162
        return h.Auth.FilterKeysForRequest(r, keys)
3✔
163
}
164

165
// handleGet retrieves the value for a key.
166
// GET /kv/{key...}
167
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
5✔
168
        key := store.NormalizeKey(r.PathValue("key"))
5✔
169
        if key == "" {
6✔
170
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
171
                return
1✔
172
        }
1✔
173

174
        value, format, err := h.Store.GetWithFormat(r.Context(), key)
4✔
175
        if errors.Is(err, store.ErrSecretsNotConfigured) {
5✔
176
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "secrets not configured")
1✔
177
                return
1✔
178
        }
1✔
179
        if errors.Is(err, store.ErrNotFound) {
4✔
180
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
181
                return
1✔
182
        }
1✔
183
        if err != nil {
2✔
184
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get key")
×
185
                return
×
186
        }
×
187

188
        log.Printf("[DEBUG] get %s (%d bytes, format=%s)", key, len(value), format)
2✔
189

2✔
190
        w.Header().Set("Content-Type", h.formatToContentType(format))
2✔
191
        w.WriteHeader(http.StatusOK)
2✔
192
        if _, err := w.Write(value); err != nil {
2✔
193
                log.Printf("[WARN] failed to write response: %v", err)
×
194
        }
×
195
}
196

197
// formatToContentType maps storage format to HTTP Content-Type
198
func (h *Handler) formatToContentType(format string) string {
11✔
199
        if f, err := stash.ParseFormat(format); err == nil {
21✔
200
                return f.ContentType()
10✔
201
        }
10✔
202
        return "application/octet-stream"
1✔
203
}
204

205
// handleSet stores a value for a key.
206
// PUT /kv/{key...}
207
// accepts format via X-Stash-Format header or ?format= query param (defaults to "text")
208
func (h *Handler) handleSet(w http.ResponseWriter, r *http.Request) {
9✔
209
        key := store.NormalizeKey(r.PathValue("key"))
9✔
210
        if key == "" {
10✔
211
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
212
                return
1✔
213
        }
1✔
214

215
        value, err := io.ReadAll(r.Body)
8✔
216
        if err != nil {
8✔
217
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "failed to read body")
×
218
                return
×
219
        }
×
220

221
        // get format from header or query param, default to "text"
222
        format := r.Header.Get("X-Stash-Format")
8✔
223
        if format == "" {
14✔
224
                format = r.URL.Query().Get("format")
6✔
225
        }
6✔
226
        if !h.Validator.IsValidFormat(format) {
14✔
227
                format = "text"
6✔
228
        }
6✔
229

230
        created, err := h.Store.Set(r.Context(), key, value, format)
8✔
231
        if err != nil {
11✔
232
                if errors.Is(err, store.ErrSecretsNotConfigured) {
4✔
233
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "secrets not configured")
1✔
234
                        return
1✔
235
                }
1✔
236
                if errors.Is(err, store.ErrInvalidZKPayload) {
3✔
237
                        rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, err, "invalid ZK payload")
1✔
238
                        return
1✔
239
                }
1✔
240
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to set key")
1✔
241
                return
1✔
242
        }
243

244
        operation := "update"
5✔
245
        if created {
10✔
246
                operation = "create"
5✔
247
        }
5✔
248
        log.Printf("[INFO] %s %q (%d bytes, format=%s) by %s", operation, key, len(value), format, h.getIdentityForLog(r))
5✔
249

5✔
250
        // commit to git if enabled
5✔
251
        if h.Git != nil {
6✔
252
                req := git.CommitRequest{Key: key, Value: value, Operation: operation, Format: format, Author: h.getAuthorFromRequest(r)}
1✔
253
                if err := h.Git.Commit(req); err != nil {
1✔
254
                        log.Printf("[WARN] git commit failed for %s: %v", key, err)
×
255
                }
×
256
        }
257

258
        // publish event for SSE subscribers
259
        if h.Events != nil {
5✔
NEW
260
                action := enum.AuditActionUpdate
×
NEW
261
                if created {
×
NEW
262
                        action = enum.AuditActionCreate
×
NEW
263
                }
×
NEW
264
                h.Events.Publish(key, action)
×
265
        }
266

267
        if created {
10✔
268
                w.WriteHeader(http.StatusCreated)
5✔
269
        } else {
5✔
270
                w.WriteHeader(http.StatusOK)
×
271
        }
×
272
}
273

274
// handleDelete removes a key from the store.
275
// DELETE /kv/{key...}
276
func (h *Handler) handleDelete(w http.ResponseWriter, r *http.Request) {
5✔
277
        key := store.NormalizeKey(r.PathValue("key"))
5✔
278
        if key == "" {
6✔
279
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
280
                return
1✔
281
        }
1✔
282

283
        err := h.Store.Delete(r.Context(), key)
4✔
284
        if errors.Is(err, store.ErrNotFound) {
5✔
285
                rest.SendErrorJSON(w, r, log.Default(), http.StatusNotFound, err, "key not found")
1✔
286
                return
1✔
287
        }
1✔
288
        if err != nil {
4✔
289
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to delete key")
1✔
290
                return
1✔
291
        }
1✔
292

293
        log.Printf("[INFO] delete %q by %s", key, h.getIdentityForLog(r))
2✔
294

2✔
295
        // delete from git if enabled
2✔
296
        if h.Git != nil {
3✔
297
                if err := h.Git.Delete(key, h.getAuthorFromRequest(r)); err != nil {
1✔
298
                        log.Printf("[WARN] git delete failed for %s: %v", key, err)
×
299
                }
×
300
        }
301

302
        // publish event for SSE subscribers
303
        if h.Events != nil {
2✔
NEW
304
                h.Events.Publish(key, enum.AuditActionDelete)
×
NEW
305
        }
×
306

307
        w.WriteHeader(http.StatusNoContent)
2✔
308
}
309

310
// historyResponse represents a single entry in the history response.
311
type historyResponse struct {
312
        Hash      string `json:"hash"`
313
        Timestamp string `json:"timestamp"`
314
        Author    string `json:"author"`
315
        Operation string `json:"operation"`
316
        Format    string `json:"format"`
317
        Value     string `json:"value"` // base64 encoded
318
}
319

320
// handleHistory returns the commit history for a key.
321
// GET /kv/history/{key...}
322
func (h *Handler) handleHistory(w http.ResponseWriter, r *http.Request) {
6✔
323
        if h.Git == nil {
7✔
324
                rest.SendErrorJSON(w, r, log.Default(), http.StatusServiceUnavailable, nil, "git integration not enabled")
1✔
325
                return
1✔
326
        }
1✔
327

328
        key := store.NormalizeKey(r.PathValue("key"))
5✔
329
        if key == "" {
6✔
330
                rest.SendErrorJSON(w, r, log.Default(), http.StatusBadRequest, nil, "key is required")
1✔
331
                return
1✔
332
        }
1✔
333

334
        // check read permission
335
        filtered := h.filterKeysByAuth(r, []string{key})
4✔
336
        if len(filtered) == 0 {
5✔
337
                rest.SendErrorJSON(w, r, log.Default(), http.StatusForbidden, nil, "access denied")
1✔
338
                return
1✔
339
        }
1✔
340

341
        history, err := h.Git.History(key, 50)
3✔
342
        if err != nil {
4✔
343
                rest.SendErrorJSON(w, r, log.Default(), http.StatusInternalServerError, err, "failed to get history")
1✔
344
                return
1✔
345
        }
1✔
346

347
        // base64-encode values to safely transmit arbitrary binary data in JSON
348
        resp := make([]historyResponse, len(history))
2✔
349
        for i, entry := range history {
4✔
350
                resp[i] = historyResponse{
2✔
351
                        Hash:      entry.Hash,
2✔
352
                        Timestamp: entry.Timestamp.UTC().Format(time.RFC3339),
2✔
353
                        Author:    entry.Author,
2✔
354
                        Operation: entry.Operation,
2✔
355
                        Format:    entry.Format,
2✔
356
                        Value:     base64.StdEncoding.EncodeToString(entry.Value),
2✔
357
                }
2✔
358
        }
2✔
359

360
        rest.RenderJSON(w, resp)
2✔
361
}
362

363
// identityType represents the type of identity detected from a request.
364
type identityType int
365

366
const (
367
        identityAnonymous identityType = iota
368
        identityUser
369
        identityToken
370
)
371

372
// identity holds information about who made a request.
373
type identity struct {
374
        typ  identityType
375
        name string // username or token prefix
376
}
377

378
// getIdentity extracts identity from request.
379
// Returns user identity, token identity, or anonymous.
380
func (h *Handler) getIdentity(r *http.Request) identity {
20✔
381
        if h.Auth == nil || !h.Auth.Enabled() {
32✔
382
                return identity{typ: identityAnonymous}
12✔
383
        }
12✔
384

385
        actorType, actorName := h.Auth.GetRequestActor(r)
8✔
386
        switch actorType {
8✔
387
        case "user":
3✔
388
                return identity{typ: identityUser, name: actorName}
3✔
389
        case "token":
3✔
390
                return identity{typ: identityToken, name: actorName}
3✔
391
        default:
2✔
392
                return identity{typ: identityAnonymous}
2✔
393
        }
394
}
395

396
// getAuthorFromRequest extracts the git author from request context.
397
// returns username from session cookie for web UI users, token prefix for API tokens, default author otherwise.
398
func (h *Handler) getAuthorFromRequest(r *http.Request) git.Author {
5✔
399
        id := h.getIdentity(r)
5✔
400
        switch id.typ {
5✔
401
        case identityUser, identityToken:
2✔
402
                return git.Author{Name: id.name, Email: id.name + "@stash"}
2✔
403
        default:
3✔
404
                return git.DefaultAuthor()
3✔
405
        }
406
}
407

408
// getIdentityForLog returns identity string for audit logging.
409
// returns "user:xxx" for web UI users, "token:xxx" for API tokens, "anonymous" otherwise.
410
func (h *Handler) getIdentityForLog(r *http.Request) string {
10✔
411
        id := h.getIdentity(r)
10✔
412
        switch id.typ {
10✔
413
        case identityUser:
1✔
414
                return "user:" + id.name
1✔
415
        case identityToken:
1✔
416
                return id.name // already has "token:" prefix
1✔
417
        default:
8✔
418
                return "anonymous"
8✔
419
        }
420
}
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