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.
468 lines
14 KiB
Go
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)
|
|
}
|
|
}
|