From c1b00d214e93812421e42cc78d9436f76a7d309a Mon Sep 17 00:00:00 2001 From: Konrad Feldmeier Date: Thu, 11 Jun 2026 14:29:45 +0200 Subject: [PATCH 1/2] Add stress test for time based key generation --- tests/time_stress/README.md | 39 ++++++ tests/time_stress/api_client.go | 162 ++++++++++++++++++++++ tests/time_stress/case_loader.go | 38 +++++ tests/time_stress/config.go | 97 +++++++++++++ tests/time_stress/env.example | 12 ++ tests/time_stress/runner.go | 155 +++++++++++++++++++++ tests/time_stress/testdata/cases.json | 8 ++ tests/time_stress/time_smoke_live_test.go | 95 +++++++++++++ tests/time_stress/types.go | 53 +++++++ tests/time_stress/utils.go | 26 ++++ 10 files changed, 685 insertions(+) create mode 100644 tests/time_stress/README.md create mode 100644 tests/time_stress/api_client.go create mode 100644 tests/time_stress/case_loader.go create mode 100644 tests/time_stress/config.go create mode 100644 tests/time_stress/env.example create mode 100644 tests/time_stress/runner.go create mode 100644 tests/time_stress/testdata/cases.json create mode 100644 tests/time_stress/time_smoke_live_test.go create mode 100644 tests/time_stress/types.go create mode 100644 tests/time_stress/utils.go diff --git a/tests/time_stress/README.md b/tests/time_stress/README.md new file mode 100644 index 0000000..0b38a36 --- /dev/null +++ b/tests/time_stress/README.md @@ -0,0 +1,39 @@ +# time_stress + +Stress tests for time-based decryption key registration. Registers N identities in parallel for the same decryption timestamp and asserts that all receive distinct keys once the timestamp is reached. + +## Setup + +Copy the example env file and set the API URL: + +```sh +cp env.example .env +# edit .env +``` + +## Run + +```sh +go test -count=1 -tags=live ./tests/time_stress -v +``` + +Run a specific case: + +```sh +CASES=stress_100 go test -count=1 -tags=live ./tests/time_stress -v +``` + +## Configuration + +| Variable | Default | Description | +|---|---|---| +| `API_BASE_URL` | required | Base URL of the Shutter API | +| `AUTH_HEADER` | | Optional auth header, format `Key:Value` | +| `TIME_DECRYPTION_OFFSET_SECONDS` | `90` | How far in the future to set the decryption timestamp | +| `REG_CONCURRENCY` | `1` | Max parallel registration requests | +| `REGISTRATION_DELAY_SECONDS` | `2` | Sleep after all registrations are submitted | +| `POLL_SECONDS` | `130` | Total time to poll for decryption keys | +| `POLL_INTERVAL` | `2` | Seconds between poll attempts | +| `MAX_CONSEC_TIMEOUTS` | `5` | Abort a case after this many consecutive API timeouts | +| `VERBOSE` | `true` | Log individual HTTP requests and responses | +| `CASES_FILE` | `testdata/cases.json` | Path to the test cases file | diff --git a/tests/time_stress/api_client.go b/tests/time_stress/api_client.go new file mode 100644 index 0000000..f8b7adf --- /dev/null +++ b/tests/time_stress/api_client.go @@ -0,0 +1,162 @@ +package timestress + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +type registerReq struct { + DecryptionTimestamp uint64 `json:"decryptionTimestamp"` + IdentityPrefix string `json:"identityPrefix"` +} + +func registerTimeIdentity(cfg *Config, timestamp uint64) (identity, txHash, prefix string, err error) { + b := make([]byte, 32) + if _, err = rand.Read(b); err != nil { + return "", "", "", err + } + prefix = "0x" + hex.EncodeToString(b) + + req := registerReq{ + DecryptionTimestamp: timestamp, + IdentityPrefix: prefix, + } + + var payload map[string]any + if err = postJSON(cfg, "/time/register_identity", req, &payload); err != nil { + return "", "", "", err + } + + root := payload + if m, ok := payload["message"].(map[string]any); ok { + root = m + } + + identity = str(root["identity"]) + txHash = str(root["tx_hash"]) + + if identity == "" || txHash == "" { + return "", "", "", errors.New(extractErr(payload)) + } + return +} + +func getTimeDecryptionKey(cfg *Config, identity string) (key, msg string, ok bool) { + u, _ := url.Parse(cfg.APIBase + "/time/get_decryption_key") + q := u.Query() + q.Set("identity", identity) + u.RawQuery = q.Encode() + + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Second) + defer cancel() + + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + applyAuthHeader(req, cfg.AuthHeader) + + resp, err := cfg.HTTPClient.Do(req) + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || strings.Contains(strings.ToLower(err.Error()), "timeout") { + return "", "api timeout (keyper fallback likely hanging)", false + } + return "", err.Error(), false + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + raw := strings.TrimSpace(string(body)) + logf(cfg, "GET %s status=%d body=%s", u.String(), resp.StatusCode, raw) + + if strings.HasPrefix(raw, "0x") && len(raw) > 2 { + return raw, "", true + } + + var m map[string]any + _ = json.Unmarshal(body, &m) + + if v := str(m["decryption_key"]); v != "" { + if strings.HasPrefix(v, "0x") && len(v) > 2 { + return v, "", true + } + return "", fmt.Sprintf("decryption_key present but unexpected format: %q", v), false + } + if msgObj, ok2 := m["message"].(map[string]any); ok2 { + if v := str(msgObj["decryption_key"]); v != "" { + if strings.HasPrefix(v, "0x") && len(v) > 2 { + return v, "", true + } + return "", fmt.Sprintf("decryption_key present but unexpected format: %q", v), false + } + } + + if resp.StatusCode >= 400 { + return "", fmt.Sprintf("http %d: %s", resp.StatusCode, raw), false + } + + e := extractErr(m) + if e == "unknown error" && raw != "" { + e = raw + } + return "", e, false +} + +func postJSON(cfg *Config, path string, body any, out any) error { + reqBytes, _ := json.Marshal(body) + fullURL := cfg.APIBase + path + + req, _ := http.NewRequest(http.MethodPost, fullURL, bytes.NewReader(reqBytes)) + req.Header.Set("Content-Type", "application/json") + applyAuthHeader(req, cfg.AuthHeader) + + logf(cfg, "POST %s body=%s", fullURL, string(reqBytes)) + resp, err := cfg.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + respBytes, _ := io.ReadAll(resp.Body) + logf(cfg, "POST %s status=%d body=%s", fullURL, resp.StatusCode, strings.TrimSpace(string(respBytes))) + + _ = json.Unmarshal(respBytes, out) + if resp.StatusCode >= 400 { + return fmt.Errorf("http %d: %s", resp.StatusCode, strings.TrimSpace(string(respBytes))) + } + return nil +} + +func applyAuthHeader(req *http.Request, authHeader string) { + if strings.TrimSpace(authHeader) == "" { + return + } + p := strings.SplitN(authHeader, ":", 2) + if len(p) != 2 { + return + } + req.Header.Set(strings.TrimSpace(p[0]), strings.TrimSpace(p[1])) +} + +func extractErr(m map[string]any) string { + if s := str(m["description"]); s != "" { + return s + } + if s := str(m["error"]); s != "" { + return s + } + if s := str(m["message"]); s != "" { + return s + } + if errs, ok := m["errors"].([]any); ok && len(errs) > 0 { + return fmt.Sprint(errs[0]) + } + return "unknown error" +} diff --git a/tests/time_stress/case_loader.go b/tests/time_stress/case_loader.go new file mode 100644 index 0000000..d0fb0e6 --- /dev/null +++ b/tests/time_stress/case_loader.go @@ -0,0 +1,38 @@ +package timestress + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +type jsonCase struct { + Name string `json:"name"` + Description string `json:"description"` + Count int `json:"count"` + Expected string `json:"expected"` // "pass" | "fail" +} + +func LoadCasesFromJSON(path string) ([]TestCase, error) { + b, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read cases file: %w", err) + } + + var raw []jsonCase + if err := json.Unmarshal(b, &raw); err != nil { + return nil, fmt.Errorf("parse cases json: %w", err) + } + + out := make([]TestCase, 0, len(raw)) + for _, c := range raw { + out = append(out, TestCase{ + Name: c.Name, + Description: c.Description, + Count: c.Count, + ExpectKeys: !strings.EqualFold(strings.TrimSpace(c.Expected), "fail"), + }) + } + return out, nil +} diff --git a/tests/time_stress/config.go b/tests/time_stress/config.go new file mode 100644 index 0000000..5cd57b9 --- /dev/null +++ b/tests/time_stress/config.go @@ -0,0 +1,97 @@ +package timestress + +import ( + "fmt" + "net/http" + "os" + "strconv" + "strings" + "time" +) + +type Config struct { + APIBase string + PollSeconds int + PollInterval int + AuthHeader string + Verbose bool + MaxConsecTimeouts int + HTTPClient *http.Client + CasesFile string + TimeDecryptionOffset time.Duration + RegConcurrency int + RegistrationDelay time.Duration +} + +func LoadConfigFromEnv() (*Config, error) { + apiBase, err := mustEnv("API_BASE_URL") + if err != nil { + return nil, err + } + + pollSeconds := getInt("POLL_SECONDS", 130) + pollInterval := getInt("POLL_INTERVAL", 2) + verbose := getBool("VERBOSE", true) + maxTimeouts := getInt("MAX_CONSEC_TIMEOUTS", 5) + casesFile := getEnv("CASES_FILE", "testdata/cases.json") + timeDecryptionOffsetSeconds := getInt("TIME_DECRYPTION_OFFSET_SECONDS", 90) + regConcurrency := getInt("REG_CONCURRENCY", 1) + regDelaySeconds := getInt("REGISTRATION_DELAY_SECONDS", 2) + + return &Config{ + APIBase: strings.TrimRight(apiBase, "/"), + PollSeconds: pollSeconds, + PollInterval: pollInterval, + AuthHeader: strings.TrimSpace(os.Getenv("AUTH_HEADER")), + Verbose: verbose, + MaxConsecTimeouts: maxTimeouts, + HTTPClient: &http.Client{Timeout: 5 * time.Second}, + CasesFile: casesFile, + TimeDecryptionOffset: time.Duration(timeDecryptionOffsetSeconds) * time.Second, + RegConcurrency: regConcurrency, + RegistrationDelay: time.Duration(regDelaySeconds) * time.Second, + }, nil +} + +func mustEnv(k string) (string, error) { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return "", fmt.Errorf("missing required env var %s", k) + } + return v, nil +} + +func getEnv(k, d string) string { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + return v +} + +func getInt(k string, d int) int { + v := strings.TrimSpace(os.Getenv(k)) + if v == "" { + return d + } + n, err := strconv.Atoi(v) + if err != nil { + return d + } + return n +} + +func getBool(k string, d bool) bool { + v := strings.TrimSpace(strings.ToLower(os.Getenv(k))) + if v == "" { + return d + } + switch v { + case "1", "true", "yes", "y", "on": + return true + case "0", "false", "no", "n", "off": + return false + default: + return d + } +} diff --git a/tests/time_stress/env.example b/tests/time_stress/env.example new file mode 100644 index 0000000..59c8ad7 --- /dev/null +++ b/tests/time_stress/env.example @@ -0,0 +1,12 @@ +API_BASE_URL=https://shutter-api.chiado.staging.shutter.network/api/ +# AUTH_HEADER=Authorization:Bearer + +# Tuning (all optional, defaults shown) +# TIME_DECRYPTION_OFFSET_SECONDS=90 +# REG_CONCURRENCY=1 +# REGISTRATION_DELAY_SECONDS=2 +# POLL_SECONDS=130 +# POLL_INTERVAL=2 +# MAX_CONSEC_TIMEOUTS=5 +# VERBOSE=true +# CASES_FILE=testdata/cases.json diff --git a/tests/time_stress/runner.go b/tests/time_stress/runner.go new file mode 100644 index 0000000..237f6b7 --- /dev/null +++ b/tests/time_stress/runner.go @@ -0,0 +1,155 @@ +package timestress + +import ( + "fmt" + "strings" + "time" +) + +func runCase(cfg *Config, tc TestCase) Result { + n := tc.Count + decryptionTimestamp := uint64(time.Now().Add(cfg.TimeDecryptionOffset).Unix()) + fmt.Printf("[%s] target decryptionTimestamp=%d (offset=%s)\n", tc.Name, decryptionTimestamp, cfg.TimeDecryptionOffset) + + type regEntry struct { + idx int + identity string + txHash string + prefix string + err error + } + + fmt.Printf("[%s] registering %d identities in parallel (concurrency=%d)\n", tc.Name, n, cfg.RegConcurrency) + regCh := make(chan regEntry, n) + sem := make(chan struct{}, cfg.RegConcurrency) + for i := 0; i < n; i++ { + i := i + go func() { + sem <- struct{}{} + defer func() { <-sem }() + identity, txHash, prefix, err := registerTimeIdentity(cfg, decryptionTimestamp) + regCh <- regEntry{i, identity, txHash, prefix, err} + }() + } + + type reg struct { + identity string + } + regs := make([]reg, 0, n) + for i := 0; i < n; i++ { + r := <-regCh + if r.err != nil { + return Result{tc.Name, "FAIL", fmt.Sprintf("register %d: %s", r.idx+1, r.err.Error())} + } + logf(cfg, "[%s] reg[%d] identity=%s prefix=%s", tc.Name, r.idx+1, r.identity, r.prefix) + regs = append(regs, reg{r.identity}) + } + + fmt.Printf("[%s] all %d registrations submitted (sleep %s)\n", tc.Name, n, cfg.RegistrationDelay) + time.Sleep(cfg.RegistrationDelay) + + targetTime := time.Unix(int64(decryptionTimestamp), 0) + if remaining := time.Until(targetTime); remaining > 0 { + fmt.Printf("[%s] waiting %s for decryption timestamp\n", tc.Name, remaining.Round(time.Second)) + time.Sleep(remaining) + } + + if !tc.ExpectKeys { + return Result{ + Name: tc.Name, + Status: "PASS", + Reason: fmt.Sprintf("registered %d identities, no keys expected", n), + } + } + + fmt.Printf("[%s] polling for %d keys\n", tc.Name, n) + deadline := time.Now().Add(time.Duration(cfg.PollSeconds) * time.Second) + keys := make(map[string]string, n) // identity -> key + timeouts := make(map[string]int, n) + + for time.Now().Before(deadline) && len(keys) < n { + for _, r := range regs { + if _, found := keys[r.identity]; found { + continue + } + key, msg, ok := getTimeDecryptionKey(cfg, r.identity) + if ok { + keys[r.identity] = key + logf(cfg, "[%s] got key for identity=%s key=%s", tc.Name, shortHex(r.identity, 10), shortHex(key, 18)) + continue + } + logf(cfg, "[%s] pending identity=%s msg=%s", tc.Name, shortHex(r.identity, 10), msg) + + if strings.Contains(strings.ToLower(msg), "timeout") { + timeouts[r.identity]++ + if timeouts[r.identity] >= cfg.MaxConsecTimeouts { + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("aborted after %d timeouts for identity %s: %s", timeouts[r.identity], r.identity, msg), + } + } + } else { + timeouts[r.identity] = 0 + } + + if !isTransient(msg) && !isTerminalNotFound(msg) { + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("non-transient error for identity %s: %s", r.identity, msg), + } + } + } + if len(keys) < n { + time.Sleep(time.Duration(cfg.PollInterval) * time.Second) + } + } + + if len(keys) < n { + missing := make([]string, 0, n-len(keys)) + for _, r := range regs { + if _, found := keys[r.identity]; !found { + missing = append(missing, shortHex(r.identity, 10)) + } + } + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("timeout: only %d/%d keys received, missing: %v", len(keys), n, missing), + } + } + + seen := make(map[string]string, n) // key -> identity + for identity, key := range keys { + if prev, dup := seen[key]; dup { + return Result{ + Name: tc.Name, + Status: "FAIL", + Reason: fmt.Sprintf("duplicate key %s for identities %s and %s", shortHex(key, 18), shortHex(prev, 10), shortHex(identity, 10)), + } + } + seen[key] = identity + } + + return Result{ + Name: tc.Name, + Status: "PASS", + Reason: fmt.Sprintf("received %d distinct decryption keys for %d registrations", n, n), + } +} + +func isTerminalNotFound(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "http 404") || + strings.Contains(m, "doesn't exist") || + strings.Contains(m, "doesnt exist") || + strings.Contains(m, "not found") +} + +func isTransient(msg string) bool { + m := strings.ToLower(msg) + return strings.Contains(m, "too early") || + strings.Contains(m, "not ready") || + strings.Contains(m, "timeout") +} diff --git a/tests/time_stress/testdata/cases.json b/tests/time_stress/testdata/cases.json new file mode 100644 index 0000000..c1b7e72 --- /dev/null +++ b/tests/time_stress/testdata/cases.json @@ -0,0 +1,8 @@ +[ + { + "name": "stress_100", + "description": "100 time-based identities registered in parallel for the same decryption timestamp", + "count": 100, + "expected": "pass" + } +] diff --git a/tests/time_stress/time_smoke_live_test.go b/tests/time_stress/time_smoke_live_test.go new file mode 100644 index 0000000..4b6ce6b --- /dev/null +++ b/tests/time_stress/time_smoke_live_test.go @@ -0,0 +1,95 @@ +//go:build live + +package timestress + +import ( + "bufio" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTimeSmokeCases(t *testing.T) { + loadDotEnv() + + cfg, err := LoadConfigFromEnv() + if err != nil { + t.Skipf("live env not configured: %v", err) + } + + allCases, err := LoadCasesFromJSON(cfg.CasesFile) + if err != nil { + t.Fatalf("load cases: %v", err) + } + + cases, err := FilterCases(allCases, os.Getenv("CASES")) + if err != nil { + t.Fatalf("filter cases: %v", err) + } + if len(cases) == 0 { + t.Fatalf("no test cases selected") + } + + logf(cfg, "config api=%s poll=%ds/%ds verbose=%t offset=%s concurrency=%d cases=%d", + cfg.APIBase, cfg.PollSeconds, cfg.PollInterval, cfg.Verbose, + cfg.TimeDecryptionOffset, cfg.RegConcurrency, len(cases)) + + for _, tc := range cases { + tc := tc + t.Run(tc.Name, func(t *testing.T) { + t.Logf("%s", tc.Description) + r := runCase(cfg, tc) + if r.Status != "PASS" { + t.Fatalf("%s", r.Reason) + } + t.Logf("pass: %s", r.Reason) + }) + } +} + +func loadDotEnv() { + candidates := []string{ + ".env", + filepath.Join("tests", "time_stress", ".env"), + filepath.Join("..", "..", ".env"), + } + for _, p := range candidates { + if _, err := os.Stat(p); err == nil { + _ = loadEnvFile(p) + } + } +} + +func loadEnvFile(path string) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer f.Close() + + sc := bufio.NewScanner(f) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" || strings.HasPrefix(line, "#") { + continue + } + if strings.HasPrefix(line, "export ") { + line = strings.TrimSpace(strings.TrimPrefix(line, "export ")) + } + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + val := strings.Trim(strings.TrimSpace(parts[1]), `"'`) + if key == "" { + continue + } + if _, exists := os.LookupEnv(key); exists { + continue + } + _ = os.Setenv(key, val) + } + return sc.Err() +} diff --git a/tests/time_stress/types.go b/tests/time_stress/types.go new file mode 100644 index 0000000..e2da106 --- /dev/null +++ b/tests/time_stress/types.go @@ -0,0 +1,53 @@ +package timestress + +import ( + "fmt" + "sort" + "strings" +) + +type TestCase struct { + Name string + Description string + Count int // number of identities to register + ExpectKeys bool // whether all identities should receive a decryption key +} + +type Result struct { + Name string + Status string + Reason string +} + +func FilterCases(all []TestCase, filter string) ([]TestCase, error) { + filter = strings.TrimSpace(filter) + if filter == "" { + return all, nil + } + + want := map[string]bool{} + for _, part := range strings.Split(filter, ",") { + k := strings.TrimSpace(part) + if k != "" { + want[k] = true + } + } + + out := make([]TestCase, 0, len(all)) + for _, tc := range all { + if want[tc.Name] { + out = append(out, tc) + delete(want, tc.Name) + } + } + + if len(want) > 0 { + missing := make([]string, 0, len(want)) + for k := range want { + missing = append(missing, k) + } + sort.Strings(missing) + return nil, fmt.Errorf("unknown CASES entries: %s", strings.Join(missing, ",")) + } + return out, nil +} diff --git a/tests/time_stress/utils.go b/tests/time_stress/utils.go new file mode 100644 index 0000000..4cb997d --- /dev/null +++ b/tests/time_stress/utils.go @@ -0,0 +1,26 @@ +package timestress + +import ( + "fmt" + "time" +) + +func logf(cfg *Config, format string, args ...any) { + if !cfg.Verbose { + return + } + ts := time.Now().Format("2006-01-02 15:04:05.000") + fmt.Printf("[%s] %s\n", ts, fmt.Sprintf(format, args...)) +} + +func shortHex(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] + "..." +} + +func str(v any) string { + s, _ := v.(string) + return s +} From 4aee34bbdc661257ee084b4f27cf4a10bdcd826c Mon Sep 17 00:00:00 2001 From: Konrad Feldmeier Date: Thu, 11 Jun 2026 16:23:40 +0200 Subject: [PATCH 2/2] Add a test for 100 time based registrations --- tests/time_stress/testdata/cases.json | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/time_stress/testdata/cases.json b/tests/time_stress/testdata/cases.json index c1b7e72..9b17a27 100644 --- a/tests/time_stress/testdata/cases.json +++ b/tests/time_stress/testdata/cases.json @@ -4,5 +4,11 @@ "description": "100 time-based identities registered in parallel for the same decryption timestamp", "count": 100, "expected": "pass" + }, + { + "name": "stress_600", + "description": "600 time-based identities registered in parallel for the same decryption timestamp", + "count": 600, + "expected": "pass" } -] +] \ No newline at end of file