Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions tests/time_stress/README.md
Original file line number Diff line number Diff line change
@@ -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 |
162 changes: 162 additions & 0 deletions tests/time_stress/api_client.go
Original file line number Diff line number Diff line change
@@ -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"
}
38 changes: 38 additions & 0 deletions tests/time_stress/case_loader.go
Original file line number Diff line number Diff line change
@@ -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
}
97 changes: 97 additions & 0 deletions tests/time_stress/config.go
Original file line number Diff line number Diff line change
@@ -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
}
}
12 changes: 12 additions & 0 deletions tests/time_stress/env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
API_BASE_URL=https://shutter-api.chiado.staging.shutter.network/api/
# AUTH_HEADER=Authorization:Bearer <token>

# 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
Loading