feat: pkg/tsnats digests-only NATS timestamp client
This commit is contained in:
parent
6c20d87131
commit
0a2d3177c6
5 changed files with 697 additions and 1 deletions
12
go.mod
12
go.mod
|
|
@ -1,3 +1,13 @@
|
|||
module git.georgelambert.org/marchon/verae-mcp-timestamping-server-go
|
||||
|
||||
go 1.22
|
||||
go 1.22.0
|
||||
|
||||
require github.com/nats-io/nats.go v1.39.1
|
||||
|
||||
require (
|
||||
github.com/klauspost/compress v1.17.11 // indirect
|
||||
github.com/nats-io/nkeys v0.4.9 // indirect
|
||||
github.com/nats-io/nuid v1.0.1 // indirect
|
||||
golang.org/x/crypto v0.31.0 // indirect
|
||||
golang.org/x/sys v0.28.0 // indirect
|
||||
)
|
||||
|
|
|
|||
12
go.sum
Normal file
12
go.sum
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
|
||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
|
||||
github.com/nats-io/nats.go v1.39.1 h1:oTkfKBmz7W047vRxV762M67ZdXeOtUgvbBaNoQ+3PPk=
|
||||
github.com/nats-io/nats.go v1.39.1/go.mod h1:MgRb8oOdigA6cYpEPhXJuRVH6UE/V4jblJ2jQ27IXYM=
|
||||
github.com/nats-io/nkeys v0.4.9 h1:qe9Faq2Gxwi6RZnZMXfmGMZkg3afLLOtrU+gDZJ35b0=
|
||||
github.com/nats-io/nkeys v0.4.9/go.mod h1:jcMqs+FLG+W5YO36OX6wFIFcmpdAns+w1Wm6D3I/evE=
|
||||
github.com/nats-io/nuid v1.0.1 h1:5iA8DT8V7q8WK2EScv2padNa/rTESc1KdnPw4TC2paw=
|
||||
github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c=
|
||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
|
||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
|
||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
398
pkg/tsnats/client.go
Normal file
398
pkg/tsnats/client.go
Normal file
|
|
@ -0,0 +1,398 @@
|
|||
package tsnats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/nats-io/nats.go"
|
||||
|
||||
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
|
||||
)
|
||||
|
||||
const defaultTimeout = 2 * time.Second
|
||||
|
||||
// Requester is the request/reply surface used by Stamp/Verify/HashReport.
|
||||
// Inject a mock in unit tests; Connect wraps *nats.Conn.
|
||||
type Requester interface {
|
||||
Request(ctx context.Context, subj string, data []byte) ([]byte, error)
|
||||
}
|
||||
|
||||
// MessageHandler receives raw message payloads from a core NATS subscription.
|
||||
type MessageHandler func(data []byte)
|
||||
|
||||
// SubConn is the subscribe surface for SubscribeCompleted.
|
||||
type SubConn interface {
|
||||
Subscribe(subj string, cb MessageHandler) (Subscription, error)
|
||||
}
|
||||
|
||||
// Subscription is returned by SubscribeCompleted.
|
||||
type Subscription interface {
|
||||
Unsubscribe() error
|
||||
}
|
||||
|
||||
// Receipt is a timestamp receipt from request/reply or hash.report.
|
||||
type Receipt struct {
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
Hash string `json:"hash"`
|
||||
Scope string `json:"scope"`
|
||||
CubeID string `json:"cube_id,omitempty"`
|
||||
OrgID string `json:"org_id,omitempty"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
Merkle string `json:"merkle"`
|
||||
RestampOf string `json:"restamp_of,omitempty"`
|
||||
}
|
||||
|
||||
// VerifyResult is the outcome of Verify.
|
||||
type VerifyResult struct {
|
||||
Valid bool
|
||||
Error string
|
||||
Receipt *Receipt
|
||||
}
|
||||
|
||||
// CompletedEvent is a fire-and-forget job-complete notification on SubjectCompleted.
|
||||
type CompletedEvent struct {
|
||||
JobID string `json:"jobId"`
|
||||
ReceiptID string `json:"receipt_id"`
|
||||
Hash string `json:"hash,omitempty"`
|
||||
}
|
||||
|
||||
// StampOptions configures Stamp. Authorization/AccessToken are optional
|
||||
// passthrough fields for embedded handlers (JSON keys authorization / access_token).
|
||||
type StampOptions struct {
|
||||
Scope string
|
||||
CubeID string
|
||||
OrgID string
|
||||
Authorization string
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// VerifyOptions configures Verify.
|
||||
type VerifyOptions struct {
|
||||
WantScope string
|
||||
Authorization string
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// ReportOptions configures HashReport.
|
||||
type ReportOptions struct {
|
||||
OrgID string
|
||||
WantScope string
|
||||
Authorization string
|
||||
AccessToken string
|
||||
}
|
||||
|
||||
// Option configures Client construction.
|
||||
type Option func(*Client)
|
||||
|
||||
// WithTimeout sets the request timeout (default 2s).
|
||||
func WithTimeout(d time.Duration) Option {
|
||||
return func(c *Client) {
|
||||
if d > 0 {
|
||||
c.timeout = d
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// WithRequester injects a Requester (tests or custom transports).
|
||||
func WithRequester(r Requester) Option {
|
||||
return func(c *Client) { c.req = r }
|
||||
}
|
||||
|
||||
// WithSubscriber injects a SubConn for SubscribeCompleted.
|
||||
func WithSubscriber(s SubConn) Option {
|
||||
return func(c *Client) { c.sub = s }
|
||||
}
|
||||
|
||||
// Client is a digests-only NATS timestamping client (core NATS only; no JetStream).
|
||||
type Client struct {
|
||||
req Requester
|
||||
sub SubConn
|
||||
timeout time.Duration
|
||||
nc *nats.Conn
|
||||
}
|
||||
|
||||
// NewClient builds a Client with an injected Requester (unit tests).
|
||||
func NewClient(r Requester, opts ...Option) *Client {
|
||||
c := &Client{req: r, timeout: defaultTimeout}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// Connect dials a NATS URL and returns a Client using core request/reply and subscribe.
|
||||
func Connect(url string, opts ...Option) (*Client, error) {
|
||||
nc, err := nats.Connect(url)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wrap := &natsAdapter{nc: nc}
|
||||
c := &Client{req: wrap, sub: wrap, timeout: defaultTimeout, nc: nc}
|
||||
for _, opt := range opts {
|
||||
opt(c)
|
||||
}
|
||||
if c.req == nil {
|
||||
c.req = wrap
|
||||
}
|
||||
if c.sub == nil {
|
||||
c.sub = wrap
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Close closes the underlying NATS connection when opened via Connect.
|
||||
func (c *Client) Close() {
|
||||
if c.nc != nil {
|
||||
c.nc.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// Timeout returns the request timeout.
|
||||
func (c *Client) Timeout() time.Duration {
|
||||
return c.timeout
|
||||
}
|
||||
|
||||
// Stamp publishes a digests-only stamp request on SubjectRequest.
|
||||
func (c *Client) Stamp(ctx context.Context, sha256 string, opts StampOptions) ([]Receipt, error) {
|
||||
h, err := digest.NormalizeSHA256(sha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
scope := opts.Scope
|
||||
if scope == "" {
|
||||
scope = "local"
|
||||
}
|
||||
payload := map[string]any{
|
||||
"hash": h,
|
||||
"scope": scope,
|
||||
"cube_id": opts.CubeID,
|
||||
"org_id": opts.OrgID,
|
||||
}
|
||||
putAuth(payload, opts.Authorization, opts.AccessToken)
|
||||
raw, err := c.request(ctx, SubjectRequest, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseStampReply(raw)
|
||||
}
|
||||
|
||||
// Verify asks SubjectVerify for a receipt by id.
|
||||
func (c *Client) Verify(ctx context.Context, receiptID string, opts VerifyOptions) (VerifyResult, error) {
|
||||
if receiptID == "" {
|
||||
return VerifyResult{Valid: false, Error: "receipt_id required"}, nil
|
||||
}
|
||||
payload := map[string]any{
|
||||
"receipt_id": receiptID,
|
||||
"want_scope": opts.WantScope,
|
||||
}
|
||||
putAuth(payload, opts.Authorization, opts.AccessToken)
|
||||
raw, err := c.request(ctx, SubjectVerify, payload)
|
||||
if err != nil {
|
||||
return VerifyResult{}, err
|
||||
}
|
||||
return parseVerifyReply(raw)
|
||||
}
|
||||
|
||||
// HashReport asks SubjectReport for receipts for a digest.
|
||||
func (c *Client) HashReport(ctx context.Context, sha256 string, opts ReportOptions) ([]Receipt, error) {
|
||||
h, err := digest.NormalizeSHA256(sha256)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
payload := map[string]any{
|
||||
"hash": h,
|
||||
"org_id": opts.OrgID,
|
||||
"want_scope": opts.WantScope,
|
||||
}
|
||||
putAuth(payload, opts.Authorization, opts.AccessToken)
|
||||
raw, err := c.request(ctx, SubjectReport, payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return parseReportReply(raw)
|
||||
}
|
||||
|
||||
// SubscribeCompleted core-subscribes to SubjectCompleted (not JetStream).
|
||||
func (c *Client) SubscribeCompleted(ctx context.Context, handler func(CompletedEvent)) (Subscription, error) {
|
||||
if c.sub == nil {
|
||||
return nil, fmt.Errorf("tsnats: no subscriber configured")
|
||||
}
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("tsnats: handler required")
|
||||
}
|
||||
_ = ctx
|
||||
return c.sub.Subscribe(SubjectCompleted, func(data []byte) {
|
||||
var ev CompletedEvent
|
||||
if err := json.Unmarshal(data, &ev); err != nil {
|
||||
return
|
||||
}
|
||||
handler(ev)
|
||||
})
|
||||
}
|
||||
|
||||
func (c *Client) request(ctx context.Context, subj string, payload map[string]any) ([]byte, error) {
|
||||
if c.req == nil {
|
||||
return nil, fmt.Errorf("tsnats: no requester configured")
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ctx, cancel := withTimeout(ctx, c.timeout)
|
||||
defer cancel()
|
||||
return c.req.Request(ctx, subj, data)
|
||||
}
|
||||
|
||||
func putAuth(payload map[string]any, authorization, accessToken string) {
|
||||
if authorization != "" {
|
||||
payload["authorization"] = authorization
|
||||
}
|
||||
if accessToken != "" {
|
||||
payload["access_token"] = accessToken
|
||||
}
|
||||
}
|
||||
|
||||
func withTimeout(ctx context.Context, d time.Duration) (context.Context, context.CancelFunc) {
|
||||
if _, ok := ctx.Deadline(); ok {
|
||||
return context.WithCancel(ctx)
|
||||
}
|
||||
return context.WithTimeout(ctx, d)
|
||||
}
|
||||
|
||||
func parseStampReply(raw []byte) ([]Receipt, error) {
|
||||
var asList []Receipt
|
||||
if err := json.Unmarshal(raw, &asList); err == nil && (len(asList) > 0 || isJSONArray(raw)) {
|
||||
return asList, nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil, fmt.Errorf("tsnats: stamp reply: %w", err)
|
||||
}
|
||||
if errRaw, ok := obj["error"]; ok {
|
||||
var msg string
|
||||
_ = json.Unmarshal(errRaw, &msg)
|
||||
if msg == "" {
|
||||
msg = string(errRaw)
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if _, hasReceipt := obj["receipt_id"]; hasReceipt {
|
||||
var rec Receipt
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []Receipt{rec}, nil
|
||||
}
|
||||
if jobRaw, ok := obj["jobId"]; ok {
|
||||
var jobID string
|
||||
_ = json.Unmarshal(jobRaw, &jobID)
|
||||
return nil, fmt.Errorf("stamp pending jobId=%s; use SubscribeCompleted", jobID)
|
||||
}
|
||||
return nil, fmt.Errorf("tsnats: unrecognized stamp reply")
|
||||
}
|
||||
|
||||
func parseVerifyReply(raw []byte) (VerifyResult, error) {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return VerifyResult{Valid: false, Error: "invalid verify payload"}, nil
|
||||
}
|
||||
if v, ok := obj["valid"]; ok {
|
||||
if b, _ := v.(bool); !b {
|
||||
errMsg, _ := obj["error"].(string)
|
||||
return VerifyResult{Valid: false, Error: errMsg}, nil
|
||||
}
|
||||
}
|
||||
if errMsg, ok := obj["error"].(string); ok && errMsg != "" {
|
||||
if valid, _ := obj["valid"].(bool); !valid {
|
||||
return VerifyResult{Valid: false, Error: errMsg}, nil
|
||||
}
|
||||
}
|
||||
if _, ok := obj["receipt_id"]; ok {
|
||||
var rec Receipt
|
||||
if err := json.Unmarshal(raw, &rec); err != nil {
|
||||
return VerifyResult{Valid: false, Error: err.Error()}, nil
|
||||
}
|
||||
return VerifyResult{Valid: true, Receipt: &rec}, nil
|
||||
}
|
||||
return VerifyResult{Valid: false, Error: "unknown receipt"}, nil
|
||||
}
|
||||
|
||||
func parseReportReply(raw []byte) ([]Receipt, error) {
|
||||
var asList []Receipt
|
||||
if err := json.Unmarshal(raw, &asList); err == nil && (len(asList) > 0 || isJSONArray(raw)) {
|
||||
return asList, nil
|
||||
}
|
||||
var obj map[string]json.RawMessage
|
||||
if err := json.Unmarshal(raw, &obj); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
if errRaw, ok := obj["error"]; ok {
|
||||
var msg string
|
||||
_ = json.Unmarshal(errRaw, &msg)
|
||||
if msg == "" {
|
||||
msg = string(errRaw)
|
||||
}
|
||||
return nil, fmt.Errorf("%s", msg)
|
||||
}
|
||||
if receiptsRaw, ok := obj["receipts"]; ok {
|
||||
var receipts []Receipt
|
||||
if err := json.Unmarshal(receiptsRaw, &receipts); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return receipts, nil
|
||||
}
|
||||
if timelineRaw, ok := obj["timeline"]; ok {
|
||||
var events []map[string]json.RawMessage
|
||||
if err := json.Unmarshal(timelineRaw, &events); err != nil {
|
||||
return nil, nil
|
||||
}
|
||||
var out []Receipt
|
||||
for _, ev := range events {
|
||||
recRaw, ok := ev["receipt"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var rec Receipt
|
||||
if err := json.Unmarshal(recRaw, &rec); err != nil {
|
||||
continue
|
||||
}
|
||||
out = append(out, rec)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func isJSONArray(raw []byte) bool {
|
||||
for _, b := range raw {
|
||||
if b == ' ' || b == '\t' || b == '\n' || b == '\r' {
|
||||
continue
|
||||
}
|
||||
return b == '['
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type natsAdapter struct {
|
||||
nc *nats.Conn
|
||||
}
|
||||
|
||||
func (a *natsAdapter) Request(ctx context.Context, subj string, data []byte) ([]byte, error) {
|
||||
msg, err := a.nc.RequestWithContext(ctx, subj, data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return msg.Data, nil
|
||||
}
|
||||
|
||||
func (a *natsAdapter) Subscribe(subj string, cb MessageHandler) (Subscription, error) {
|
||||
sub, err := a.nc.Subscribe(subj, func(msg *nats.Msg) {
|
||||
cb(msg.Data)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sub, nil
|
||||
}
|
||||
267
pkg/tsnats/client_test.go
Normal file
267
pkg/tsnats/client_test.go
Normal file
|
|
@ -0,0 +1,267 @@
|
|||
package tsnats
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
const testDigest = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
|
||||
|
||||
type mockRequester struct {
|
||||
mu sync.Mutex
|
||||
lastSubj string
|
||||
lastData []byte
|
||||
reply []byte
|
||||
err error
|
||||
}
|
||||
|
||||
func (m *mockRequester) Request(ctx context.Context, subj string, data []byte) ([]byte, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.lastSubj = subj
|
||||
m.lastData = append([]byte(nil), data...)
|
||||
if m.err != nil {
|
||||
return nil, m.err
|
||||
}
|
||||
return m.reply, nil
|
||||
}
|
||||
|
||||
type mockSubConn struct {
|
||||
mu sync.Mutex
|
||||
subj string
|
||||
handler MessageHandler
|
||||
unsub bool
|
||||
}
|
||||
|
||||
func (m *mockSubConn) Subscribe(subj string, cb MessageHandler) (Subscription, error) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.subj = subj
|
||||
m.handler = cb
|
||||
return &mockSubscription{parent: m}, nil
|
||||
}
|
||||
|
||||
type mockSubscription struct {
|
||||
parent *mockSubConn
|
||||
}
|
||||
|
||||
func (s *mockSubscription) Unsubscribe() error {
|
||||
s.parent.mu.Lock()
|
||||
defer s.parent.mu.Unlock()
|
||||
s.parent.unsub = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestSubjects(t *testing.T) {
|
||||
if SubjectRequest != "verae.ts.request" {
|
||||
t.Fatalf("SubjectRequest=%q", SubjectRequest)
|
||||
}
|
||||
if SubjectVerify != "verae.ts.verify" {
|
||||
t.Fatalf("SubjectVerify=%q", SubjectVerify)
|
||||
}
|
||||
if SubjectReport != "verae.hash.report" {
|
||||
t.Fatalf("SubjectReport=%q", SubjectReport)
|
||||
}
|
||||
if SubjectCompleted != "verae.ts.completed" {
|
||||
t.Fatalf("SubjectCompleted=%q", SubjectCompleted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStamp_SendsJSONAndParsesReceiptList(t *testing.T) {
|
||||
reply, _ := json.Marshal([]map[string]any{{
|
||||
"receipt_id": "rid-1",
|
||||
"hash": testDigest,
|
||||
"scope": "global",
|
||||
"created_at": "2026-01-01T00:00:00Z",
|
||||
"merkle": "m1",
|
||||
}})
|
||||
mock := &mockRequester{reply: reply}
|
||||
c := NewClient(mock)
|
||||
|
||||
receipts, err := c.Stamp(context.Background(), testDigest, StampOptions{Scope: "global"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stamp: %v", err)
|
||||
}
|
||||
if mock.lastSubj != SubjectRequest {
|
||||
t.Fatalf("subject=%q want %q", mock.lastSubj, SubjectRequest)
|
||||
}
|
||||
var body map[string]any
|
||||
if err := json.Unmarshal(mock.lastData, &body); err != nil {
|
||||
t.Fatalf("request json: %v", err)
|
||||
}
|
||||
if body["hash"] != testDigest || body["scope"] != "global" {
|
||||
t.Fatalf("request body=%v", body)
|
||||
}
|
||||
if len(receipts) != 1 || receipts[0].ReceiptID != "rid-1" || receipts[0].Hash != testDigest {
|
||||
t.Fatalf("receipts=%+v", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStamp_NormalizesDigestAndRejectsInvalid(t *testing.T) {
|
||||
reply, _ := json.Marshal(map[string]any{
|
||||
"receipt_id": "rid-1",
|
||||
"hash": testDigest,
|
||||
"scope": "local",
|
||||
"created_at": "t",
|
||||
"merkle": "m",
|
||||
})
|
||||
mock := &mockRequester{reply: reply}
|
||||
c := NewClient(mock)
|
||||
|
||||
_, err := c.Stamp(context.Background(), "sha256:"+strings.ToUpper(testDigest), StampOptions{Scope: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("Stamp: %v", err)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(mock.lastData, &body)
|
||||
if body["hash"] != testDigest {
|
||||
t.Fatalf("normalized hash=%v", body["hash"])
|
||||
}
|
||||
|
||||
beforeSubj, beforeData := mock.lastSubj, string(mock.lastData)
|
||||
_, err = c.Stamp(context.Background(), "notadigest", StampOptions{})
|
||||
if err == nil {
|
||||
t.Fatal("expected digest error")
|
||||
}
|
||||
if mock.lastSubj != beforeSubj || string(mock.lastData) != beforeData {
|
||||
t.Fatal("invalid digest must not publish")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStamp_IncludesBearerFields(t *testing.T) {
|
||||
reply, _ := json.Marshal(map[string]any{
|
||||
"receipt_id": "rid-1",
|
||||
"hash": testDigest,
|
||||
"scope": "global",
|
||||
"created_at": "t",
|
||||
"merkle": "m",
|
||||
})
|
||||
mock := &mockRequester{reply: reply}
|
||||
c := NewClient(mock)
|
||||
_, err := c.Stamp(context.Background(), testDigest, StampOptions{
|
||||
Scope: "global",
|
||||
Authorization: "tok-a",
|
||||
AccessToken: "tok-b",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Stamp: %v", err)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(mock.lastData, &body)
|
||||
if body["authorization"] != "tok-a" || body["access_token"] != "tok-b" {
|
||||
t.Fatalf("auth fields missing: %v", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStamp_ErrorAndJobID(t *testing.T) {
|
||||
mock := &mockRequester{reply: []byte(`{"error":"boom"}`)}
|
||||
c := NewClient(mock)
|
||||
_, err := c.Stamp(context.Background(), testDigest, StampOptions{Scope: "local"})
|
||||
if err == nil || !strings.Contains(err.Error(), "boom") {
|
||||
t.Fatalf("want boom error, got %v", err)
|
||||
}
|
||||
|
||||
mock.reply = []byte(`{"jobId":"job-9"}`)
|
||||
_, err = c.Stamp(context.Background(), testDigest, StampOptions{Scope: "local"})
|
||||
if err == nil || !strings.Contains(err.Error(), "job-9") {
|
||||
t.Fatalf("want pending jobId error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerify_ParsesReceiptAndInvalid(t *testing.T) {
|
||||
mock := &mockRequester{reply: []byte(`{"receipt_id":"rid-1","hash":"` + testDigest + `","scope":"local","created_at":"t","merkle":"m"}`)}
|
||||
c := NewClient(mock)
|
||||
res, err := c.Verify(context.Background(), "rid-1", VerifyOptions{WantScope: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify: %v", err)
|
||||
}
|
||||
if mock.lastSubj != SubjectVerify {
|
||||
t.Fatalf("subject=%q", mock.lastSubj)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(mock.lastData, &body)
|
||||
if body["receipt_id"] != "rid-1" || body["want_scope"] != "local" {
|
||||
t.Fatalf("body=%v", body)
|
||||
}
|
||||
if !res.Valid || res.Receipt == nil || res.Receipt.ReceiptID != "rid-1" {
|
||||
t.Fatalf("result=%+v", res)
|
||||
}
|
||||
|
||||
mock.reply = []byte(`{"valid":false,"error":"unknown"}`)
|
||||
res, err = c.Verify(context.Background(), "missing", VerifyOptions{})
|
||||
if err != nil {
|
||||
t.Fatalf("Verify invalid: %v", err)
|
||||
}
|
||||
if res.Valid || res.Error != "unknown" {
|
||||
t.Fatalf("result=%+v", res)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHashReport_ParsesReceiptsWrapper(t *testing.T) {
|
||||
mock := &mockRequester{reply: []byte(`{"receipts":[{"receipt_id":"rid-1","hash":"` + testDigest + `","scope":"local","created_at":"t","merkle":"m"}]}`)}
|
||||
c := NewClient(mock)
|
||||
receipts, err := c.HashReport(context.Background(), testDigest, ReportOptions{OrgID: "org-1", WantScope: "local"})
|
||||
if err != nil {
|
||||
t.Fatalf("HashReport: %v", err)
|
||||
}
|
||||
if mock.lastSubj != SubjectReport {
|
||||
t.Fatalf("subject=%q", mock.lastSubj)
|
||||
}
|
||||
var body map[string]any
|
||||
_ = json.Unmarshal(mock.lastData, &body)
|
||||
if body["hash"] != testDigest || body["org_id"] != "org-1" || body["want_scope"] != "local" {
|
||||
t.Fatalf("body=%v", body)
|
||||
}
|
||||
if len(receipts) != 1 || receipts[0].ReceiptID != "rid-1" {
|
||||
t.Fatalf("receipts=%+v", receipts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubscribeCompleted(t *testing.T) {
|
||||
sub := &mockSubConn{}
|
||||
c := NewClient(&mockRequester{}, WithSubscriber(sub))
|
||||
var got CompletedEvent
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(1)
|
||||
s, err := c.SubscribeCompleted(context.Background(), func(ev CompletedEvent) {
|
||||
got = ev
|
||||
wg.Done()
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("SubscribeCompleted: %v", err)
|
||||
}
|
||||
if sub.subj != SubjectCompleted {
|
||||
t.Fatalf("subj=%q", sub.subj)
|
||||
}
|
||||
sub.handler([]byte(`{"jobId":"j1","receipt_id":"r1","hash":"` + testDigest + `"}`))
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
wg.Wait()
|
||||
close(done)
|
||||
}()
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("handler not called")
|
||||
}
|
||||
if got.JobID != "j1" || got.ReceiptID != "r1" || got.Hash != testDigest {
|
||||
t.Fatalf("event=%+v", got)
|
||||
}
|
||||
if err := s.Unsubscribe(); err != nil {
|
||||
t.Fatalf("Unsubscribe: %v", err)
|
||||
}
|
||||
if !sub.unsub {
|
||||
t.Fatal("expected unsubscribe")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultTimeout(t *testing.T) {
|
||||
c := NewClient(&mockRequester{})
|
||||
if c.Timeout() != 2*time.Second {
|
||||
t.Fatalf("timeout=%v", c.Timeout())
|
||||
}
|
||||
}
|
||||
9
pkg/tsnats/subjects.go
Normal file
9
pkg/tsnats/subjects.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package tsnats
|
||||
|
||||
// Core NATS subjects for timestamping. Job-complete is ephemeral core NATS only.
|
||||
const (
|
||||
SubjectRequest = "verae.ts.request"
|
||||
SubjectVerify = "verae.ts.verify"
|
||||
SubjectReport = "verae.hash.report"
|
||||
SubjectCompleted = "verae.ts.completed"
|
||||
)
|
||||
Loading…
Reference in a new issue