@x47base/pocketbase-addon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (117) hide show
  1. package/.dockerignore +9 -0
  2. package/Dockerfile +26 -0
  3. package/FEATURES.md +32 -0
  4. package/LICENSE.md +17 -0
  5. package/MIGRATION.md +49 -0
  6. package/NOTICE.md +5 -0
  7. package/README.md +26 -0
  8. package/adapter.go +31 -0
  9. package/admin/register.go +60 -0
  10. package/admin/register_test.go +37 -0
  11. package/backups/backup_encryption_test.go +82 -0
  12. package/backups/encryption.go +138 -0
  13. package/backups/integration_test.go +51 -0
  14. package/backups/register.go +120 -0
  15. package/backups/restore.go +119 -0
  16. package/backups/s3_test.go +52 -0
  17. package/backups/swap.go +53 -0
  18. package/backups/swap_test.go +75 -0
  19. package/backups/upload.go +52 -0
  20. package/bin/pocketbase-extension.mjs +25 -0
  21. package/cmd/edge/main.go +123 -0
  22. package/cmd/import-fork/main.go +37 -0
  23. package/cmd/loadtest/main.go +61 -0
  24. package/cmd/loadtest/sandbox.go +73 -0
  25. package/cmd/loadtest/sandbox_test.go +20 -0
  26. package/cmd/pocketbase/main.go +78 -0
  27. package/deploy/README.md +148 -0
  28. package/deploy/app/hooks/README.md +2 -0
  29. package/deploy/app/migrations/1789000000_notes.js +16 -0
  30. package/deploy/app/public/README.md +2 -0
  31. package/deploy/compose.secrets.yaml +8 -0
  32. package/deploy/compose.yaml +68 -0
  33. package/deploy/edge.json +13 -0
  34. package/edge/gateway.go +295 -0
  35. package/edge/gateway_test.go +296 -0
  36. package/edge/openapi.json +1 -0
  37. package/edge/policy.go +150 -0
  38. package/features/collection_singleton.go +13 -0
  39. package/features/collection_singleton_test.go +46 -0
  40. package/features/dimensions_test.go +65 -0
  41. package/features/duplicate.go +178 -0
  42. package/features/duplicate_test.go +128 -0
  43. package/features/field_color.go +46 -0
  44. package/features/field_date_only.go +39 -0
  45. package/features/field_json_schema.go +92 -0
  46. package/features/field_scalar_extensions_test.go +66 -0
  47. package/features/files.go +36 -0
  48. package/features/filter_has_any_test.go +81 -0
  49. package/features/generate_test.go +72 -0
  50. package/features/has_any_visibility_test.go +62 -0
  51. package/features/json.go +50 -0
  52. package/features/membership.go +64 -0
  53. package/features/register.go +45 -0
  54. package/features/schema_test.go +58 -0
  55. package/features/ui/main.js +133 -0
  56. package/features/ui/settings.js +31 -0
  57. package/go.mod +54 -0
  58. package/go.sum +159 -0
  59. package/internal/archive/create.go +91 -0
  60. package/internal/archive/create_test.go +125 -0
  61. package/internal/archive/extract.go +99 -0
  62. package/internal/archive/extract_test.go +88 -0
  63. package/jsvm/binds.go +1273 -0
  64. package/jsvm/binds_app_reset_test.go +314 -0
  65. package/jsvm/binds_test.go +1870 -0
  66. package/jsvm/form_data.go +149 -0
  67. package/jsvm/form_data_test.go +225 -0
  68. package/jsvm/internal/types/generated/embed.go +6 -0
  69. package/jsvm/internal/types/generated/types.d.ts +24820 -0
  70. package/jsvm/internal/types/types.go +1408 -0
  71. package/jsvm/jsvm.go +587 -0
  72. package/jsvm/mapper.go +67 -0
  73. package/jsvm/mapper_test.go +42 -0
  74. package/jsvm/pool.go +73 -0
  75. package/jsvm/program_source_test.go +24 -0
  76. package/loadtest/loadtest.go +202 -0
  77. package/loadtest/loadtest_test.go +84 -0
  78. package/localization/README.md +23 -0
  79. package/localization/catalogue.json +483 -0
  80. package/localization/localization.go +94 -0
  81. package/localization/localization_test.go +21 -0
  82. package/mail/register.go +80 -0
  83. package/mail/register_test.go +49 -0
  84. package/mail/resolve.go +91 -0
  85. package/migration/import.go +96 -0
  86. package/migration/import_test.go +58 -0
  87. package/otp/otp.go +56 -0
  88. package/otp/otp_test.go +56 -0
  89. package/package.json +51 -0
  90. package/scripts/check-edge.py +42 -0
  91. package/scripts/check.sh +11 -0
  92. package/scripts/sync-jsvm-types.sh +10 -0
  93. package/security/README.md +94 -0
  94. package/security/assurance_test.go +149 -0
  95. package/security/compatibility_test.go +128 -0
  96. package/security/config.go +78 -0
  97. package/security/dashboard_test.go +103 -0
  98. package/security/management.go +169 -0
  99. package/security/openapi.json +508 -0
  100. package/security/review.go +34 -0
  101. package/security/security.go +503 -0
  102. package/security/security_test.go +146 -0
  103. package/security/state.go +116 -0
  104. package/security/ui/dashboard.css +4 -0
  105. package/security/ui/dashboard.js +83 -0
  106. package/security/ui/main.js +15 -0
  107. package/security/ui/model.js +32 -0
  108. package/security/ui/model.test.mjs +25 -0
  109. package/security/ui/registration.test.mjs +10 -0
  110. package/settings/env_test.go +41 -0
  111. package/settings/openapi.json +193 -0
  112. package/settings/settings.go +155 -0
  113. package/settings/settings_test.go +31 -0
  114. package/watcher/watcher.go +192 -0
  115. package/watcher/watcher_test.go +200 -0
  116. package/web/static.go +99 -0
  117. package/web/static_test.go +48 -0
