verae-mcp-timestamping-serv.../internal/veraetime/client_test.go
Cursor Agent deb0e251de
Send global stamps as a verae-meta-git/1 history.
Optional metadata or restamp builds the same commit history as node-red. A call that omits both stays digest-only with restamp false. Local and shared stamps reject metadata.
2026-10-11 05:37:24 +00:00

468 lines
14 KiB
Go

package veraetime_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strings"
"sync"
"testing"
"time"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/veraetime"
)
const sha256Empty = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }
func jsonResp(status int, body any) *http.Response {
raw, _ := json.Marshal(body)
return &http.Response{
StatusCode: status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewReader(raw)),
}
}
func readJSON(t *testing.T, r *http.Request) map[string]any {
t.Helper()
defer r.Body.Close()
var m map[string]any
if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
t.Fatalf("decode body: %v", err)
}
return m
}
func TestStamp_PostsDigestRestampFalse(t *testing.T) {
var gotBody map[string]any
var gotAuth string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/timestamp") {
t.Fatalf("unexpected %s %s", r.Method, r.URL)
}
gotAuth = r.Header.Get("Authorization")
gotBody = readJSON(t, r)
return jsonResp(200, map[string]any{
"jobId": "job-1",
"existing": true,
"certificate": "QmCert1",
"timestamp": "2026-08-15T00:00:00Z",
"blockHash": "0xdead",
}), nil
})
c := veraetime.NewClient("https://api.veraetime.net", veraetime.WithRoundTripper(rt))
rec, err := c.Stamp(context.Background(), sha256Empty, "secret")
if err != nil {
t.Fatal(err)
}
if gotAuth != "Bearer secret" {
t.Fatalf("auth=%q", gotAuth)
}
if gotBody["data"] != sha256Empty || gotBody["digest"] != sha256Empty {
t.Fatalf("body=%v", gotBody)
}
if gotBody["hashAlg"] != "SHA256" {
t.Fatalf("hashAlg=%v", gotBody["hashAlg"])
}
if gotBody["restamp"] != false {
t.Fatalf("restamp=%v want false", gotBody["restamp"])
}
if rec.ReceiptID != "QmCert1" || rec.Scope != "global" || rec.Hash != sha256Empty {
t.Fatalf("rec=%+v", rec)
}
}
func TestStamp_ExistingCertificateNoPoll(t *testing.T) {
calls := 0
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
calls++
if strings.Contains(r.URL.Path, "/api/status/") {
t.Fatal("must not poll status when certificate present")
}
return jsonResp(200, map[string]any{
"jobId": "28df9952-094b-45a9-b6e5-e3ff81389bdd",
"existing": true,
"certificate": "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA",
}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
rec, err := c.Stamp(context.Background(), sha256Empty, "tok")
if err != nil {
t.Fatal(err)
}
if rec.ReceiptID != "QmVBa3op7DHgxSDgMURFpfGxTQ6UnZvzLsBCtpmLVAjXbA" {
t.Fatalf("receipt=%q", rec.ReceiptID)
}
if calls != 1 {
t.Fatalf("calls=%d", calls)
}
}
func TestStamp_PollsUntilCompleted(t *testing.T) {
statusCalls := 0
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(r.URL.Path, "/api/timestamp"):
return jsonResp(202, map[string]any{"jobId": "job-p"}), nil
case strings.HasSuffix(r.URL.Path, "/api/status/job-p"):
statusCalls++
if statusCalls < 3 {
return jsonResp(200, map[string]any{"id": "job-p", "status": "processing"}), nil
}
return jsonResp(200, map[string]any{
"id": "job-p",
"status": "completed",
"result": "QmDone",
"metadata": map[string]any{
"timestamp": "2026-09-23T00:00:00Z",
"blockHash": "0xabc",
},
}), nil
default:
return jsonResp(404, map[string]any{"error": "nope"}), nil
}
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithPollInterval(time.Millisecond),
)
rec, err := c.Stamp(context.Background(), sha256Empty, "tok")
if err != nil {
t.Fatal(err)
}
if rec.ReceiptID != "QmDone" {
t.Fatalf("receipt=%q", rec.ReceiptID)
}
if statusCalls != 3 {
t.Fatalf("statusCalls=%d", statusCalls)
}
}
func TestStamp_401AuthError(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
return jsonResp(401, map[string]any{"error": "Unauthorized"}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
_, err := c.Stamp(context.Background(), sha256Empty, "bad")
if err == nil {
t.Fatal("expected error")
}
var ae *veraetime.AuthError
if !asAuthError(err, &ae) {
t.Fatalf("want AuthError, got %T %v", err, err)
}
if ae.Status != 401 {
t.Fatalf("status=%d", ae.Status)
}
}
func asAuthError(err error, target **veraetime.AuthError) bool {
if ae, ok := err.(*veraetime.AuthError); ok {
*target = ae
return true
}
return false
}
func TestVerify_PostsCertificate(t *testing.T) {
var gotBody map[string]any
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Method != http.MethodPost || !strings.HasSuffix(r.URL.Path, "/api/verify") {
t.Fatalf("unexpected %s %s", r.Method, r.URL)
}
gotBody = readJSON(t, r)
return jsonResp(200, map[string]any{
"valid": true,
"hash": sha256Empty,
"timestamp": "2026-08-15T00:00:00Z",
"blockHash": "0xdead",
}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
vr, err := c.Verify(context.Background(), "QmCert1", "tok")
if err != nil {
t.Fatal(err)
}
if !vr.Valid || vr.Receipt == nil || vr.Receipt.Hash != sha256Empty {
t.Fatalf("%+v", vr)
}
if gotBody["certificate"] != "QmCert1" {
t.Fatalf("body=%v", gotBody)
}
}
func TestHashReport_UsesAPIHash(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case strings.Contains(r.URL.Path, "/api/hash/"):
return jsonResp(200, map[string]any{
"digest": sha256Empty,
"receipts": []map[string]any{{
"certificate": "job-1",
"digest": sha256Empty,
"completedAt": "2026-08-15T00:00:00Z",
}},
}), nil
case strings.HasSuffix(r.URL.Path, "/api/verify"):
return jsonResp(200, map[string]any{
"valid": true,
"hash": sha256Empty,
"timestamp": "2026-08-15T00:00:00Z",
"blockIndex": 113072,
}), nil
default:
return jsonResp(404, map[string]any{"error": "nope"}), nil
}
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
recs, err := c.HashReport(context.Background(), sha256Empty, "tok")
if err != nil {
t.Fatal(err)
}
if len(recs) != 1 || recs[0].ReceiptID != "job-1" || recs[0].Merkle != "113072" {
t.Fatalf("%+v", recs)
}
}
func TestLogin_StoresAuthProperties(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if !strings.HasSuffix(r.URL.Path, "/auth/login") {
t.Fatalf("url=%s", r.URL)
}
body := readJSON(t, r)
if body["username"] != "admin" || body["password"] != "secret-pass" {
t.Fatalf("body=%v", body)
}
return jsonResp(200, map[string]any{
"token": "fresh-token",
"expiresAt": "2099-01-01T00:00:00Z",
"user": map[string]any{"username": "admin", "role": "admin"},
}), nil
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithAuthPath(path),
)
res, err := c.Login(context.Background(), "admin", "secret-pass")
if err != nil {
t.Fatal(err)
}
if res.Username != "admin" || res.Role != "admin" || res.ExpiresAt != "2099-01-01T00:00:00Z" {
t.Fatalf("%+v", res)
}
props, err := veraetime.ReadProperties(path)
if err != nil {
t.Fatal(err)
}
if props["access_token"] != "fresh-token" || props["password"] != "secret-pass" {
t.Fatalf("%v", props)
}
}
func TestSignup_DoesNotSendBearer(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
if r.Header.Get("Authorization") != "" {
t.Fatal("signup must not send Authorization")
}
return jsonResp(201, map[string]any{"id": "u3", "username": "newcomer", "role": "user"}), nil
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
res, err := c.Signup(context.Background(), "newcomer", "secret-pass")
if err != nil {
t.Fatal(err)
}
if res.ID != "u3" || res.Username != "newcomer" {
t.Fatalf("%+v", res)
}
}
func TestSession_AndCreateUser(t *testing.T) {
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case strings.HasSuffix(r.URL.Path, "/auth/validate"):
if r.Header.Get("Authorization") != "Bearer fresh-token" {
return jsonResp(401, map[string]any{"error": "Unauthorized"}), nil
}
return jsonResp(200, map[string]any{
"valid": true, "username": "admin", "role": "admin", "expiresAt": "2099-01-01T00:00:00Z",
}), nil
case strings.HasSuffix(r.URL.Path, "/auth/users"):
body := readJSON(t, r)
return jsonResp(201, map[string]any{
"id": "u2", "username": body["username"], "role": body["role"],
}), nil
default:
return jsonResp(404, map[string]any{"error": "nope"}), nil
}
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt))
sess, err := c.Session(context.Background(), "fresh-token")
if err != nil {
t.Fatal(err)
}
if !sess.Valid || sess.Role != "admin" {
t.Fatalf("%+v", sess)
}
created, err := c.CreateUser(context.Background(), "newuser", "secret-pass", "user", "fresh-token")
if err != nil {
t.Fatal(err)
}
if created.ID != "u2" || created.Username != "newuser" {
t.Fatalf("%+v", created)
}
}
func TestKeeper_RefreshesNearExpiry(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
if err := veraetime.WriteProperties(path, map[string]string{
"access_token": "old-token",
"expires_at": time.Now().UTC().Add(30 * time.Second).Format(time.RFC3339),
"username": "admin",
"password": "secret-pass",
}); err != nil {
t.Fatal(err)
}
var mu sync.Mutex
var urls []string
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
mu.Lock()
urls = append(urls, r.URL.Path)
mu.Unlock()
if strings.HasSuffix(r.URL.Path, "/auth/login") {
return jsonResp(200, map[string]any{
"token": "fresh-token",
"expiresAt": "2099-01-01T00:00:00Z",
"user": map[string]any{"username": "admin", "role": "admin"},
}), nil
}
return jsonResp(404, map[string]any{"error": "nope"}), nil
})
c := veraetime.NewClient("https://api.example",
veraetime.WithRoundTripper(rt),
veraetime.WithAuthPath(path),
)
k := veraetime.NewKeeper(c, path)
tok, err := k.Token(context.Background())
if err != nil {
t.Fatal(err)
}
if tok != "fresh-token" {
t.Fatalf("tok=%q", tok)
}
mu.Lock()
defer mu.Unlock()
if len(urls) == 0 || !strings.HasSuffix(urls[0], "/auth/login") {
t.Fatalf("urls=%v", urls)
}
}
func TestKeeper_UsesGlobalTokenEnv(t *testing.T) {
t.Setenv("VERAE_GLOBAL_TOKEN", "env-bearer")
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) {
t.Fatal("no HTTP expected when VERAE_GLOBAL_TOKEN set")
return nil, nil
})))
k := veraetime.NewKeeper(c, t.TempDir()+"/missing.properties", veraetime.WithGlobalTokenEnv(true))
tok, err := k.Token(context.Background())
if err != nil {
t.Fatal(err)
}
if tok != "env-bearer" {
t.Fatalf("tok=%q", tok)
}
}
func TestStampWithMeta_SendsHistoryAndExtendsOnRestamp(t *testing.T) {
var posted []map[string]any
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/timestamp"):
posted = append(posted, readJSON(t, r))
return jsonResp(202, map[string]any{"jobId": "job-new"}), nil
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/hash/"):
return jsonResp(200, map[string]any{
"receipts": []any{map[string]any{"jobId": "job-old", "certificate": "QmOld"}},
}), nil
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/status/job-old"):
return jsonResp(200, map[string]any{
"status": "completed",
"metadata": map[string]any{
"title": "deed",
"tags": []any{"a"},
},
}), nil
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/status/job-new"):
return jsonResp(200, map[string]any{
"status": "completed",
"result": "QmNew",
"metadata": map[string]any{
"schema": "verae-meta-git/1",
},
}), nil
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
return nil, nil
}
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt), veraetime.WithPollInterval(time.Millisecond))
_, err := c.StampWithMeta(context.Background(), sha256Empty, "tok", veraetime.StampMeta{
Metadata: map[string]any{"place": "office"},
Restamp: true,
Message: "add the place",
At: "2026-10-11T02:00:00.000Z",
})
if err != nil {
t.Fatal(err)
}
if len(posted) != 1 {
t.Fatalf("posts=%d", len(posted))
}
if posted[0]["restamp"] != true {
t.Fatalf("restamp=%v", posted[0]["restamp"])
}
doc, ok := posted[0]["metadata"].(map[string]any)
if !ok || doc["schema"] != "verae-meta-git/1" {
t.Fatalf("metadata=%v", posted[0]["metadata"])
}
snap := doc["snapshot"].(map[string]any)
if snap["title"] != "deed" || snap["place"] != "office" {
t.Fatalf("snapshot=%v", snap)
}
if _, ok := posted[0]["metadata"]; !ok {
t.Fatal("missing metadata")
}
}
func TestKeeper_UsesFreshStoredToken(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"
if err := veraetime.WriteProperties(path, map[string]string{
"access_token": "still-good",
"expires_at": "2099-01-01T00:00:00Z",
"username": "admin",
"password": "secret-pass",
}); err != nil {
t.Fatal(err)
}
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(roundTripFunc(func(r *http.Request) (*http.Response, error) {
t.Fatal("no login expected")
return nil, nil
})))
k := veraetime.NewKeeper(c, path)
tok, err := k.Token(context.Background())
if err != nil {
t.Fatal(err)
}
if tok != "still-good" {
t.Fatalf("tok=%q", tok)
}
}