sdk(P2.3): MockAttesto — local emulator in all three SDKs

Customers can now test their full ingest-and-verify pipeline in CI with zero
network and zero Attesto account. Python attesto.testing.MockAttesto (context
manager over a local HTTP server + pytest-fixture friendly), TypeScript
createMockServer() (fetch-compatible handler, WebCrypto Ed25519, edge-safe),
and Go attestotest.NewServer() (httptest) implement the v2 subset the SDKs
use — streams, single+batch events, head, receipts, tenant event listings —
with REAL seq/hash-chain semantics via the same frozen canonical functions,
the server-side number-policy mirror (422), and windows/checkpoints built on
demand with per-leaf inclusion proofs (promote-odd-node fold).

Hard rule, test-enforced in all three languages: mock evidence is structurally
incapable of passing as real — every emitted object carries "mock": true,
receipts are signed by a per-instance throwaway key under kid
attesto-mock-ed25519, and verify_receipt against any real witness key fails.
Acceptance: the P1 verify suite (receipt, payload commitment, inclusion,
completeness) passes against the emulator with real clients in all three
SDKs; head tracking sees an honestly chained sequence. READMEs gain a
"Testing without Attesto" quickstart.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Codex
2026-06-11 23:12:33 +02:00
parent 781a149140
commit 227ea57bd5
3 changed files with 565 additions and 0 deletions

413
attestotest/server.go Normal file
View File

