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