verae-mcp-timestamping-serv.../pkg/tsnats/client_test.go

267 lines
7.2 KiB
Go

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())
}
}