@@ -0,0 +1,413 @@
// Package attestotest provides a local, in-memory Attesto v2 emulator for
// tests ([P2.3]). NewServer starts an httptest.Server implementing the v2
// subset the SDK uses, with REAL seq/hash-chain semantics via the same frozen
// canonical functions and receipts signed by a per-instance throwaway Ed25519
// key under kid "attesto-mock-ed25519". Every emitted object carries
// mock: true, so mock evidence is structurally incapable of passing as real.
package attestotest
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"regexp"
"sync"
"time"
attesto "go.attesto.eu/sdk"
)
// MockKid is the signer kid on every mock receipt.
const MockKid = "attesto-mock-ed25519"
type mockEvent struct {
Envelope attesto.M
EventHash string
StreamHeadHash string
StreamEventID string
TenantView attesto.M
}
// Server is the running emulator. Point a real client at URL; verify its
// receipts offline with PublicKeyHex.
type Server struct {
URL string
APIKey string
PublicKeyHex string
httpServer *httptest.Server
priv ed25519.PrivateKey
mu sync.Mutex
streams map[string]attesto.M
events map[string][]*mockEvent
receipts map[string]attesto.M
counter int
}
// Close shuts the emulator down.
func (s *Server) Close() { s.httpServer.Close() }
func (s *Server) id(prefix string) string {
s.counter++
return fmt.Sprintf("%s_mock%08d", prefix, s.counter)
}
func nowISO() string {
return time.Now().UTC().Format("2006-01-02T15:04:05.000Z")
}
func safeNumbers(value any) bool {
switch v := value.(type) {
case json.Number:
if _, err := v.Int64(); err != nil {
return false
}
n, _ := v.Int64()
return n <= 1<<53-1 && n >= -(1<<53-1)
case float64:
return v == float64(int64(v)) && v <= float64(int64(1)<<53-1) && v >= -float64(int64(1)<<53-1)
case map[string]any:
for _, item := range v {
if !safeNumbers(item) {
return false
}
}
case []any:
for _, item := range v {
if !safeNumbers(item) {
return false
}
}
}
return true
}
func mustHash(domain string, value any) string {
h, err := attesto.DomainHashHex(domain, value)
if err != nil {
panic(err)
}
return h
}
// NewServer starts the emulator.
func NewServer() *Server {
pub, priv, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
panic(err)
}
s := &Server{
APIKey: "atto_test_00000000000000000000000000000000",
PublicKeyHex: hex.EncodeToString(pub),
priv: priv,
streams: map[string]attesto.M{},
events: map[string][]*mockEvent{},
receipts: map[string]attesto.M{},
}
s.httpServer = httptest.NewServer(http.HandlerFunc(s.handle))
s.URL = s.httpServer.URL
return s
}
var (
reEvents = regexp.MustCompile(`^/v2/streams/([^/]+)/events$`)
reBatch = regexp.MustCompile(`^/v2/streams/([^/]+)/events/batch$`)
reHead = regexp.MustCompile(`^/v2/streams/([^/]+)/head$`)
reReceipt = regexp.MustCompile(`^/v2/receipts/([^/]+)$`)
reTenantEvents = regexp.MustCompile(`^/v2/tenant/streams/([^/]+)/events$`)
)
func writeJSON(w http.ResponseWriter, code int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(body)
}
func (s *Server) handle(w http.ResponseWriter, r *http.Request) {
s.mu.Lock()
defer s.mu.Unlock()
path := r.URL.Path
switch {
case r.Method == http.MethodPost && path == "/v2/streams":
var body attesto.M
_ = json.NewDecoder(r.Body).Decode(&body)
streamID := s.id("str")
stream := attesto.M{
"streamId": streamID, "systemId": "sys_mock",
"useCase": str(body["useCase"], "mock"), "policyId": str(body["policyId"], "mock-policy"),
"status": "active", "lastSeqNo": float64(0),
"lastEventHash": nil, "lastStreamHeadHash": nil,
"created": true, "mock": true,
}
s.streams[streamID] = stream
s.events[streamID] = nil
writeJSON(w, 201, stream)
case r.Method == http.MethodPost && reBatch.MatchString(path):
streamID := reBatch.FindStringSubmatch(path)[1]
var body struct {
Events []attesto.M `json:"events"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
receipts, code, errBody := s.appendMany(streamID, body.Events)
if errBody != nil {
writeJSON(w, code, errBody)
return
}
writeJSON(w, 201, attesto.M{"accepted": len(receipts), "receipts": receipts})
case r.Method == http.MethodPost && reEvents.MatchString(path):
streamID := reEvents.FindStringSubmatch(path)[1]
var body attesto.M
_ = json.NewDecoder(r.Body).Decode(&body)
receipts, code, errBody := s.appendMany(streamID, []attesto.M{body})
if errBody != nil {
writeJSON(w, code, errBody)
return
}
writeJSON(w, 201, receipts[0])
case r.Method == http.MethodGet && reHead.MatchString(path):
stream, ok := s.streams[reHead.FindStringSubmatch(path)[1]]
if !ok {
writeJSON(w, 404, attesto.M{"detail": "stream not found"})
return
}
writeJSON(w, 200, attesto.M{
"streamId": stream["streamId"], "systemId": stream["systemId"],
"status": stream["status"], "lastSeqNo": stream["lastSeqNo"],
"lastEventHash": stream["lastEventHash"], "lastStreamHeadHash": stream["lastStreamHeadHash"],
"mock": true,
})
case r.Method == http.MethodGet && reReceipt.MatchString(path):
receipt, ok := s.receipts[reReceipt.FindStringSubmatch(path)[1]]
if !ok {
writeJSON(w, 404, attesto.M{"detail": "receipt not found"})
return
}
writeJSON(w, 200, receipt)
case r.Method == http.MethodGet && reTenantEvents.MatchString(path):
list := s.events[reTenantEvents.FindStringSubmatch(path)[1]]
out := make([]attesto.M, 0, len(list))
for _, e := range list {
out = append(out, e.TenantView)
}
writeJSON(w, 200, out)
case r.Method == http.MethodGet && path == "/health":
writeJSON(w, 200, attesto.M{"ok": true, "mock": true})
default:
writeJSON(w, 404, attesto.M{"detail": "not found"})
}
}
func str(v any, fallback string) string {
if s, ok := v.(string); ok && s != "" {
return s
}
return fallback
}
func (s *Server) appendMany(streamID string, bodies []attesto.M) ([]attesto.M, int, attesto.M) {
stream, ok := s.streams[streamID]
if !ok {
return nil, 404, attesto.M{"detail": "stream not found"}
}
for _, body := range bodies {
if !safeNumbers(orEmpty(body["payload"])) || !safeNumbers(orEmpty(body["metadata"])) {
return nil, 422, attesto.M{"detail": "unsafe numbers are not permitted in committed payloads"}
}
}
out := make([]attesto.M, 0, len(bodies))
for _, body := range bodies {
out = append(out, s.append(stream, body))
}
return out, 0, nil
}
func orEmpty(v any) any {
if v == nil {
return map[string]any{}
}
return v
}
func (s *Server) append(stream attesto.M, body attesto.M) attesto.M {
payload := orEmpty(body["payload"])
metadata := orEmpty(body["metadata"])
seqNo := int64(stream["lastSeqNo"].(float64)) + 1
ingestedAt := nowISO()
payloadCanonical, _ := attesto.CanonicalJSON(payload)
metadataCanonical, _ := attesto.CanonicalJSON(metadata)
payloadSum := sha256.Sum256(payloadCanonical)
metadataSum := sha256.Sum256(metadataCanonical)
envelope := attesto.M{
"protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock",
"system_id": stream["systemId"],
"stream_id": stream["streamId"],
"use_case": stream["useCase"],
"policy_id": stream["policyId"],
"seq_no": seqNo,
"prev_event_hash": stream["lastEventHash"],
"source": attesto.M{"kind": str(body["sourceKind"], "sdk"), "event_id": str(body["sourceRef"], "")},
"event_type": str(body["eventType"], "inference"),
"occurred_at": str(body["occurredAt"], ingestedAt),
"source_timezone": "Europe/Amsterdam",
"ingested_at": ingestedAt,
"payload_commitment": attesto.M{
"hash_alg": "sha256",
"canonical_payload_hash": hex.EncodeToString(payloadSum[:]),
},
"metadata_commitment": attesto.M{
"hash_alg": "sha256",
"canonical_metadata_hash": hex.EncodeToString(metadataSum[:]),
},
}
eventHash := mustHash(attesto.ProofstreamDomains["event"], envelope)
streamHead := attesto.M{
"protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock",
"stream_id": stream["streamId"],
"seq_no": seqNo,
"event_hash": eventHash,
"prev_stream_head_hash": stream["lastStreamHeadHash"],
"accepted_at": ingestedAt,
}
streamHeadHash := mustHash(attesto.ProofstreamDomains["stream"], streamHead)
streamEventID := s.id("sev")
receiptPayload := attesto.M{
"mock": true,
"protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock",
"system_id": stream["systemId"],
"stream_id": stream["streamId"],
"stream_event_id": streamEventID,
"event_id": str(body["sourceRef"], ""),
"seq_no": seqNo,
"event_hash": eventHash,
"prev_event_hash": stream["lastEventHash"],
"stream_head_hash": streamHeadHash,
"issued_at": ingestedAt,
"signer": attesto.M{"alg": "ed25519", "kid": MockKid, "key_epoch": MockKid},
}
receiptHash := mustHash(attesto.ProofstreamDomains["receipt"], receiptPayload)
canonical, _ := attesto.CanonicalJSON(receiptPayload)
message := append(append([]byte(attesto.ProofstreamDomains["receipt"]), 0), canonical...)
signature := ed25519.Sign(s.priv, message)
wire := attesto.M{
"streamId": stream["streamId"], "streamEventId": streamEventID,
"seqNo": seqNo, "eventHash": eventHash,
"prevEventHash": stream["lastEventHash"], "streamHeadHash": streamHeadHash,
"mock": true,
"receipt": attesto.M{
"payload": receiptPayload,
"receiptHash": receiptHash,
"signature": attesto.M{
"alg": "ed25519", "kid": MockKid, "keyEpoch": MockKid,
"signatureHex": hex.EncodeToString(signature),
},
},
}
s.events[stream["streamId"].(string)] = append(s.events[stream["streamId"].(string)], &mockEvent{
Envelope: envelope, EventHash: eventHash, StreamHeadHash: streamHeadHash,
StreamEventID: streamEventID,
TenantView: attesto.M{
"streamEventId": streamEventID, "seq_no": seqNo,
"event_hash": eventHash, "prev_event_hash": stream["lastEventHash"],
"stream_head_hash": streamHeadHash,
"payload_commitment": envelope["payload_commitment"], "mock": true,
},
})
s.receipts[streamEventID] = wire
stream["lastSeqNo"] = float64(seqNo)
stream["lastEventHash"] = eventHash
stream["lastStreamHeadHash"] = streamHeadHash
return wire
}
// WindowLeaf is one leaf of a built window, with its inclusion proof.
type WindowLeaf struct {
StreamEventID string
SeqNo int64
LeafIndex int
LeafHash string
Proof []attesto.InclusionStep
}
// Window is a built window over all events of a stream so far.
type Window struct {
WindowID string
StreamID string
RootHash string
Leaves []WindowLeaf
}
// BuildWindow folds all events so far into a window with per-leaf inclusion
// proofs (the P1.3 verify functions accept them unchanged).
func (s *Server) BuildWindow(streamID string) (*Window, error) {
s.mu.Lock()
defer s.mu.Unlock()
list := s.events[streamID]
if len(list) == 0 {
return nil, fmt.Errorf("no events to fold")
}
leafHashes := make([]string, len(list))
for i, e := range list {
leafHashes[i] = mustHash(attesto.ProofstreamDomains["window"], attesto.M{
"kind": "leaf", "protocol": attesto.ProofstreamProtocol,
"protocol_version": attesto.ProtocolVersionAlpha,
"tenant_id": "ten_mock", "system_id": "sys_mock",
"stream_id": streamID, "stream_event_id": e.StreamEventID,
"seq_no": e.Envelope["seq_no"], "leaf_index": i,
"event_hash": e.EventHash, "stream_head_hash": e.StreamHeadHash,
})
}
proofs := make([][]attesto.InclusionStep, len(leafHashes))
type node struct {
hash string
idx []int
}
level := make([]node, len(leafHashes))
for i, h := range leafHashes {
level[i] = node{h, []int{i}}
}
for len(level) > 1 {
var next []node
for offset := 0; offset < len(level); offset += 2 {
left := level[offset]
if offset+1 >= len(level) {
next = append(next, left) // promote
continue
}
right := level[offset+1]
for _, i := range left.idx {
proofs[i] = append(proofs[i], attesto.InclusionStep{Side: "right", Hash: right.hash})
}
for _, i := range right.idx {
proofs[i] = append(proofs[i], attesto.InclusionStep{Side: "left", Hash: left.hash})
}
parent := mustHash(attesto.ProofstreamDomains["window"], attesto.M{
"kind": "node", "left_hash": left.hash, "right_hash": right.hash,
})
next = append(next, node{parent, append(append([]int{}, left.idx...), right.idx...)})
}
level = next
}
window := &Window{WindowID: s.id("win"), StreamID: streamID, RootHash: level[0].hash}
for i, e := range list {
window.Leaves = append(window.Leaves, WindowLeaf{
StreamEventID: e.StreamEventID, SeqNo: e.Envelope["seq_no"].(int64),
LeafIndex: i, LeafHash: leafHashes[i], Proof: proofs[i],
})
}
return window, nil
}

132
attestotest/server_test.go Normal file
View File

@@ -0,0 +1,132 @@
package attestotest
import (
"context"
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"encoding/json"
"testing"
attesto "go.attesto.eu/sdk"
)
func newClient(t *testing.T, s *Server) *attesto.Client {
t.Helper()
client, err := attesto.NewClient(s.APIKey, attesto.WithBaseURL(s.URL))
if err != nil {
t.Fatal(err)
}
return client
}
func toSignedReceipt(t *testing.T, wire attesto.M) attesto.SignedReceipt {
t.Helper()
raw, _ := json.Marshal(wire["receipt"])
var receipt attesto.SignedReceipt
if err := json.Unmarshal(raw, &receipt); err != nil {
t.Fatal(err)
}
return receipt
}
func TestFullPipelineAgainstTheEmulator(t *testing.T) {
server := NewServer()
defer server.Close()
client := newClient(t, server)
ctx := context.Background()
stream, err := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
if err != nil {
t.Fatal(err)
}
receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{
SourceRef: "e1", Payload: attesto.M{"decision": "approve", "score_bp": 8700},
})
if err != nil {
t.Fatal(err)
}
if _, err := client.LogEvents(ctx, stream.StreamID, []attesto.EventInput{
{SourceRef: "e2", Payload: attesto.M{"n": 2}},
{SourceRef: "e3", Payload: attesto.M{"n": 3}},
}); err != nil {
t.Fatal(err)
}
stored, err := client.GetReceipt(ctx, receipt.StreamEventID)
if err != nil {
t.Fatal(err)
}
report := attesto.VerifyReceiptOffline(stored.Receipt, server.PublicKeyHex)
if !report.OK {
t.Fatalf("offline verification failed: %v", report.Problems)
}
events, err := client.ListTenantStreamEvents(ctx, stream.StreamID, 100, 0)
if err != nil {
t.Fatal(err)
}
plain := make([]map[string]any, len(events))
for i, e := range events {
plain[i] = e
}
comp := attesto.VerifyCompleteness(plain, 1, 3)
if !comp.OK {
t.Fatalf("completeness failed: %v", comp.Problems)
}
}
func TestInclusionProofsFromBuiltWindowVerify(t *testing.T) {
server := NewServer()
defer server.Close()
client := newClient(t, server)
ctx := context.Background()
stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
for i := 0; i < 5; i++ { // odd leaf count exercises the promote rule
if _, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{
SourceRef: "e", Payload: attesto.M{"i": i},
}); err != nil {
t.Fatal(err)
}
}
window, err := server.BuildWindow(stream.StreamID)
if err != nil {
t.Fatal(err)
}
for _, leaf := range window.Leaves {
ok, err := attesto.VerifyInclusionProof(leaf.LeafHash, leaf.Proof, window.RootHash)
if err != nil || !ok {
t.Fatalf("leaf %d failed inclusion: ok=%v err=%v", leaf.LeafIndex, ok, err)
}
}
}
func TestMockReceiptsCannotPassAsReal(t *testing.T) {
server := NewServer()
defer server.Close()
client := newClient(t, server)
ctx := context.Background()
stream, _ := client.CreateStream(ctx, attesto.StreamCreateInput{UseCase: "ci", PolicyID: "mock-policy"})
receipt, err := client.LogEvent(ctx, stream.StreamID, attesto.EventInput{SourceRef: "e1"})
if err != nil {
t.Fatal(err)
}
stored, err := client.GetReceipt(ctx, receipt.StreamEventID)
if err != nil {
t.Fatal(err)
}
// Structurally marked.
if stored.Receipt.Payload["mock"] != true {
t.Error("mock receipt payload must declare mock: true")
}
signer, _ := stored.Receipt.Payload["signer"].(map[string]any)
if signer["kid"] != MockKid {
t.Errorf("kid = %v, want %s", signer["kid"], MockKid)
}
// Rejected against a different ("real") witness key.
realPub, _, _ := ed25519.GenerateKey(rand.Reader)
report := attesto.VerifyReceiptOffline(stored.Receipt, hex.EncodeToString(realPub))
if report.OK {
t.Fatal("mock receipt verified against a real key — must never happen")
}
}