aere-cloud Go 1.5.0: the Go client (standard library only), same surface as the JS client
Import path git.aere.network/aere-network/aere-cloud-sdk/go. JSON routes return json.RawMessage; the audit export and the check of a kept export return typed results with the sha256 computed locally. Redirects are not followed. go test ./... runs offline against a local httptest server; control-negativ.mjs breaks six guards in a copy of the package and requires each one's test to fail.
This commit is contained in:
parent
84cc9493f2
commit
ed88a38a4e
43
go/README.md
Normal file
43
go/README.md
Normal file
@ -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.
|
||||||
353
go/aerecloud.go
Normal file
353
go/aerecloud.go
Normal file
@ -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.<address>.<secret>).
|
||||||
|
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.<address>.<secret>)")
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
206
go/aerecloud_test.go
Normal file
206
go/aerecloud_test.go
Normal file
@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
53
go/control-negativ.mjs
Normal file
53
go/control-negativ.mjs
Normal file
@ -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;
|
||||||
Loading…
Reference in New Issue
Block a user