@@ -0,0 +1,295 @@
1
+ package edge
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ _ "embed"
7
+ "encoding/json"
8
+ "io"
9
+ "net"
10
+ "net/http"
11
+ "net/http/httputil"
12
+ "net/netip"
13
+ "net/url"
14
+ "path"
15
+ "strings"
16
+ "sync"
17
+ "sync/atomic"
18
+ "time"
19
+
20
+ "github.com/spink-dev/pocketbase-extension/security"
21
+ )
22
+
23
+ //go:embed openapi.json
24
+ var contract []byte
25
+
26
+ type operatorKey struct{}
27
+
28
+ type bucket struct {
29
+ tokens float64
30
+ updated time.Time
31
+ }
32
+
33
+ func (b *bucket) take(now time.Time, rate, burst int) bool {
34
+ if b.updated.IsZero() {
35
+ b.tokens = float64(burst)
36
+ } else {
37
+ b.tokens = min(float64(burst), b.tokens+now.Sub(b.updated).Seconds()*float64(rate))
38
+ }
39
+ b.updated = now
40
+ if b.tokens < 1 {
41
+ return false
42
+ }
43
+ b.tokens--
44
+ return true
45
+ }
46
+
47
+ type Gateway struct {
48
+ mu sync.Mutex
49
+ policy Policy
50
+ snapshot Snapshot
51
+ valid bool
52
+ global bucket
53
+ clients map[netip.Addr]*bucket
54
+ active int
55
+ admin chan struct{}
56
+ proxy *httputil.ReverseProxy
57
+ upstreamHealthy atomic.Bool
58
+ accepted atomic.Uint64
59
+ rejected atomic.Uint64
60
+ }
61
+
62
+ func New(upstream *url.URL, p Policy) (*Gateway, error) {
63
+ if err := p.Validate(); err != nil {
64
+ return nil, err
65
+ }
66
+ g := &Gateway{policy: p, clients: map[netip.Addr]*bucket{}, admin: make(chan struct{}, 8)}
67
+ g.proxy = &httputil.ReverseProxy{
68
+ Rewrite: func(r *httputil.ProxyRequest) {
69
+ r.SetURL(upstream)
70
+ // Only our sanitized peer identity crosses the private network.
71
+ r.Out.Header.Del("X-Spink-Operator")
72
+ if r.In.Context().Value(operatorKey{}) == true {
73
+ r.Out.Header.Set("X-Spink-Operator", "1")
74
+ }
75
+ r.Out.Header.Del("Forwarded")
76
+ r.Out.Header.Del("X-Real-IP")
77
+ r.Out.Header.Del("X-Forwarded-Host")
78
+ r.Out.Header.Del("X-Forwarded-Proto")
79
+ r.Out.Header.Del("X-Forwarded-For")
80
+ host, _, _ := net.SplitHostPort(r.In.RemoteAddr)
81
+ r.Out.Header.Set("X-Forwarded-For", host)
82
+ },
83
+ Transport: &http.Transport{Proxy: nil, DialContext: (&net.Dialer{Timeout: 3 * time.Second, KeepAlive: 30 * time.Second}).DialContext, MaxIdleConns: 128, MaxIdleConnsPerHost: 128, IdleConnTimeout: 60 * time.Second, ResponseHeaderTimeout: 10 * time.Second, MaxResponseHeaderBytes: 64 << 10},
84
+ ErrorHandler: func(w http.ResponseWriter, r *http.Request, err error) {
85
+ http.Error(w, "upstream unavailable", http.StatusBadGateway)
86
+ },
87
+ FlushInterval: -1,
88
+ }
89
+ return g, nil
90
+ }
91
+
92
+ // Apply never resets budgets during reload, including unchanged polling results.
93
+ func (g *Gateway) Apply(p Policy, s Snapshot, valid bool) {
94
+ g.mu.Lock()
95
+ defer g.mu.Unlock()
96
+ if valid {
97
+ g.policy = p
98
+ g.snapshot = s
99
+ }
100
+ g.valid = valid
101
+ }
102
+ func (g *Gateway) SetHealthy(v bool) { g.upstreamHealthy.Store(v) }
103
+ func (g *Gateway) reject(w http.ResponseWriter, code int) {
104
+ g.rejected.Add(1)
105
+ w.Header().Set("Cache-Control", "no-store")
106
+ if code == 429 || code == 503 {
107
+ w.Header().Set("Retry-After", "1")
108
+ }
109
+ http.Error(w, http.StatusText(code), code)
110
+ }
111
+ func canonical(r *http.Request) bool {
112
+ // Reject ambiguous decoding instead of normalizing differently from the backend.
113
+ return r.URL.RawPath == "" && !strings.ContainsAny(r.URL.Path, "\\%\x00") && path.Clean(r.URL.Path) == strings.TrimSuffix(r.URL.Path, "/") && !strings.Contains(r.URL.Path, "//")
114
+ }
115
+ func client(r *http.Request, p Policy) (netip.Addr, bool) {
116
+ host, _, err := net.SplitHostPort(r.RemoteAddr)
117
+ if err != nil {
118
+ return netip.Addr{}, false
119
+ }
120
+ ip, err := netip.ParseAddr(host)
121
+ if err != nil {
122
+ return ip, false
123
+ }
124
+ ip = ip.Unmap()
125
+ if !contains(p.trusted, ip) {
126
+ return ip, true
127
+ }
128
+ parts := strings.Split(r.Header.Get("X-Forwarded-For"), ",")
129
+ if len(parts) > 16 {
130
+ return ip, false
131
+ }
132
+ for i := len(parts) - 1; i >= 0; i-- {
133
+ next, e := netip.ParseAddr(strings.TrimSpace(parts[i]))
134
+ if e != nil {
135
+ return ip, false
136
+ }
137
+ ip = next.Unmap()
138
+ if !contains(p.trusted, ip) {
139
+ return ip, true
140
+ }
141
+ }
142
+ return ip, true
143
+ }
144
+ func publicPath(r *http.Request, p Policy) bool {
145
+ parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
146
+ if len(parts) == 2 && parts[0] == "api" && (parts[1] == "health" || parts[1] == "realtime") {
147
+ return true
148
+ }
149
+ if len(parts) < 3 || parts[0] != "api" || (parts[1] != "collections" && parts[1] != "files") {
150
+ return false
151
+ }
152
+ allowed := false
153
+ for _, c := range p.Collections {
154
+ if parts[2] == c {
155
+ allowed = true
156
+ break
157
+ }
158
+ }
159
+ if !allowed {
160
+ return false
161
+ }
162
+ if parts[1] == "files" {
163
+ return len(parts) >= 5
164
+ }
165
+ // Collection schema/settings APIs never cross the public listener.
166
+ return len(parts) >= 4 && (parts[3] == "records" || strings.HasPrefix(parts[3], "auth-") || strings.HasPrefix(parts[3], "request-") || strings.HasPrefix(parts[3], "confirm-"))
167
+ }
168
+ func (g *Gateway) Public(w http.ResponseWriter, r *http.Request) {
169
+ if !canonical(r) {
170
+ g.reject(w, 400)
171
+ return
172
+ }
173
+ g.mu.Lock()
174
+ p := g.policy
175
+ ip, ok := client(r, p)
176
+ code := 0
177
+ now := time.Now()
178
+ switch {
179
+ case !g.valid:
180
+ code = 503
181
+ case !ok:
182
+ code = 400
183
+ case contains(p.blocked, ip):
184
+ code = 403
185
+ case !publicPath(r, p):
186
+ code = 403
187
+ }
188
+ if code == 0 {
189
+ for _, prefix := range p.BlockedPaths {
190
+ if strings.HasPrefix(r.URL.Path, prefix) {
191
+ code = 403
192
+ break
193
+ }
194
+ }
195
+ }
196
+ if code == 0 && g.snapshot.Policy.Mode == "enforce" {
197
+ if a, exists := g.snapshot.Actions[security.RequestFamily(r)]; exists && a.ExpiresAt.After(now) {
198
+ code = 403
199
+ }
200
+ }
201
+ if code == 0 && !g.global.take(now, p.Rate, p.Burst) {
202
+ code = 429
203
+ }
204
+ if code == 0 {
205
+ b := g.clients[ip]
206
+ if b == nil {
207
+ if len(g.clients) >= p.MaxClients {
208
+ for key, old := range g.clients {
209
+ if now.Sub(old.updated) > time.Minute {
210
+ delete(g.clients, key)
211
+ }
212
+ }
213
+ }
214
+ if len(g.clients) >= p.MaxClients {
215
+ code = 429
216
+ } else {
217
+ b = &bucket{}
218
+ g.clients[ip] = b
219
+ }
220
+ }
221
+ if b != nil && !b.take(now, p.ClientRate, p.ClientBurst) {
222
+ code = 429
223
+ }
224
+ }
225
+ if code == 0 {
226
+ if g.active >= p.MaxConcurrent {
227
+ code = 503
228
+ } else {
229
+ g.active++
230
+ }
231
+ }
232
+ g.mu.Unlock()
233
+ if code != 0 {
234
+ g.reject(w, code)
235
+ return
236
+ }
237
+ defer func() { g.mu.Lock(); g.active--; g.mu.Unlock() }()
238
+ if r.ContentLength > p.MaxBodyBytes {
239
+ g.reject(w, 413)
240
+ return
241
+ }
242
+ // Fully bound and validate chunked bodies before any upstream side effect.
243
+ if r.Body != nil {
244
+ b, err := io.ReadAll(http.MaxBytesReader(w, r.Body, p.MaxBodyBytes))
245
+ r.Body.Close()
246
+ if err != nil {
247
+ g.reject(w, 413)
248
+ return
249
+ }
250
+ r.Body = io.NopCloser(bytes.NewReader(b))
251
+ r.ContentLength = int64(len(b))
252
+ }
253
+ r.RemoteAddr = net.JoinHostPort(ip.String(), "0")
254
+ g.accepted.Add(1)
255
+ g.proxy.ServeHTTP(w, r)
256
+ }
257
+ func (g *Gateway) Operator(w http.ResponseWriter, r *http.Request) {
258
+ switch r.URL.Path {
259
+ case "/edge/openapi.json":
260
+ w.Header().Set("Content-Type", "application/json")
261
+ w.Write(contract)
262
+ return
263
+ case "/edge/healthz":
264
+ w.WriteHeader(204)
265
+ return
266
+ case "/edge/readyz":
267
+ g.mu.Lock()
268
+ valid := g.valid
269
+ g.mu.Unlock()
270
+ if !valid || !g.upstreamHealthy.Load() {
271
+ http.Error(w, "not ready", 503)
272
+ return
273
+ }
274
+ w.WriteHeader(204)
275
+ return
276
+ case "/edge/status":
277
+ g.mu.Lock()
278
+ state := map[string]any{"policyReady": g.valid, "upstreamHealthy": g.upstreamHealthy.Load(), "active": g.active, "trackedClients": len(g.clients), "accepted": g.accepted.Load(), "rejected": g.rejected.Load()}
279
+ g.mu.Unlock()
280
+ w.Header().Set("Content-Type", "application/json")
281
+ w.Header().Set("Cache-Control", "no-store")
282
+ json.NewEncoder(w).Encode(state)
283
+ return
284
+ }
285
+ select {
286
+ case g.admin <- struct{}{}:
287
+ defer func() { <-g.admin }()
288
+ default:
289
+ g.reject(w, 503)
290
+ return
291
+ }
292
+ // Operator uploads retain native limits without buffering large backups.
293
+ r = r.WithContext(context.WithValue(r.Context(), operatorKey{}, true))
294
+ g.proxy.ServeHTTP(w, r)
295
+ }
@@ -0,0 +1,296 @@
1
+ package edge
2
+
3
+ import (
4
+ "encoding/json"
5
+ "net/http"
6
+ "net/http/httptest"
7
+ "net/url"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "sync/atomic"
12
+ "testing"
13
+ "time"
14
+
15
+ "github.com/spink-dev/pocketbase-extension/security"
16
+ )
17
+
18
+ func policy() Policy {
19
+ return Policy{Collections: []string{"notes"}, Rate: 100, Burst: 200, ClientRate: 20, ClientBurst: 40, MaxClients: 16, MaxConcurrent: 2, MaxBodyBytes: 1024}
20
+ }
21
+ func snap() Snapshot {
22
+ c := security.DefaultConfig()
23
+ c.Mode = "enforce"
24
+ return Snapshot{Version: 1, Revision: 1, Policy: c, Actions: map[string]security.Action{}}
25
+ }
26
+ func fixture(t *testing.T, p Policy, h http.HandlerFunc) (*Gateway, *atomic.Int64) {
27
+ t.Helper()
28
+ count := new(atomic.Int64)
29
+ s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
30
+ count.Add(1)
31
+ if h != nil {
32
+ h(w, r)
33
+ } else {
34
+ w.WriteHeader(204)
35
+ }
36
+ }))
37
+ t.Cleanup(s.Close)
38
+ u, _ := url.Parse(s.URL)
39
+ g, err := New(u, p)
40
+ if err != nil {
41
+ t.Fatal(err)
42
+ }
43
+ g.Apply(g.policy, snap(), true)
44
+ return g, count
45
+ }
46
+ func req(target string) *http.Request {
47
+ r := httptest.NewRequest("GET", target, nil)
48
+ r.RemoteAddr = "192.0.2.10:1234"
49
+ return r
50
+ }
51
+ func TestRejectBeforeUpstream(t *testing.T) {
52
+ cases := []struct {
53
+ name, path string
54
+ code int
55
+ alter func(*Gateway, *http.Request)
56
+ }{
57
+ {"admin", "/_/", 403, nil},
58
+ {"schema", "/api/collections/notes", 403, nil},
59
+ {"superuser", "/api/collections/_superusers/auth-with-password", 403, nil},
60
+ {"superuserID", "/api/collections/pbc_3142635823/auth-with-password", 403, nil},
61
+ {"management", "/api/security/status", 403, nil},
62
+ {"batch", "/api/batch", 403, nil},
63
+ {"unknownCollection", "/api/collections/hidden/records", 403, nil},
64
+ {"ambiguous", "/api/collections/notes/../_superusers/records", 400, nil},
65
+ {"encoded", "/api/collections/%6eotes/records", 400, nil},
66
+ {"CIDR", "/api/health", 403, func(g *Gateway, r *http.Request) {
67
+ p := g.policy
68
+ p.BlockedCIDRs = []string{"192.0.2.0/24"}
69
+ p.Validate()
70
+ g.Apply(p, snap(), true)
71
+ }},
72
+ {"path", "/api/health", 403, func(g *Gateway, r *http.Request) {
73
+ p := g.policy
74
+ p.BlockedPaths = []string{"/api/health"}
75
+ g.Apply(p, snap(), true)
76
+ }},
77
+ {"action", "/api/collections/notes/records", 403, func(g *Gateway, r *http.Request) {
78
+ s := snap()
79
+ s.Actions["reads"] = security.Action{Family: "reads", ExpiresAt: time.Now().Add(time.Minute)}
80
+ g.Apply(g.policy, s, true)
81
+ }},
82
+ {"unready", "/api/health", 503, func(g *Gateway, r *http.Request) { g.Apply(Policy{}, Snapshot{}, false) }},
83
+ {"body", "/api/collections/notes/records", 413, func(g *Gateway, r *http.Request) {
84
+ *r = *httptest.NewRequest("POST", r.URL.String(), strings.NewReader(strings.Repeat("a", 1025)))
85
+ r.RemoteAddr = "192.0.2.10:1234"
86
+ }},
87
+ {"chunked", "/api/collections/notes/records", 413, func(g *Gateway, r *http.Request) {
88
+ *r = *httptest.NewRequest("POST", r.URL.String(), strings.NewReader(strings.Repeat("a", 1025)))
89
+ r.RemoteAddr = "192.0.2.10:1234"
90
+ r.ContentLength = -1
91
+ }},
92
+ }
93
+ for _, tc := range cases {
94
+ t.Run(tc.name, func(t *testing.T) {
95
+ g, n := fixture(t, policy(), nil)
96
+ r := req(tc.path)
97
+ if tc.alter != nil {
98
+ tc.alter(g, r)
99
+ }
100
+ w := httptest.NewRecorder()
101
+ g.Public(w, r)
102
+ if w.Code != tc.code || n.Load() != 0 {
103
+ t.Fatalf("status=%d upstream=%d", w.Code, n.Load())
104
+ }
105
+ })
106
+ }
107
+ }
108
+ func TestForwardedIdentity(t *testing.T) {
109
+ g, _ := fixture(t, policy(), func(w http.ResponseWriter, r *http.Request) {
110
+ if r.Header.Get("X-Forwarded-For") != "192.0.2.10" || r.Header.Get("X-Real-IP") != "" || r.Header.Get("Forwarded") != "" {
111
+ t.Errorf("spoofed forwarding headers: %v", r.Header)
112
+ }
113
+ w.WriteHeader(204)
114
+ })
115
+ r := req("/api/health")
116
+ r.Header.Set("X-Forwarded-For", "127.0.0.1")
117
+ r.Header.Set("X-Real-IP", "127.0.0.1")
118
+ r.Header.Set("Forwarded", "for=127.0.0.1")
119
+ w := httptest.NewRecorder()
120
+ g.Public(w, r)
121
+ if w.Code != 204 {
122
+ t.Fatal(w.Code)
123
+ }
124
+ p := policy()
125
+ p.TrustedProxies = []string{"192.0.2.0/24"}
126
+ p.Validate()
127
+ r.Header.Set("X-Forwarded-For", "198.51.100.3, 192.0.2.20")
128
+ ip, ok := client(r, p)
129
+ if !ok || ip.String() != "198.51.100.3" {
130
+ t.Fatal(ip, ok)
131
+ }
132
+ }
133
+ func TestPublicCapacityDoesNotConsumeOperator(t *testing.T) {
134
+ entered := make(chan struct{}, 1)
135
+ release := make(chan struct{})
136
+ p := policy()
137
+ p.MaxConcurrent = 1
138
+ g, n := fixture(t, p, func(w http.ResponseWriter, r *http.Request) {
139
+ if r.URL.Path == "/api/health" {
140
+ entered <- struct{}{}
141
+ <-release
142
+ }
143
+ w.WriteHeader(204)
144
+ })
145
+ done := make(chan struct{})
146
+ go func() { defer close(done); g.Public(httptest.NewRecorder(), req("/api/health")) }()
147
+ <-entered
148
+ w := httptest.NewRecorder()
149
+ g.Public(w, req("/api/health"))
150
+ if w.Code != 503 || n.Load() != 1 {
151
+ t.Fatal(w.Code, n.Load())
152
+ }
153
+ for _, path := range []string{"/edge/healthz", "/_/"} {
154
+ w = httptest.NewRecorder()
155
+ g.Operator(w, req(path))
156
+ if w.Code != 204 {
157
+ t.Fatal(path, w.Code)
158
+ }
159
+ }
160
+ close(release)
161
+ <-done
162
+ }
163
+ func TestRateReloadAndBoundedClients(t *testing.T) {
164
+ p := policy()
165
+ p.ClientBurst = 1
166
+ p.MaxClients = 1
167
+ g, n := fixture(t, p, nil)
168
+ w := httptest.NewRecorder()
169
+ g.Public(w, req("/api/health"))
170
+ if w.Code != 204 {
171
+ t.Fatal(w.Code)
172
+ }
173
+ g.Apply(g.policy, snap(), true)
174
+ w = httptest.NewRecorder()
175
+ g.Public(w, req("/api/health"))
176
+ if w.Code != 429 {
177
+ t.Fatal(w.Code)
178
+ }
179
+ r := req("/api/health")
180
+ r.RemoteAddr = "198.51.100.1:1234"
181
+ w = httptest.NewRecorder()
182
+ g.Public(w, r)
183
+ if w.Code != 429 || n.Load() != 1 || len(g.clients) != 1 {
184
+ t.Fatal(w.Code, n.Load(), len(g.clients))
185
+ }
186
+ }
187
+ func TestExpiredAndObserveActions(t *testing.T) {
188
+ for _, observe := range []bool{false, true} {
189
+ g, _ := fixture(t, policy(), nil)
190
+ s := snap()
191
+ expiry := time.Now().Add(-time.Minute)
192
+ if observe {
193
+ s.Policy.Mode = "observe"
194
+ expiry = time.Now().Add(time.Minute)
195
+ }
196
+ s.Actions["reads"] = security.Action{Family: "reads", ExpiresAt: expiry}
197
+ g.Apply(g.policy, s, true)
198
+ w := httptest.NewRecorder()
199
+ g.Public(w, req("/api/collections/notes/records"))
200
+ if w.Code != 204 {
201
+ t.Fatal(w.Code)
202
+ }
203
+ }
204
+ }
205
+ func TestPolicyAndSnapshotFiles(t *testing.T) {
206
+ dir := t.TempDir()
207
+ file := filepath.Join(dir, "policy.json")
208
+ b, _ := json.Marshal(policy())
209
+ os.WriteFile(file, b, 0600)
210
+ if _, err := LoadPolicy(file); err != nil {
211
+ t.Fatal(err)
212
+ }
213
+ os.WriteFile(file, append(b, []byte(" {}")...), 0600)
214
+ if _, err := LoadPolicy(file); err == nil {
215
+ t.Fatal("accepted trailing JSON")
216
+ }
217
+ os.WriteFile(file, []byte(`{"unknown":1}`), 0600)
218
+ if _, err := LoadPolicy(file); err == nil {
219
+ t.Fatal("accepted unknown field")
220
+ }
221
+ b, _ = json.Marshal(snap())
222
+ os.WriteFile(file, b, 0600)
223
+ if _, err := LoadSnapshot(file); err != nil {
224
+ t.Fatal(err)
225
+ }
226
+ s := snap()
227
+ s.Version = 2
228
+ b, _ = json.Marshal(s)
229
+ os.WriteFile(file, b, 0600)
230
+ if _, err := LoadSnapshot(file); err == nil {
231
+ t.Fatal("accepted wrong checkpoint version")
232
+ }
233
+ p := policy()
234
+ p.MaxConcurrent = 1024
235
+ p.MaxBodyBytes = 32 << 20
236
+ if p.Validate() == nil {
237
+ t.Fatal("unbounded memory budget")
238
+ }
239
+ }
240
+ func TestReadinessIsNotLiveness(t *testing.T) {
241
+ g, _ := fixture(t, policy(), nil)
242
+ w := httptest.NewRecorder()
243
+ g.Operator(w, req("/edge/readyz"))
244
+ if w.Code != 503 {
245
+ t.Fatal(w.Code)
246
+ }
247
+ g.SetHealthy(true)
248
+ w = httptest.NewRecorder()
249
+ g.Operator(w, req("/edge/readyz"))
250
+ if w.Code != 204 {
251
+ t.Fatal(w.Code)
252
+ }
253
+ g.Apply(Policy{}, Snapshot{}, false)
254
+ w = httptest.NewRecorder()
255
+ g.Operator(w, req("/edge/readyz"))
256
+ if w.Code != 503 {
257
+ t.Fatal(w.Code)
258
+ }
259
+ w = httptest.NewRecorder()
260
+ g.Operator(w, req("/edge/healthz"))
261
+ if w.Code != 204 {
262
+ t.Fatal(w.Code)
263
+ }
264
+ }
265
+
266
+ func TestStaleCheckpointFailsClosed(t *testing.T) {
267
+ file := filepath.Join(t.TempDir(), "state.json")
268
+ b, _ := json.Marshal(snap())
269
+ if err := os.WriteFile(file, b, 0600); err != nil {
270
+ t.Fatal(err)
271
+ }
272
+ old := time.Now().Add(-time.Minute)
273
+ os.Chtimes(file, old, old)
274
+ if _, err := LoadSnapshot(file); err == nil {
275
+ t.Fatal("accepted stale checkpoint")
276
+ }
277
+ }
278
+
279
+ func TestOperatorMarkerCannotCrossPublicListener(t *testing.T) {
280
+ operator := false
281
+ g, _ := fixture(t, policy(), func(w http.ResponseWriter, r *http.Request) {
282
+ want := ""
283
+ if operator {
284
+ want = "1"
285
+ }
286
+ if r.Header.Get("X-Spink-Operator") != want {
287
+ t.Error("wrong operator marker")
288
+ }
289
+ w.WriteHeader(204)
290
+ })
291
+ r := req("/api/health")
292
+ r.Header.Set("X-Spink-Operator", "1")
293
+ g.Public(httptest.NewRecorder(), r)
294
+ operator = true
295
+ g.Operator(httptest.NewRecorder(), req("/_/"))
296
+ }
@@ -0,0 +1 @@
1
+ {"openapi":"3.1.0","info":{"title":"Spink edge local diagnostics","version":"0.1.0"},"servers":[{"url":"http://127.0.0.1:8081"}],"paths":{"/edge/healthz":{"get":{"summary":"Gateway process liveness","responses":{"204":{"description":"Alive"}}}},"/edge/readyz":{"get":{"summary":"Validated policy and cached upstream health","responses":{"204":{"description":"Ready"},"503":{"description":"Not ready"}}}},"/edge/status":{"get":{"summary":"Local-only aggregate counters; no database credentials","responses":{"200":{"description":"Status","content":{"application/json":{"schema":{"type":"object","properties":{"policyReady":{"type":"boolean"},"upstreamHealthy":{"type":"boolean"},"active":{"type":"integer"},"trackedClients":{"type":"integer"},"accepted":{"type":"integer"},"rejected":{"type":"integer"}}}}}}}}}}}