diff --git a/go/README.md b/go/README.md new file mode 100644 index 0000000..8ec34cf --- /dev/null +++ b/go/README.md @@ -0,0 +1,43 @@ +# aere-cloud (Go) + +The official Go client for the [Aere Cloud API](https://aere.network/cloud.html) on Aere Network (chain 2800). +**Zero dependencies** beyond the Go standard library. Same routes and semantics as the JS, Python and Java +clients 1.5.0. + +JSON routes return the server's JSON as `json.RawMessage`, so you decode it into the types you already use. The two +routes that return files (the audit export and the check of a kept export) return typed results, with the SHA-256 +computed locally from the bytes received. + +## Use + +```go +import aerecloud "git.aere.network/aere-network/aere-cloud-sdk/go" + +c, err := aerecloud.New(os.Getenv("AERE_KEY")) +head, err := c.DataHead(ctx) // {"block":...,"chainId":2800,...} +n, err := c.BlockNumber(ctx) + +// quantum-readiness scan of a domain's TLS edge: free, no key is sent unless you attest the report +report, err := c.Readiness(ctx, "example.com", false, false) + +// the audit log of a day as a file; Verified is true only if the sha256 of the bytes you received equals the digest +day, err := c.AuditExport(ctx, "2026-09-24", "jsonl") + +// a kept export still matches the day we hold, or the tombstone a deleted day left behind +k, err := c.AuditVerifyKept(ctx, "2026-05-01", keptBytes) + +// verify a webhook delivery (constant-time HMAC-SHA256 over the exact bytes received) +ok := aerecloud.VerifyWebhook(rawBody, r.Header.Get("x-aere-signature"), secret) +``` + +Non-2xx answers come back as `*aerecloud.Error` with `Status`, `Body` and `Path`. The client does not follow +redirects: a 3xx is returned as an answer, so your API key is never sent to a host you did not name. + +## Tests + +``` +go test ./... # offline, against a local httptest server +node control-negativ.mjs # breaks six guards in a copy of the package; each one's test must fail +``` + +Measured 2026-09-25: 7 tests green; the negative control 6 of 6 red, the untouched package green. diff --git a/go/aerecloud.go b/go/aerecloud.go new file mode 100644 index 0000000..3818f79 --- /dev/null +++ b/go/aerecloud.go @@ -0,0 +1,353 @@ +// Package aerecloud is the official Go client for the Aere Cloud API (Aere Network, chain 2800). +// +// Same routes and semantics as @aere/cloud (JS), aere-cloud (Python) and aere-cloud (Java) 1.5.0. Zero dependencies +// beyond the Go standard library. JSON routes return the server's JSON as json.RawMessage, so the integrator decodes it +// into whatever types they already use; the two routes that return FILES (the audit export and the check of a kept +// export) return typed results with the digest computed locally from the bytes received. +// +// Non-2xx answers are returned as *Error with the HTTP status, the server's body and the path. +package aerecloud + +// Nota interna: comentariile de implementare sunt in romana fara diacritice, ca in celelalte SDK-uri ale casei; +// documentatia publica a API-ului (comentariile exportate) e in engleza. + +import ( + "bytes" + "context" + "crypto/hmac" + "crypto/sha256" + "crypto/subtle" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +// Version of this client, sent in the User-Agent (the edge refuses generic library agents on every route). +const Version = "1.5.0" + +// DefaultBaseURL is the production gateway. +const DefaultBaseURL = "https://cloud.aere.network/v1" + +// Error is a non-2xx answer from Aere Cloud (Status 0 means the request never got an answer). +type Error struct { + Status int + Body []byte + Path string +} + +func (e *Error) Error() string { + b := string(e.Body) + if b == "" { + b = "request failed" + } + return fmt.Sprintf("Aere Cloud %d on %s: %s", e.Status, e.Path, b) +} + +// Client talks to one Aere Cloud gateway with one API key (ak2800.
.). +type Client struct { + apiKey string + baseURL string + http *http.Client +} + +// New returns a client for the production gateway. The API key is required. +func New(apiKey string) (*Client, error) { return NewWithBase(apiKey, DefaultBaseURL, nil) } + +// NewWithBase returns a client for another base URL (a test server) and an optional *http.Client. +func NewWithBase(apiKey, baseURL string, hc *http.Client) (*Client, error) { + if apiKey == "" { + return nil, errors.New("aerecloud: apiKey is required (ak2800.
.)") + } + if hc == nil { + // fara redirectari urmate: un 3xx e un raspuns, nu un drum spre alta gazda care ar primi cheia + hc = &http.Client{Timeout: 30 * time.Second, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + } + return &Client{apiKey: apiKey, baseURL: strings.TrimRight(baseURL, "/"), http: hc}, nil +} + +type raspuns struct { + status int + body []byte + headers http.Header +} + +func (c *Client) do(ctx context.Context, method, path string, body any, auth bool) (*raspuns, error) { + var rd io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return nil, err + } + rd = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, rd) + if err != nil { + return nil, err + } + req.Header.Set("user-agent", "aere-cloud-go/"+Version) + if body != nil { + req.Header.Set("content-type", "application/json") + } + if auth { + req.Header.Set("x-api-key", c.apiKey) + } + r, err := c.http.Do(req) + if err != nil { + // mesajul de retea nu poarta cheia (e in antet, nu in URL) + return nil, &Error{Status: 0, Body: []byte(err.Error()), Path: path} + } + defer r.Body.Close() + b, err := io.ReadAll(r.Body) + if err != nil { + return nil, &Error{Status: 0, Body: []byte(err.Error()), Path: path} + } + return &raspuns{status: r.StatusCode, body: b, headers: r.Header}, nil +} + +func (c *Client) jsonReq(ctx context.Context, method, path string, body any, auth bool) (json.RawMessage, error) { + r, err := c.do(ctx, method, path, body, auth) + if err != nil { + return nil, err + } + if r.status < 200 || r.status >= 300 { + return nil, &Error{Status: r.status, Body: r.body, Path: path} + } + return json.RawMessage(r.body), nil +} + +func esc(s string) string { return url.PathEscape(s) } + +// ---- health, account, JSON-RPC ---- + +func (c *Client) Health(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/health", nil, false) +} +func (c *Client) Account(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/account", nil, true) +} + +// RPC sends one JSON-RPC call and returns the whole JSON-RPC envelope. +func (c *Client) RPC(ctx context.Context, method string, params []any) (json.RawMessage, error) { + if params == nil { + params = []any{} + } + return c.jsonReq(ctx, "POST", "/rpc", map[string]any{"jsonrpc": "2.0", "id": 1, "method": method, "params": params}, true) +} + +// BlockNumber is eth_blockNumber as an integer. +func (c *Client) BlockNumber(ctx context.Context) (uint64, error) { + b, err := c.RPC(ctx, "eth_blockNumber", nil) + if err != nil { + return 0, err + } + var env struct { + Result string `json:"result"` + } + if err := json.Unmarshal(b, &env); err != nil || !strings.HasPrefix(env.Result, "0x") { + return 0, &Error{Status: 200, Body: b, Path: "/rpc"} + } + return strconv.ParseUint(env.Result[2:], 16, 64) +} + +// ---- post-quantum verification, chain data, notarization, proofs ---- + +// PQVerify: payload like {"scheme":"ml-dsa-44","publicKey":"0x..","signature":"0x..","message":"0x.."}. +func (c *Client) PQVerify(ctx context.Context, payload any) (json.RawMessage, error) { + return c.jsonReq(ctx, "POST", "/pq/verify", payload, true) +} +func (c *Client) DataHead(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/data/head", nil, true) +} +func (c *Client) Validators(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/data/validators", nil, true) +} +func (c *Client) Anchors(ctx context.Context, limit int) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/data/anchors?limit="+strconv.Itoa(limit), nil, true) +} +func (c *Client) Anchor(ctx context.Context, height uint64) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/data/anchors/"+strconv.FormatUint(height, 10), nil, true) +} +func (c *Client) Transfers(ctx context.Context, address string, limit int) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/data/transfers?address="+url.QueryEscape(address)+"&limit="+strconv.Itoa(limit), nil, true) +} +func (c *Client) Notarize(ctx context.Context, hash string) (json.RawMessage, error) { + return c.jsonReq(ctx, "POST", "/notarize", map[string]string{"hash": hash}, true) +} +func (c *Client) ProofOf(ctx context.Context, hash string) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/notarize/"+esc(hash), nil, true) +} + +// Proof: the proof with its post-quantum finality (first appearance, covering anchor, verdict, verification steps). +func (c *Client) Proof(ctx context.Context, hash string) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/proof/"+esc(hash), nil, true) +} + +// ---- quantum readiness ---- + +// Readiness scans a domain's public TLS edge. Free without a key; attest=true notarizes the report digest and sends the key. +func (c *Client) Readiness(ctx context.Context, domain string, fresh, attest bool) (json.RawMessage, error) { + b := map[string]any{"domain": domain} + if fresh { + b["fresh"] = true + } + if attest { + b["attest"] = true + } + return c.jsonReq(ctx, "POST", "/pq/readiness", b, attest) +} +func (c *Client) ReadinessReport(ctx context.Context, domain string) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/pq/readiness/"+esc(domain), nil, false) +} + +// PerimeterOptions: Hosts may be nil; Discover nil keeps the server default; Attest notarizes only a COMPLETE report. +type PerimeterOptions struct { + Hosts []string + Discover *bool + Attest bool +} + +// ReadinessPerimeter: the whole public perimeter of a domain in one keyed report; call again while summary.complete is false. +func (c *Client) ReadinessPerimeter(ctx context.Context, domain string, o PerimeterOptions) (json.RawMessage, error) { + b := map[string]any{"domain": domain} + if o.Hosts != nil { + b["hosts"] = o.Hosts + } + if o.Discover != nil && !*o.Discover { + b["discover"] = false + } + if o.Attest { + b["attest"] = true + } + return c.jsonReq(ctx, "POST", "/pq/readiness/perimeter", b, true) +} + +// PQFinality: AIP-21 per-block post-quantum finality. client: "besu", "nethermind" or "both" (with `agreement`). No key. +func (c *Client) PQFinality(ctx context.Context, block, network, client string) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/pq/finality/"+esc(block)+"?network="+url.QueryEscape(network)+"&client="+url.QueryEscape(client), nil, false) +} + +// ---- audit log ---- + +// AuditLog: the paged JSON view of one UTC day (day "" = today), with the digest of the day file. +func (c *Client) AuditLog(ctx context.Context, day string, offset, limit int) (json.RawMessage, error) { + q := "?offset=" + strconv.Itoa(offset) + "&limit=" + strconv.Itoa(limit) + if day != "" { + q += "&day=" + url.QueryEscape(day) + } + return c.jsonReq(ctx, "GET", "/account/audit"+q, nil, true) +} + +// AuditExport is a day as a file. For jsonl, Verified reports whether the sha256 of the bytes RECEIVED equals the stated +// digest (computed here, not taken on trust); for cef it is nil, because the digest is the one of the source file. +type AuditExport struct { + Day, Format, Digest string + Bytes []byte + Entries int + Verified *bool +} + +func sha256Hex(b []byte) string { h := sha256.Sum256(b); return "0x" + hex.EncodeToString(h[:]) } + +func (c *Client) AuditExport(ctx context.Context, day, format string) (*AuditExport, error) { + if format == "" { + format = "jsonl" + } + p := "/account/audit?format=" + url.QueryEscape(format) + if day != "" { + p += "&day=" + url.QueryEscape(day) + } + r, err := c.do(ctx, "GET", p, nil, true) + if err != nil { + return nil, err + } + if r.status < 200 || r.status >= 300 { + return nil, &Error{Status: r.status, Body: r.body, Path: "/account/audit"} + } + e := &AuditExport{Day: r.headers.Get("x-aere-day"), Format: format, Digest: r.headers.Get("x-aere-digest"), Bytes: r.body} + e.Entries, _ = strconv.Atoi(r.headers.Get("x-aere-entries")) + if format == "jsonl" { + ok := e.Digest != "" && e.Digest == sha256Hex(r.body) + e.Verified = &ok + } + return e, nil +} + +// AuditRetention reads the retention of the audit log with the API key (read-only). Retention is SET with a wallet +// signature in the console, never with the key: a stolen key must not be able to shorten the trail of its own use. +func (c *Client) AuditRetention(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/account/audit/retention", nil, true) +} + +// KeptCheck: does an export you KEPT still match the day we hold ("held"), or the tombstone a deleted day left ("expired")? +type KeptCheck struct { + Day, State, Digest, Local, DeletedAt string + Matches bool +} + +// AuditVerifyKept hashes YOUR bytes locally and compares them with the day we hold or with the tombstone of a deleted day +// (410). Any other failure is returned as an error, never as "does not match". +func (c *Client) AuditVerifyKept(ctx context.Context, day string, kept []byte) (*KeptCheck, error) { + local := sha256Hex(kept) + r, err := c.do(ctx, "GET", "/account/audit?format=jsonl&day="+url.QueryEscape(day), nil, true) + if err != nil { + return nil, err + } + if r.status == 410 { + var t struct { + Expired *struct { + Digest string `json:"digest"` + DeletedAt string `json:"deletedAt"` + } `json:"expired"` + } + if json.Unmarshal(r.body, &t) == nil && t.Expired != nil && t.Expired.Digest != "" { + return &KeptCheck{Day: day, State: "expired", Digest: t.Expired.Digest, Local: local, Matches: local == t.Expired.Digest, DeletedAt: t.Expired.DeletedAt}, nil + } + } + if r.status < 200 || r.status >= 300 { + return nil, &Error{Status: r.status, Body: r.body, Path: "/account/audit"} + } + d := sha256Hex(r.body) + return &KeptCheck{Day: day, State: "held", Digest: d, Local: local, Matches: local == d}, nil +} + +// ---- webhooks and gas sponsorship ---- + +func (c *Client) ListWebhooks(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/webhooks", nil, true) +} +func (c *Client) CreateWebhook(ctx context.Context, spec any) (json.RawMessage, error) { + return c.jsonReq(ctx, "POST", "/webhooks", spec, true) +} +func (c *Client) DeleteWebhook(ctx context.Context, id string) (json.RawMessage, error) { + return c.jsonReq(ctx, "DELETE", "/webhooks/"+esc(id), nil, true) +} +func (c *Client) SponsorHealth(ctx context.Context) (json.RawMessage, error) { + return c.jsonReq(ctx, "GET", "/sponsor/health", nil, true) +} +func (c *Client) SponsorCreateAccount(ctx context.Context, body any) (json.RawMessage, error) { + return c.jsonReq(ctx, "POST", "/sponsor/createAccount", body, true) +} + +// SponsorCreatePQAccount: a smart account with a POST-QUANTUM owner (Falcon-512, 0x09-prefixed, 897 bytes), gas sponsored. +func (c *Client) SponsorCreatePQAccount(ctx context.Context, falconPubKeyHex string, salt uint64) (json.RawMessage, error) { + return c.jsonReq(ctx, "POST", "/sponsor/createPqAccount", map[string]any{"falconPubKey": falconPubKeyHex, "salt": salt}, true) +} +func (c *Client) SponsorExecute(ctx context.Context, body any) (json.RawMessage, error) { + return c.jsonReq(ctx, "POST", "/sponsor/execute", body, true) +} + +// VerifyWebhook checks a webhook delivery's HMAC-SHA256 signature in constant time. rawBody must be the EXACT bytes +// received, not re-serialized JSON. Never act on a webhook without this. +func VerifyWebhook(rawBody []byte, signatureHeader, secret string) bool { + m := hmac.New(sha256.New, []byte(secret)) + m.Write(rawBody) + want := []byte(hex.EncodeToString(m.Sum(nil))) + return subtle.ConstantTimeCompare(want, []byte(signatureHeader)) == 1 +} diff --git a/go/aerecloud_test.go b/go/aerecloud_test.go new file mode 100644 index 0000000..3dfb4f7 --- /dev/null +++ b/go/aerecloud_test.go @@ -0,0 +1,206 @@ +package aerecloud + +// Probele clientului Go, pe un server httptest LOCAL: ce trimite clientul (metoda, cale, cheia, corpul, user-agent-ul) si ce +// face cu fiecare raspuns. Fiecare proba are perechea ei negativa; controlul negativ pe paznici e control-negativ.mjs. +// go test ./... + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" +) + +type vazut struct{ Method, Path, Key, Body, UA string } + +type raspunsProba struct { + status int + body []byte + headers map[string]string +} + +type server struct { + mu sync.Mutex + vazute []vazut + raspunsuri map[string]raspunsProba + srv *httptest.Server +} + +func porneste(t *testing.T) (*server, *Client) { + s := &server{raspunsuri: map[string]raspunsProba{}} + s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + p := r.URL.EscapedPath() + if r.URL.RawQuery != "" { + p += "?" + r.URL.RawQuery + } + s.mu.Lock() + s.vazute = append(s.vazute, vazut{r.Method, p, r.Header.Get("x-api-key"), string(b), r.Header.Get("user-agent")}) + rp, ok := s.raspunsuri[r.URL.EscapedPath()] + s.mu.Unlock() + if !ok { + rp = raspunsProba{200, []byte("{}"), nil} + } + for k, v := range rp.headers { + w.Header().Set(k, v) + } + w.WriteHeader(rp.status) + w.Write(rp.body) + })) + t.Cleanup(s.srv.Close) + c, err := NewWithBase("ak2800.0xAB.SINTETIC", s.srv.URL+"/v1", nil) + if err != nil { + t.Fatal(err) + } + return s, c +} + +func (s *server) ultima() vazut { s.mu.Lock(); defer s.mu.Unlock(); return s.vazute[len(s.vazute)-1] } +func (s *server) pune(p string, st int, body []byte, h map[string]string) { + s.mu.Lock() + s.raspunsuri[p] = raspunsProba{st, body, h} + s.mu.Unlock() +} + +var ctx = context.Background() + +func TestCheiaEObligatorie(t *testing.T) { + if _, err := New(""); err == nil { + t.Fatal("o cheie goala nu a fost refuzata") + } +} + +func TestReadinessCheiaNumaiLaAttest(t *testing.T) { + s, c := porneste(t) + if _, err := c.Readiness(ctx, "example.com", false, false); err != nil { + t.Fatal(err) + } + v := s.ultima() + if v.Method != "POST" || v.Path != "/v1/pq/readiness" || v.Key != "" || v.Body != `{"domain":"example.com"}` { + t.Fatalf("fara attest: %+v", v) + } + if v.UA != "aere-cloud-go/1.5.0" { + t.Fatalf("user-agent: %q", v.UA) + } + c.Readiness(ctx, "example.com", true, true) + v = s.ultima() + if v.Key != "ak2800.0xAB.SINTETIC" || v.Body != `{"attest":true,"domain":"example.com","fresh":true}` { + t.Fatalf("cu attest: %+v", v) + } +} + +func TestPerimetruFinalitateDovada(t *testing.T) { + s, c := porneste(t) + f := false + c.ReadinessPerimeter(ctx, "example.com", PerimeterOptions{Hosts: []string{"api", `www"x`}, Discover: &f, Attest: true}) + v := s.ultima() + var b map[string]any + if err := json.Unmarshal([]byte(v.Body), &b); err != nil || b["discover"] != false || b["attest"] != true || len(b["hosts"].([]any)) != 2 || v.Key == "" { + t.Fatalf("perimetru: %+v", v) + } + c.PQFinality(ctx, "latest", "testnet", "both") + if v = s.ultima(); v.Path != "/v1/pq/finality/latest?network=testnet&client=both" || v.Key != "" { + t.Fatalf("finalitate: %+v", v) + } + c.Proof(ctx, "0xab") + if v = s.ultima(); v.Path != "/v1/proof/0xab" || v.Key == "" { + t.Fatalf("dovada: %+v", v) + } +} + +var zi = []byte("{\"t\":\"2026-05-01T10:00:00.000Z\",\"m\":\"GET\",\"p\":\"/v1/account\",\"s\":200}\n") + +func TestExportulZilei(t *testing.T) { + s, c := porneste(t) + d := sha256Hex(zi) + s.pune("/v1/account/audit", 200, zi, map[string]string{"x-aere-digest": d, "x-aere-day": "2026-05-01", "x-aere-entries": "1"}) + e, err := c.AuditExport(ctx, "2026-05-01", "jsonl") + if err != nil || e.Verified == nil || !*e.Verified || e.Entries != 1 || e.Day != "2026-05-01" || string(e.Bytes) != string(zi) { + t.Fatalf("jsonl: %+v %v", e, err) + } + if v := s.ultima(); v.Path != "/v1/account/audit?format=jsonl&day=2026-05-01" { + t.Fatalf("cale: %s", v.Path) + } + s.pune("/v1/account/audit", 200, []byte("alti octeti"), map[string]string{"x-aere-digest": d}) + if e, _ = c.AuditExport(ctx, "2026-05-01", "jsonl"); e.Verified == nil || *e.Verified { + t.Fatal("octeti straini nu au voie sa iasa verificati") + } + if e, _ = c.AuditExport(ctx, "2026-05-01", "cef"); e.Verified != nil { + t.Fatal("cef nu pretinde verificare") + } + s.pune("/v1/account/audit", 400, []byte(`{"error":"bad_format"}`), nil) + var ae *Error + if _, err = c.AuditExport(ctx, "", "xml"); !errors.As(err, &ae) || ae.Status != 400 { + t.Fatalf("400 neridicat: %v", err) + } +} + +func TestExportulPastrat(t *testing.T) { + s, c := porneste(t) + d := sha256Hex(zi) + s.pune("/v1/account/audit/retention", 200, []byte(`{"retentionDays":90}`), nil) + if r, err := c.AuditRetention(ctx); err != nil || !strings.Contains(string(r), "90") || s.ultima().Method != "GET" || s.ultima().Key == "" { + t.Fatalf("retentie: %s %v", r, err) + } + piatra := `{"error":"audit_day_expired","expired":{"day":"2026-05-01","digest":"` + d + `","entries":1,"deletedAt":"2026-09-25T12:00:01.000Z","retentionDays":90}}` + s.pune("/v1/account/audit", 410, []byte(piatra), nil) + k, err := c.AuditVerifyKept(ctx, "2026-05-01", zi) + if err != nil || k.State != "expired" || !k.Matches || k.Digest != d || k.DeletedAt != "2026-09-25T12:00:01.000Z" { + t.Fatalf("piatra: %+v %v", k, err) + } + stricat := append([]byte{}, zi...) + stricat[5] ^= 1 + if k, _ = c.AuditVerifyKept(ctx, "2026-05-01", stricat); k.Matches { + t.Fatal("un export modificat nu are voie sa se potriveasca cu piatra") + } + s.pune("/v1/account/audit", 200, zi, map[string]string{"x-aere-digest": d}) + if k, _ = c.AuditVerifyKept(ctx, "2026-05-01", zi); k.State != "held" || !k.Matches { + t.Fatalf("ziua tinuta: %+v", k) + } + if k, _ = c.AuditVerifyKept(ctx, "2026-05-01", stricat); k.Matches { + t.Fatal("ziua tinuta: un export modificat nu se potriveste") + } + s.pune("/v1/account/audit", 429, []byte(`{"error":"rate_limit"}`), nil) + var ae *Error + if _, err = c.AuditVerifyKept(ctx, "2026-05-01", zi); !errors.As(err, &ae) || ae.Status != 429 { + t.Fatalf("un 429 intors ca nepotrivire: %v", err) + } +} + +func TestRedirectareaNuEUrmata(t *testing.T) { + s, c := porneste(t) + // o redirectare spre alta cale a aceluiasi server: daca ar fi urmata, cheia ar ajunge acolo + s.pune("/v1/account", 302, nil, map[string]string{"location": s.srv.URL + "/in-alta-parte"}) + var ae *Error + if _, err := c.Account(ctx); !errors.As(err, &ae) || ae.Status != 302 { + t.Fatalf("302 trebuia intors ca raspuns, nu urmat: %v", err) + } + for _, v := range s.vazute { + if strings.HasPrefix(v.Path, "/in-alta-parte") { + t.Fatal("redirectarea a fost urmata") + } + } +} + +func TestVerifyWebhook(t *testing.T) { + secret := "AERE-SINTETIC-secret" + corp := []byte(`{"event":"pq-anchor","data":{"height":42}}`) + m := hmac.New(sha256.New, []byte(secret)) + m.Write(corp) + bun := hex.EncodeToString(m.Sum(nil)) + if !VerifyWebhook(corp, bun, secret) { + t.Fatal("semnatura buna refuzata") + } + stricata := bun[:len(bun)-1] + map[bool]string{true: "b", false: "a"}[strings.HasSuffix(bun, "a")] + if VerifyWebhook(corp, stricata, secret) || VerifyWebhook(corp, bun+"x", secret) || VerifyWebhook(append(corp, ' '), bun, secret) { + t.Fatal("o semnatura gresita sau un corp atins a trecut") + } +} diff --git a/go/control-negativ.mjs b/go/control-negativ.mjs new file mode 100644 index 0000000..fc216b6 --- /dev/null +++ b/go/control-negativ.mjs @@ -0,0 +1,53 @@ +#!/usr/bin/env node +// Controlul negativ al probelor clientului Go: fiecare paznic se strica intr-o COPIE a pachetului (sursa din depozit nu se +// atinge), `go test` ruleaza pe copie si proba tinta TREBUIE sa pice; o plantare care nu se aplica sau nu compileaza e STRICAT, +// nu rosu. La urma, pachetul neatins trebuie sa treaca intreg. +// node aerenew/sdk/go/control-negativ.mjs +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const AICI = path.dirname(fileURLToPath(import.meta.url)); +const FISIERE = ['go.mod', 'aerecloud.go', 'aerecloud_test.go']; +const sursa = fs.readFileSync(path.join(AICI, 'aerecloud.go'), 'utf8'); + +const PLANTARI = [ + ['exportul jsonl declarat verificat fara hash', 'ok := e.Digest != "" && e.Digest == sha256Hex(r.body)', 'ok := true', 'TestExportulZilei'], + ['piatra zilei sterse acceptata fara comparatie', 'Matches: local == t.Expired.Digest,', 'Matches: true,', 'TestExportulPastrat'], + ['ziua tinuta acceptata fara comparatie', 'Matches: local == d}', 'Matches: true}', 'TestExportulPastrat'], + ['cheia trimisa si fara attest', 'return c.jsonReq(ctx, "POST", "/pq/readiness", b, attest)', 'return c.jsonReq(ctx, "POST", "/pq/readiness", b, true)', 'TestReadinessCheiaNumaiLaAttest'], + ['redirectarile urmate (cheia ar pleca spre alta gazda)', 'CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }', 'CheckRedirect: nil', 'TestRedirectareaNuEUrmata'], + ['un 429 intors ca nepotrivire', 'return nil, &Error{Status: r.status, Body: r.body, Path: "/account/audit"}\n\t}\n\td := sha256Hex(r.body)', + 'return &KeptCheck{Day: day, State: "held", Local: local}, nil\n\t}\n\td := sha256Hex(r.body)', 'TestExportulPastrat'], +]; + +function ruleaza(src) { + const d = fs.mkdtempSync(path.join(os.tmpdir(), 'aere-go-cn-')); + try { + for (const f of FISIERE) fs.copyFileSync(path.join(AICI, f), path.join(d, f)); + fs.writeFileSync(path.join(d, 'aerecloud.go'), src); + const r = spawnSync('go', ['test', '-count=1', '-v', './...'], { cwd: d, encoding: 'utf8' }); + const out = (r.stdout || '') + (r.stderr || ''); + if (/\[build failed\]|cannot use|undefined:|syntax error/.test(out)) return { stricat: 'nu compileaza: ' + out.split('\n').find((l) => /error|undefined|cannot/.test(l)) }; + const picate = [...out.matchAll(/--- FAIL: (\w+)/g)].map((m) => m[1]); + const trecute = [...out.matchAll(/--- PASS: (\w+)/g)].map((m) => m[1]); + if (!picate.length && !trecute.length) return { stricat: 'probele nu au rulat' }; + return { picate, trecute }; + } finally { fs.rmSync(d, { recursive: true, force: true }); } +} + +let rele = 0; +for (const [nume, vechi, nou, tinta] of PLANTARI) { + if (sursa.split(vechi).length !== 2) { console.log(` STRICAT ${nume}: ancora apare de ${sursa.split(vechi).length - 1} ori`); rele++; continue; } + const r = ruleaza(sursa.replace(vechi, nou)); + if (r.stricat) { console.log(` STRICAT ${nume}: ${r.stricat}`); rele++; continue; } + if (r.picate.includes(tinta)) console.log(` ROSU cum trebuia ${nume} (pica ${r.picate.join(', ')})`); + else { console.log(` CONTROL CAZUT ${nume}: ${tinta} a ramas verde`); rele++; } +} +const m = ruleaza(sursa); +if (m.stricat || m.picate.length) { console.log(' MARTORUL nu e verde: ' + JSON.stringify(m)); rele++; } +else console.log(` martor: sursa neatinsa, ${m.trecute.length} probe verzi`); +console.log(rele ? `CONTROL NEGATIV: ${rele} probleme` : `CONTROL NEGATIV TRECUT (${PLANTARI.length} din ${PLANTARI.length} rosii, martorul verde)`); +process.exitCode = rele ? 1 : 0; diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..c7a2172 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,3 @@ +module git.aere.network/aere-network/aere-cloud-sdk/go + +go 1.21