@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.
- package/.dockerignore +9 -0
- package/Dockerfile +26 -0
- package/FEATURES.md +32 -0
- package/LICENSE.md +17 -0
- package/MIGRATION.md +49 -0
- package/NOTICE.md +5 -0
- package/README.md +26 -0
- package/adapter.go +31 -0
- package/admin/register.go +60 -0
- package/admin/register_test.go +37 -0
- package/backups/backup_encryption_test.go +82 -0
- package/backups/encryption.go +138 -0
- package/backups/integration_test.go +51 -0
- package/backups/register.go +120 -0
- package/backups/restore.go +119 -0
- package/backups/s3_test.go +52 -0
- package/backups/swap.go +53 -0
- package/backups/swap_test.go +75 -0
- package/backups/upload.go +52 -0
- package/bin/pocketbase-extension.mjs +25 -0
- package/cmd/edge/main.go +123 -0
- package/cmd/import-fork/main.go +37 -0
- package/cmd/loadtest/main.go +61 -0
- package/cmd/loadtest/sandbox.go +73 -0
- package/cmd/loadtest/sandbox_test.go +20 -0
- package/cmd/pocketbase/main.go +78 -0
- package/deploy/README.md +148 -0
- package/deploy/app/hooks/README.md +2 -0
- package/deploy/app/migrations/1789000000_notes.js +16 -0
- package/deploy/app/public/README.md +2 -0
- package/deploy/compose.secrets.yaml +8 -0
- package/deploy/compose.yaml +68 -0
- package/deploy/edge.json +13 -0
- package/edge/gateway.go +295 -0
- package/edge/gateway_test.go +296 -0
- package/edge/openapi.json +1 -0
- package/edge/policy.go +150 -0
- package/features/collection_singleton.go +13 -0
- package/features/collection_singleton_test.go +46 -0
- package/features/dimensions_test.go +65 -0
- package/features/duplicate.go +178 -0
- package/features/duplicate_test.go +128 -0
- package/features/field_color.go +46 -0
- package/features/field_date_only.go +39 -0
- package/features/field_json_schema.go +92 -0
- package/features/field_scalar_extensions_test.go +66 -0
- package/features/files.go +36 -0
- package/features/filter_has_any_test.go +81 -0
- package/features/generate_test.go +72 -0
- package/features/has_any_visibility_test.go +62 -0
- package/features/json.go +50 -0
- package/features/membership.go +64 -0
- package/features/register.go +45 -0
- package/features/schema_test.go +58 -0
- package/features/ui/main.js +133 -0
- package/features/ui/settings.js +31 -0
- package/go.mod +54 -0
- package/go.sum +159 -0
- package/internal/archive/create.go +91 -0
- package/internal/archive/create_test.go +125 -0
- package/internal/archive/extract.go +99 -0
- package/internal/archive/extract_test.go +88 -0
- package/jsvm/binds.go +1273 -0
- package/jsvm/binds_app_reset_test.go +314 -0
- package/jsvm/binds_test.go +1870 -0
- package/jsvm/form_data.go +149 -0
- package/jsvm/form_data_test.go +225 -0
- package/jsvm/internal/types/generated/embed.go +6 -0
- package/jsvm/internal/types/generated/types.d.ts +24820 -0
- package/jsvm/internal/types/types.go +1408 -0
- package/jsvm/jsvm.go +587 -0
- package/jsvm/mapper.go +67 -0
- package/jsvm/mapper_test.go +42 -0
- package/jsvm/pool.go +73 -0
- package/jsvm/program_source_test.go +24 -0
- package/loadtest/loadtest.go +202 -0
- package/loadtest/loadtest_test.go +84 -0
- package/localization/README.md +23 -0
- package/localization/catalogue.json +483 -0
- package/localization/localization.go +94 -0
- package/localization/localization_test.go +21 -0
- package/mail/register.go +80 -0
- package/mail/register_test.go +49 -0
- package/mail/resolve.go +91 -0
- package/migration/import.go +96 -0
- package/migration/import_test.go +58 -0
- package/otp/otp.go +56 -0
- package/otp/otp_test.go +56 -0
- package/package.json +51 -0
- package/scripts/check-edge.py +42 -0
- package/scripts/check.sh +11 -0
- package/scripts/sync-jsvm-types.sh +10 -0
- package/security/README.md +94 -0
- package/security/assurance_test.go +149 -0
- package/security/compatibility_test.go +128 -0
- package/security/config.go +78 -0
- package/security/dashboard_test.go +103 -0
- package/security/management.go +169 -0
- package/security/openapi.json +508 -0
- package/security/review.go +34 -0
- package/security/security.go +503 -0
- package/security/security_test.go +146 -0
- package/security/state.go +116 -0
- package/security/ui/dashboard.css +4 -0
- package/security/ui/dashboard.js +83 -0
- package/security/ui/main.js +15 -0
- package/security/ui/model.js +32 -0
- package/security/ui/model.test.mjs +25 -0
- package/security/ui/registration.test.mjs +10 -0
- package/settings/env_test.go +41 -0
- package/settings/openapi.json +193 -0
- package/settings/settings.go +155 -0
- package/settings/settings_test.go +31 -0
- package/watcher/watcher.go +192 -0
- package/watcher/watcher_test.go +200 -0
- package/web/static.go +99 -0
- package/web/static_test.go +48 -0
package/jsvm/pool.go
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
package jsvm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"sync"
|
|
5
|
+
|
|
6
|
+
"github.com/dop251/goja"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
type poolItem struct {
|
|
10
|
+
mux sync.Mutex
|
|
11
|
+
busy bool
|
|
12
|
+
vm *goja.Runtime
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
type vmsPool struct {
|
|
16
|
+
mux sync.RWMutex
|
|
17
|
+
factory func() *goja.Runtime
|
|
18
|
+
items []*poolItem
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// newPool creates a new pool with pre-warmed vms generated from the specified factory.
|
|
22
|
+
func newPool(size int, factory func() *goja.Runtime) *vmsPool {
|
|
23
|
+
pool := &vmsPool{
|
|
24
|
+
factory: factory,
|
|
25
|
+
items: make([]*poolItem, size),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
for i := 0; i < size; i++ {
|
|
29
|
+
vm := pool.factory()
|
|
30
|
+
pool.items[i] = &poolItem{vm: vm}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return pool
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// run executes "call" with a vm created from the pool
|
|
37
|
+
// (either from the buffer or a new one if all buffered vms are busy)
|
|
38
|
+
func (p *vmsPool) run(call func(vm *goja.Runtime) error) error {
|
|
39
|
+
p.mux.RLock()
|
|
40
|
+
|
|
41
|
+
// try to find a free item
|
|
42
|
+
var freeItem *poolItem
|
|
43
|
+
for _, item := range p.items {
|
|
44
|
+
item.mux.Lock()
|
|
45
|
+
if item.busy {
|
|
46
|
+
item.mux.Unlock()
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
item.busy = true
|
|
50
|
+
item.mux.Unlock()
|
|
51
|
+
freeItem = item
|
|
52
|
+
break
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
p.mux.RUnlock()
|
|
56
|
+
|
|
57
|
+
// create a new one-off item if of all of the pool items are currently busy
|
|
58
|
+
//
|
|
59
|
+
// note: if turned out not efficient we may change this in the future
|
|
60
|
+
// by adding the created item in the pool with some timer for removal
|
|
61
|
+
if freeItem == nil {
|
|
62
|
+
return call(p.factory())
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
execErr := call(freeItem.vm)
|
|
66
|
+
|
|
67
|
+
// "free" the vm
|
|
68
|
+
freeItem.mux.Lock()
|
|
69
|
+
freeItem.busy = false
|
|
70
|
+
freeItem.mux.Unlock()
|
|
71
|
+
|
|
72
|
+
return execErr
|
|
73
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package jsvm
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"errors"
|
|
5
|
+
"github.com/dop251/goja"
|
|
6
|
+
"testing"
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
func TestProgramSource(t *testing.T) {
|
|
10
|
+
count := 0
|
|
11
|
+
p := &plugin{config: Config{ProgramSource: func(name, source string) (*goja.Program, error) { count++; return goja.Compile(name, source, false) }}}
|
|
12
|
+
v, err := p.runScript(goja.New(), "test.js", "2+3")
|
|
13
|
+
if err != nil || v.ToInteger() != 5 || count != 1 {
|
|
14
|
+
t.Fatalf("%v %v %d", v, err, count)
|
|
15
|
+
}
|
|
16
|
+
p.config.ProgramSource = func(string, string) (*goja.Program, error) { return nil, errors.New("compile failed") }
|
|
17
|
+
if _, err = p.runScript(goja.New(), "x", ""); err == nil {
|
|
18
|
+
t.Fatal("missing compile error")
|
|
19
|
+
}
|
|
20
|
+
p.config.ProgramSource = func(string, string) (*goja.Program, error) { return nil, nil }
|
|
21
|
+
if _, err = p.runScript(goja.New(), "x", ""); err == nil {
|
|
22
|
+
t.Fatal("nil program accepted")
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
package loadtest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bufio"
|
|
5
|
+
"context"
|
|
6
|
+
"encoding/json"
|
|
7
|
+
"errors"
|
|
8
|
+
"fmt"
|
|
9
|
+
"io"
|
|
10
|
+
"net"
|
|
11
|
+
"net/http"
|
|
12
|
+
"net/netip"
|
|
13
|
+
"net/url"
|
|
14
|
+
"strings"
|
|
15
|
+
"sync"
|
|
16
|
+
"sync/atomic"
|
|
17
|
+
"time"
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
type Config struct {
|
|
21
|
+
NormalEvery int
|
|
22
|
+
Target string
|
|
23
|
+
Rate int
|
|
24
|
+
Clients int
|
|
25
|
+
Duration time.Duration
|
|
26
|
+
MaxRequests int
|
|
27
|
+
}
|
|
28
|
+
type Report struct {
|
|
29
|
+
CanarySent uint64 `json:"canarySent"`
|
|
30
|
+
CanarySucceeded uint64 `json:"canarySucceeded"`
|
|
31
|
+
Offered uint64 `json:"offered"`
|
|
32
|
+
Interrupted bool `json:"interrupted"`
|
|
33
|
+
Sent uint64 `json:"sent"`
|
|
34
|
+
Completed uint64 `json:"completed"`
|
|
35
|
+
Errors uint64 `json:"errors"`
|
|
36
|
+
RateLimited uint64 `json:"rateLimited"`
|
|
37
|
+
Unavailable uint64 `json:"unavailable"`
|
|
38
|
+
Dropped uint64 `json:"dropped"`
|
|
39
|
+
StatusClasses [6]uint64 `json:"statusClasses"`
|
|
40
|
+
LatencyBuckets [7]uint64 `json:"latencyBuckets"`
|
|
41
|
+
Elapsed time.Duration `json:"elapsedNanoseconds"`
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
func (c Config) validate() (*url.URL, error) {
|
|
45
|
+
u, err := url.Parse(c.Target)
|
|
46
|
+
if err != nil || u.Scheme != "http" || u.User != nil || u.Fragment != "" || u.RawQuery != "" {
|
|
47
|
+
return nil, errors.New("target must be an HTTP loopback URL without credentials, query, or fragment")
|
|
48
|
+
}
|
|
49
|
+
ip, err := netip.ParseAddr(u.Hostname())
|
|
50
|
+
if err != nil || !ip.IsLoopback() || ip.Zone() != "" {
|
|
51
|
+
return nil, errors.New("target must use a literal loopback IP")
|
|
52
|
+
}
|
|
53
|
+
if u.Path != "/api/health" && u.Path != "/api/realtime" && u.Path != "/api/collections/pb_sim_records/records" {
|
|
54
|
+
return nil, errors.New("supported read-only targets are health, realtime and the pb_sim_records fixture")
|
|
55
|
+
}
|
|
56
|
+
if c.NormalEvery < 0 || c.NormalEvery == 1 || c.NormalEvery > 100 {
|
|
57
|
+
return nil, errors.New("normalEvery must be zero or 2 to 100")
|
|
58
|
+
}
|
|
59
|
+
if c.Rate < 1 || c.Rate > 10000 || c.Clients < 1 || c.Clients > 512 || c.Duration < time.Millisecond || c.Duration > 10*time.Minute || c.MaxRequests < 1 || c.MaxRequests > 1000000 {
|
|
60
|
+
return nil, errors.New("limits: 1..10000 requests/s, 1..512 clients, up to 10 minutes and 1000000 requests")
|
|
61
|
+
}
|
|
62
|
+
return u, nil
|
|
63
|
+
}
|
|
64
|
+
func Validate(c Config) error { _, err := c.validate(); return err }
|
|
65
|
+
|
|
66
|
+
func Run(ctx context.Context, c Config) (Report, error) {
|
|
67
|
+
callerCtx := ctx
|
|
68
|
+
u, err := c.validate()
|
|
69
|
+
if err != nil {
|
|
70
|
+
return Report{}, err
|
|
71
|
+
}
|
|
72
|
+
dialer := &net.Dialer{Timeout: 2 * time.Second}
|
|
73
|
+
transport := &http.Transport{MaxConnsPerHost: c.Clients, MaxIdleConnsPerHost: c.Clients, MaxIdleConns: c.Clients, ResponseHeaderTimeout: 2 * time.Second, DisableCompression: true, DialContext: func(ctx context.Context, network, address string) (net.Conn, error) {
|
|
74
|
+
expected := u.Host
|
|
75
|
+
if u.Port() == "" {
|
|
76
|
+
expected = net.JoinHostPort(u.Hostname(), "80")
|
|
77
|
+
}
|
|
78
|
+
if address != expected {
|
|
79
|
+
return nil, fmt.Errorf("unexpected target address")
|
|
80
|
+
}
|
|
81
|
+
return dialer.DialContext(ctx, network, address)
|
|
82
|
+
}}
|
|
83
|
+
defer transport.CloseIdleConnections()
|
|
84
|
+
client := &http.Client{Transport: transport, Timeout: 3 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
|
|
85
|
+
ctx, cancel := context.WithTimeout(ctx, c.Duration)
|
|
86
|
+
defer cancel()
|
|
87
|
+
var report Report
|
|
88
|
+
var mu sync.Mutex
|
|
89
|
+
var sent atomic.Uint64
|
|
90
|
+
jobs := make(chan int)
|
|
91
|
+
var wg sync.WaitGroup
|
|
92
|
+
start := time.Now()
|
|
93
|
+
for i := 0; i < c.Clients; i++ {
|
|
94
|
+
wg.Go(func() {
|
|
95
|
+
for sequence := range jobs {
|
|
96
|
+
destination := *u
|
|
97
|
+
canary := c.NormalEvery > 0 && sequence%c.NormalEvery == 0
|
|
98
|
+
if canary {
|
|
99
|
+
destination.Path = "/api/health"
|
|
100
|
+
}
|
|
101
|
+
req, err := http.NewRequestWithContext(ctx, http.MethodGet, destination.String(), nil)
|
|
102
|
+
if err != nil {
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
req.Header.Set("User-Agent", "PocketBase-local-loadtest")
|
|
106
|
+
begin := time.Now()
|
|
107
|
+
sent.Add(1)
|
|
108
|
+
res, err := client.Do(req)
|
|
109
|
+
status := 0
|
|
110
|
+
if err == nil {
|
|
111
|
+
status = res.StatusCode
|
|
112
|
+
if destination.Path == "/api/realtime" && status == 200 {
|
|
113
|
+
scanner := bufio.NewScanner(io.LimitReader(res.Body, 65537))
|
|
114
|
+
connected := false
|
|
115
|
+
eventName := ""
|
|
116
|
+
for scanner.Scan() {
|
|
117
|
+
line := scanner.Text()
|
|
118
|
+
if strings.HasPrefix(line, "event:") {
|
|
119
|
+
eventName = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
|
|
120
|
+
}
|
|
121
|
+
if eventName == "PB_CONNECT" && strings.HasPrefix(line, "data:") {
|
|
122
|
+
var data struct {
|
|
123
|
+
ClientID string `json:"clientId"`
|
|
124
|
+
}
|
|
125
|
+
connected = json.Unmarshal([]byte(strings.TrimSpace(strings.TrimPrefix(line, "data:"))), &data) == nil && data.ClientID != ""
|
|
126
|
+
break
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
if !connected {
|
|
130
|
+
err = errors.New("missing realtime connection event")
|
|
131
|
+
}
|
|
132
|
+
} else {
|
|
133
|
+
var body []byte
|
|
134
|
+
body, err = io.ReadAll(io.LimitReader(res.Body, (1<<20)+1))
|
|
135
|
+
if len(body) > 1<<20 {
|
|
136
|
+
err = errors.New("response body exceeds 1 MiB")
|
|
137
|
+
}
|
|
138
|
+
if status >= 200 && status < 300 && !json.Valid(body) {
|
|
139
|
+
err = errors.New("invalid JSON response")
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
res.Body.Close()
|
|
143
|
+
}
|
|
144
|
+
elapsed := time.Since(begin)
|
|
145
|
+
mu.Lock()
|
|
146
|
+
if canary {
|
|
147
|
+
report.CanarySent++
|
|
148
|
+
if err == nil && status >= 200 && status < 300 {
|
|
149
|
+
report.CanarySucceeded++
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if err != nil {
|
|
153
|
+
report.Errors++
|
|
154
|
+
} else {
|
|
155
|
+
report.Completed++
|
|
156
|
+
}
|
|
157
|
+
if status > 0 {
|
|
158
|
+
report.StatusClasses[min(status/100, 5)]++
|
|
159
|
+
}
|
|
160
|
+
if status == 429 {
|
|
161
|
+
report.RateLimited++
|
|
162
|
+
}
|
|
163
|
+
if status == 503 {
|
|
164
|
+
report.Unavailable++
|
|
165
|
+
}
|
|
166
|
+
bucket := 6
|
|
167
|
+
for i, limit := range []time.Duration{time.Millisecond, 5 * time.Millisecond, 10 * time.Millisecond, 50 * time.Millisecond, 100 * time.Millisecond, time.Second} {
|
|
168
|
+
if elapsed <= limit {
|
|
169
|
+
bucket = i
|
|
170
|
+
break
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
report.LatencyBuckets[bucket]++
|
|
174
|
+
mu.Unlock()
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
ticker := time.NewTicker(time.Second / time.Duration(c.Rate))
|
|
179
|
+
defer ticker.Stop()
|
|
180
|
+
scheduled := 0
|
|
181
|
+
loop:
|
|
182
|
+
for scheduled < c.MaxRequests {
|
|
183
|
+
select {
|
|
184
|
+
case <-ctx.Done():
|
|
185
|
+
break loop
|
|
186
|
+
case <-ticker.C:
|
|
187
|
+
select {
|
|
188
|
+
case jobs <- scheduled:
|
|
189
|
+
scheduled++
|
|
190
|
+
default:
|
|
191
|
+
report.Dropped++
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
close(jobs)
|
|
196
|
+
wg.Wait()
|
|
197
|
+
report.Sent = sent.Load()
|
|
198
|
+
report.Offered = report.Sent + report.Dropped
|
|
199
|
+
report.Interrupted = callerCtx.Err() != nil
|
|
200
|
+
report.Elapsed = time.Since(start)
|
|
201
|
+
return report, nil
|
|
202
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
package loadtest
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"context"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net/http"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"strings"
|
|
9
|
+
"sync/atomic"
|
|
10
|
+
"testing"
|
|
11
|
+
"time"
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
func TestRejectExternalTargets(t *testing.T) {
|
|
15
|
+
for _, target := range []string{"http://example.com/api/health", "http://192.0.2.1/api/health", "http://localhost/api/health", "http://127.0.0.1/api/collections/users/records", "http://user:secret@127.0.0.1/api/health", "http://127.0.0.1/api/health?url=example.com"} {
|
|
16
|
+
_, err := Run(context.Background(), Config{Target: target, Rate: 1, Clients: 1, Duration: time.Second, MaxRequests: 1})
|
|
17
|
+
if err == nil {
|
|
18
|
+
t.Fatalf("accepted %s", target)
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
func TestBudgetAndNoRedirect(t *testing.T) {
|
|
23
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
24
|
+
http.Redirect(w, r, "http://192.0.2.1/", http.StatusFound)
|
|
25
|
+
}))
|
|
26
|
+
defer server.Close()
|
|
27
|
+
report, err := Run(context.Background(), Config{Target: server.URL + "/api/health", Rate: 100, Clients: 2, Duration: time.Second, MaxRequests: 5})
|
|
28
|
+
if err != nil {
|
|
29
|
+
t.Fatal(err)
|
|
30
|
+
}
|
|
31
|
+
if report.Sent != 5 || report.Completed != 5 || report.StatusClasses[3] != 5 {
|
|
32
|
+
t.Fatalf("unexpected report %+v", report)
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
func TestCancellation(t *testing.T) {
|
|
36
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() }))
|
|
37
|
+
defer server.Close()
|
|
38
|
+
ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond)
|
|
39
|
+
defer cancel()
|
|
40
|
+
start := time.Now()
|
|
41
|
+
report, err := Run(ctx, Config{Target: server.URL + "/api/realtime", Rate: 100, Clients: 2, Duration: time.Minute, MaxRequests: 100})
|
|
42
|
+
if err != nil {
|
|
43
|
+
t.Fatal(err)
|
|
44
|
+
}
|
|
45
|
+
if time.Since(start) > time.Second || report.Sent > 2 || report.Errors != report.Sent {
|
|
46
|
+
t.Fatalf("cancellation failed %+v", report)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
func TestProtocolAndResponseBounds(t *testing.T) {
|
|
51
|
+
for _, tc := range []struct {
|
|
52
|
+
path, body string
|
|
53
|
+
valid bool
|
|
54
|
+
}{
|
|
55
|
+
{"/api/health", `{"code":200}`, true},
|
|
56
|
+
{"/api/health", `not json`, false},
|
|
57
|
+
{"/api/health", strings.Repeat(" ", 1<<20) + `{}`, false},
|
|
58
|
+
{"/api/realtime", "event: PB_CONNECT\ndata: {\"clientId\":\"fixture\"}\n\n", true},
|
|
59
|
+
{"/api/realtime", "event: PB_CONNECT\ndata: {}\n\n", false},
|
|
60
|
+
} {
|
|
61
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, tc.body) }))
|
|
62
|
+
config := Config{Target: server.URL + tc.path, Rate: 100, Clients: 1, Duration: time.Second, MaxRequests: 1}
|
|
63
|
+
if err := Validate(config); err != nil {
|
|
64
|
+
t.Fatal(err)
|
|
65
|
+
}
|
|
66
|
+
report, err := Run(context.Background(), config)
|
|
67
|
+
server.Close()
|
|
68
|
+
if err != nil || (report.Errors == 0) != tc.valid || report.Sent != 1 {
|
|
69
|
+
t.Fatalf("unexpected protocol result %+v %v", report, err)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
func TestDryValidationDoesNotContactTarget(t *testing.T) {
|
|
75
|
+
var calls atomic.Int32
|
|
76
|
+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { calls.Add(1) }))
|
|
77
|
+
defer server.Close()
|
|
78
|
+
if err := Validate(Config{Target: server.URL + "/api/health", Rate: 1, Clients: 1, Duration: time.Second, MaxRequests: 1}); err != nil {
|
|
79
|
+
t.Fatal(err)
|
|
80
|
+
}
|
|
81
|
+
if calls.Load() != 0 {
|
|
82
|
+
t.Fatal("validation sent traffic")
|
|
83
|
+
}
|
|
84
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Validation messages
|
|
2
|
+
|
|
3
|
+
Opt-in error-code translations for a custom Go binary:
|
|
4
|
+
|
|
5
|
+
```go
|
|
6
|
+
localization.Register(app, localization.Catalog{
|
|
7
|
+
"de": {"validation_required": "Pflichtfeld"},
|
|
8
|
+
"fr": {"validation_required": "Champ obligatoire"},
|
|
9
|
+
})
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Clients select a language with `?lang=de` (including SDK query options). Exact tags
|
|
13
|
+
are tried before progressively shorter language tags. Missing translations keep
|
|
14
|
+
PocketBase's English message. This changes only normalized public validation
|
|
15
|
+
messages, preserving status, error codes and parameters. It never translates raw
|
|
16
|
+
internal errors or mutates shared validation errors. Top-level API messages remain
|
|
17
|
+
unchanged. Translations are startup configuration; no new settings database or
|
|
18
|
+
admin translation application is introduced.
|
|
19
|
+
|
|
20
|
+
`catalogue.json` lists literal codes/messages found in PocketBase and its pinned
|
|
21
|
+
validation dependency. Regenerate with `the root repository’s `python3 scripts/localization/catalogue.py``.
|
|
22
|
+
Dynamic/custom plugin error codes are not enumerable and must be supplied by the
|
|
23
|
+
application. Catalogue messages are text, not HTML; clients must render them as text.
|