Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8444ec9be |
||
|
|
8f55e17f73 |
7 changed files with 639 additions and 6 deletions
24
.forgejo/workflows/test-and-deploy.yml
Normal file
24
.forgejo/workflows/test-and-deploy.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: test-and-deploy
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: 138
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Unit tests
|
||||||
|
run: bash scripts/test-138.sh
|
||||||
|
|
||||||
|
deploy-138:
|
||||||
|
needs: test
|
||||||
|
if: success() && github.event_name != 'pull_request' && github.ref == 'refs/heads/main'
|
||||||
|
runs-on: 138
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Deploy the host checkout
|
||||||
|
run: bash scripts/deploy-138.sh
|
||||||
341
internal/metadatahist/history.go
Normal file
341
internal/metadatahist/history.go
Normal file
|
|
@ -0,0 +1,341 @@
|
||||||
|
// Package metadatahist builds verae-meta-git/1 documents the same way as
|
||||||
|
// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete
|
||||||
|
// fields. A document that already uses the schema is stored as sent.
|
||||||
|
package metadatahist
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"sort"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const Schema = "verae-meta-git/1"
|
||||||
|
|
||||||
|
func IsBlank(value any) bool {
|
||||||
|
if value == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
for _, r := range v {
|
||||||
|
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
case []any:
|
||||||
|
return len(v) == 0
|
||||||
|
case map[string]any:
|
||||||
|
return len(v) == 0
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func isObject(value any) (map[string]any, bool) {
|
||||||
|
m, ok := value.(map[string]any)
|
||||||
|
return m, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func clone(value any) any {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []any:
|
||||||
|
out := make([]any, len(v))
|
||||||
|
for i := range v {
|
||||||
|
out[i] = clone(v[i])
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
case map[string]any:
|
||||||
|
out := make(map[string]any, len(v))
|
||||||
|
for k, item := range v {
|
||||||
|
out[k] = clone(item)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func jsonScalar(value any) string {
|
||||||
|
var buf bytes.Buffer
|
||||||
|
enc := json.NewEncoder(&buf)
|
||||||
|
enc.SetEscapeHTML(false)
|
||||||
|
_ = enc.Encode(value)
|
||||||
|
b := bytes.TrimRight(buf.Bytes(), "\n")
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonical(value any) string {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case []any:
|
||||||
|
parts := make([]string, len(v))
|
||||||
|
for i := range v {
|
||||||
|
parts[i] = canonical(v[i])
|
||||||
|
}
|
||||||
|
return "[" + join(parts) + "]"
|
||||||
|
case map[string]any:
|
||||||
|
keys := make([]string, 0, len(v))
|
||||||
|
for k := range v {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
parts := make([]string, len(keys))
|
||||||
|
for i, k := range keys {
|
||||||
|
parts[i] = jsonScalar(k) + ":" + canonical(v[k])
|
||||||
|
}
|
||||||
|
return "{" + join(parts) + "}"
|
||||||
|
default:
|
||||||
|
return jsonScalar(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func join(parts []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, p := range parts {
|
||||||
|
if i > 0 {
|
||||||
|
out += ","
|
||||||
|
}
|
||||||
|
out += p
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func same(a, b any) bool { return canonical(a) == canonical(b) }
|
||||||
|
|
||||||
|
func ExtendSnapshot(base, patch map[string]any) map[string]any {
|
||||||
|
out := map[string]any{}
|
||||||
|
if base != nil {
|
||||||
|
if cloned, ok := clone(base).(map[string]any); ok {
|
||||||
|
out = cloned
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if patch == nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
for key, next := range patch {
|
||||||
|
if IsBlank(next) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev, has := out[key]
|
||||||
|
prevObj, prevIsObj := isObject(prev)
|
||||||
|
nextObj, nextIsObj := isObject(next)
|
||||||
|
prevArr, prevIsArr := prev.([]any)
|
||||||
|
nextArr, nextIsArr := next.([]any)
|
||||||
|
switch {
|
||||||
|
case has && prevIsObj && nextIsObj:
|
||||||
|
out[key] = ExtendSnapshot(prevObj, nextObj)
|
||||||
|
case prevIsArr && nextIsArr:
|
||||||
|
extra := nonBlank(nextArr)
|
||||||
|
if len(extra) > 0 {
|
||||||
|
out[key] = append(append([]any{}, prevArr...), extra...)
|
||||||
|
}
|
||||||
|
case nextIsArr:
|
||||||
|
extra := nonBlank(nextArr)
|
||||||
|
if len(extra) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if prevIsArr {
|
||||||
|
out[key] = append(append([]any{}, prevArr...), extra...)
|
||||||
|
} else {
|
||||||
|
out[key] = extra
|
||||||
|
}
|
||||||
|
case nextIsObj:
|
||||||
|
nestedBase := map[string]any{}
|
||||||
|
if prevIsObj {
|
||||||
|
nestedBase = prevObj
|
||||||
|
}
|
||||||
|
nested := ExtendSnapshot(nestedBase, nextObj)
|
||||||
|
if !IsBlank(nested) {
|
||||||
|
out[key] = nested
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
out[key] = clone(next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonBlank(items []any) []any {
|
||||||
|
out := make([]any, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if !IsBlank(item) {
|
||||||
|
out = append(out, clone(item))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChangesBetween(before, after map[string]any) map[string]any {
|
||||||
|
added := map[string]any{}
|
||||||
|
extended := map[string]any{}
|
||||||
|
updated := map[string]any{}
|
||||||
|
if before == nil {
|
||||||
|
before = map[string]any{}
|
||||||
|
}
|
||||||
|
if after == nil {
|
||||||
|
after = map[string]any{}
|
||||||
|
}
|
||||||
|
keys := map[string]struct{}{}
|
||||||
|
for k := range before {
|
||||||
|
keys[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for k := range after {
|
||||||
|
keys[k] = struct{}{}
|
||||||
|
}
|
||||||
|
ordered := make([]string, 0, len(keys))
|
||||||
|
for k := range keys {
|
||||||
|
ordered = append(ordered, k)
|
||||||
|
}
|
||||||
|
sort.Strings(ordered)
|
||||||
|
for _, key := range ordered {
|
||||||
|
next, hasNext := after[key]
|
||||||
|
if !hasNext {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prev, hasPrev := before[key]
|
||||||
|
if !hasPrev {
|
||||||
|
added[key] = clone(next)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if same(prev, next) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prevObj, prevIsObj := isObject(prev)
|
||||||
|
nextObj, nextIsObj := isObject(next)
|
||||||
|
prevArr, prevIsArr := prev.([]any)
|
||||||
|
nextArr, nextIsArr := next.([]any)
|
||||||
|
switch {
|
||||||
|
case prevIsObj && nextIsObj:
|
||||||
|
nested := ChangesBetween(prevObj, nextObj)
|
||||||
|
if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) {
|
||||||
|
extended[key] = nested
|
||||||
|
}
|
||||||
|
case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr):
|
||||||
|
appended := nextArr[len(prevArr):]
|
||||||
|
if len(appended) > 0 {
|
||||||
|
extended[key] = map[string]any{"appended": clone(appended)}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
updated[key] = map[string]any{"from": clone(prev), "to": clone(next)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]any{"added": added, "extended": extended, "updated": updated}
|
||||||
|
}
|
||||||
|
|
||||||
|
func commitID(commit map[string]any) string {
|
||||||
|
material := canonical(map[string]any{
|
||||||
|
"parent": commit["parent"],
|
||||||
|
"at": commit["at"],
|
||||||
|
"message": commit["message"],
|
||||||
|
"snapshot": commit["snapshot"],
|
||||||
|
})
|
||||||
|
sum := sha256.Sum256([]byte(material))
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsHistory(value any) bool {
|
||||||
|
m, ok := isObject(value)
|
||||||
|
if !ok || m["schema"] != Schema {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if _, ok := m["commits"].([]any); !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok = isObject(m["snapshot"])
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func historyFromSnapshot(snapshot map[string]any, at string) map[string]any {
|
||||||
|
stored, _ := clone(snapshot).(map[string]any)
|
||||||
|
commit := map[string]any{
|
||||||
|
"parent": nil,
|
||||||
|
"at": at,
|
||||||
|
"message": "stamp",
|
||||||
|
"snapshot": stored,
|
||||||
|
"changes": ChangesBetween(map[string]any{}, stored),
|
||||||
|
}
|
||||||
|
commit["id"] = commitID(commit)
|
||||||
|
return map[string]any{
|
||||||
|
"schema": Schema,
|
||||||
|
"head": commit["id"],
|
||||||
|
"snapshot": clone(stored),
|
||||||
|
"commits": []any{commit},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceHistory(existing any, at string) map[string]any {
|
||||||
|
if IsHistory(existing) {
|
||||||
|
cloned, _ := clone(existing).(map[string]any)
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
if m, ok := isObject(existing); ok && !IsBlank(m) {
|
||||||
|
return historyFromSnapshot(m, at)
|
||||||
|
}
|
||||||
|
return map[string]any{
|
||||||
|
"schema": Schema,
|
||||||
|
"head": nil,
|
||||||
|
"snapshot": map[string]any{},
|
||||||
|
"commits": []any{},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasChanges(changes map[string]any) bool {
|
||||||
|
return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append builds the next history document. A nil result means the caller
|
||||||
|
// should omit metadata. restamp with a blank patch still appends one commit.
|
||||||
|
func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any {
|
||||||
|
if at == "" {
|
||||||
|
at = time.Now().UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch))
|
||||||
|
if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if priorAt == "" {
|
||||||
|
priorAt = at
|
||||||
|
}
|
||||||
|
doc := coerceHistory(existing, priorAt)
|
||||||
|
head, _ := doc["head"].(string)
|
||||||
|
if !restamp && blankPatch {
|
||||||
|
if head != "" {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
base, _ := doc["snapshot"].(map[string]any)
|
||||||
|
snapshot := ExtendSnapshot(base, patch)
|
||||||
|
changes := ChangesBetween(base, snapshot)
|
||||||
|
if !restamp && !hasChanges(changes) && head != "" {
|
||||||
|
return doc
|
||||||
|
}
|
||||||
|
msg := message
|
||||||
|
if msg == "" {
|
||||||
|
if head != "" {
|
||||||
|
msg = "restamp"
|
||||||
|
} else {
|
||||||
|
msg = "stamp"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var parent any
|
||||||
|
if head != "" {
|
||||||
|
parent = head
|
||||||
|
}
|
||||||
|
commit := map[string]any{
|
||||||
|
"parent": parent,
|
||||||
|
"at": at,
|
||||||
|
"message": msg,
|
||||||
|
"snapshot": snapshot,
|
||||||
|
"changes": changes,
|
||||||
|
}
|
||||||
|
commit["id"] = commitID(commit)
|
||||||
|
commits, _ := doc["commits"].([]any)
|
||||||
|
doc["commits"] = append(commits, commit)
|
||||||
|
doc["head"] = commit["id"]
|
||||||
|
doc["snapshot"] = snapshot
|
||||||
|
return doc
|
||||||
|
}
|
||||||
52
internal/metadatahist/history_test.go
Normal file
52
internal/metadatahist/history_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package metadatahist
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestAppendMatchesNodeReference(t *testing.T) {
|
||||||
|
first := Append(nil, map[string]any{"title": "deed", "tags": []any{"a"}}, false, "2026-10-11T01:00:00.000Z", "", "stamp")
|
||||||
|
if first["head"] != "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7" {
|
||||||
|
t.Fatalf("head1=%v", first["head"])
|
||||||
|
}
|
||||||
|
second := Append(first, map[string]any{"title": "deed-2", "tags": []any{"b"}, "place": "office"}, true, "2026-10-11T02:00:00.000Z", "", "correct the title")
|
||||||
|
if second["head"] != "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918" {
|
||||||
|
t.Fatalf("head2=%v", second["head"])
|
||||||
|
}
|
||||||
|
snap := second["snapshot"].(map[string]any)
|
||||||
|
if snap["title"] != "deed-2" || snap["place"] != "office" {
|
||||||
|
t.Fatalf("snapshot=%v", snap)
|
||||||
|
}
|
||||||
|
tags := snap["tags"].([]any)
|
||||||
|
if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
|
||||||
|
t.Fatalf("tags=%v", tags)
|
||||||
|
}
|
||||||
|
commits := second["commits"].([]any)
|
||||||
|
tip := commits[1].(map[string]any)
|
||||||
|
if tip["parent"] != first["head"] {
|
||||||
|
t.Fatalf("parent=%v", tip["parent"])
|
||||||
|
}
|
||||||
|
if _, ok := tip["changes"].(map[string]any)["removed"]; ok {
|
||||||
|
t.Fatal("removed key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBlankRestampKeepsSnapshot(t *testing.T) {
|
||||||
|
base := map[string]any{"title": "deed", "note": "keep", "tags": []any{"a"}}
|
||||||
|
next := Append(base, map[string]any{"title": "", "note": nil, "tags": []any{}}, true, "2026-10-11T01:47:00.000Z", "2026-10-11T01:00:00.000Z", "")
|
||||||
|
snap := next["snapshot"].(map[string]any)
|
||||||
|
if snap["title"] != "deed" || snap["note"] != "keep" {
|
||||||
|
t.Fatalf("snapshot=%v", snap)
|
||||||
|
}
|
||||||
|
commits := next["commits"].([]any)
|
||||||
|
if len(commits) != 2 {
|
||||||
|
t.Fatalf("commits=%d", len(commits))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOmitWhenEmpty(t *testing.T) {
|
||||||
|
if Append(nil, nil, false, "2026-10-11T01:00:00.000Z", "", "") != nil {
|
||||||
|
t.Fatal("expected omit")
|
||||||
|
}
|
||||||
|
if Append(nil, map[string]any{}, false, "2026-10-11T01:00:00.000Z", "", "") != nil {
|
||||||
|
t.Fatal("expected omit")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -12,6 +12,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||||
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/metadatahist"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|
@ -73,18 +74,30 @@ func (c *Client) Stamp(digest, documentName string) (Receipt, error) {
|
||||||
case auth.ChannelZapier:
|
case auth.ChannelZapier:
|
||||||
return c.stampZapier(digest, documentName)
|
return c.stampZapier(digest, documentName)
|
||||||
default:
|
default:
|
||||||
return c.stampVerae(digest)
|
return c.stampVerae(digest, documentName)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) stampVerae(digest string) (Receipt, error) {
|
func historyForTitle(documentName string) map[string]any {
|
||||||
|
title := strings.TrimSpace(documentName)
|
||||||
|
if title == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return metadatahist.Append(nil, map[string]any{"title": title}, false, "", "", "stamp")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) stampVerae(digest, documentName string) (Receipt, error) {
|
||||||
base := c.veraeBase()
|
base := c.veraeBase()
|
||||||
body, _ := json.Marshal(map[string]any{
|
payload := map[string]any{
|
||||||
"data": digest,
|
"data": digest,
|
||||||
"digest": digest,
|
"digest": digest,
|
||||||
"hashAlg": "SHA256",
|
"hashAlg": "SHA256",
|
||||||
"restamp": false,
|
"restamp": false,
|
||||||
})
|
}
|
||||||
|
if doc := historyForTitle(documentName); doc != nil {
|
||||||
|
payload["metadata"] = doc
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body))
|
req, err := http.NewRequest(http.MethodPost, base+"/api/timestamp", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Receipt{}, err
|
return Receipt{}, err
|
||||||
|
|
@ -287,12 +300,16 @@ func (c *Client) enrichFromHash(base string, rec Receipt) (Receipt, error) {
|
||||||
|
|
||||||
func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
|
func (c *Client) stampZapier(digest, documentName string) (Receipt, error) {
|
||||||
base := c.zapierBase()
|
base := c.zapierBase()
|
||||||
body, _ := json.Marshal(map[string]any{
|
payload := map[string]any{
|
||||||
"sha256": digest,
|
"sha256": digest,
|
||||||
"attachPdf": true,
|
"attachPdf": true,
|
||||||
"wait": true,
|
"wait": true,
|
||||||
"documentName": documentName,
|
"documentName": documentName,
|
||||||
})
|
}
|
||||||
|
if doc := historyForTitle(documentName); doc != nil {
|
||||||
|
payload["metadata"] = doc
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(payload)
|
||||||
req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body))
|
req, err := http.NewRequest(http.MethodPost, base+"/v1/timestamp", bytes.NewReader(body))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Receipt{}, err
|
return Receipt{}, err
|
||||||
|
|
|
||||||
|
|
@ -2,16 +2,93 @@ package veraeapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/auth"
|
||||||
)
|
)
|
||||||
|
|
||||||
const digest64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
const digest64 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
|
||||||
|
|
||||||
|
func TestStampVeraeSendsTitleHistory(t *testing.T) {
|
||||||
|
var posted map[string]any
|
||||||
|
c := &Client{
|
||||||
|
Channel: auth.ChannelVerae,
|
||||||
|
VeraeBase: "https://api.example.test",
|
||||||
|
HTTPDo: func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Path == "/api/timestamp" {
|
||||||
|
raw, _ := io.ReadAll(req.Body)
|
||||||
|
_ = json.Unmarshal(raw, &posted)
|
||||||
|
body := `{"jobId":"job-1","certificate":"QmHist","existing":false}`
|
||||||
|
return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/api/status/") {
|
||||||
|
body := `{"status":"completed","result":"QmHist","completedAt":"2026-10-11T01:00:00Z"}`
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" {
|
||||||
|
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("path %s", req.URL.Path)
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.PollInterval = time.Millisecond
|
||||||
|
if _, err := c.Stamp(digest64, "Deed.pdf"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
meta, _ := posted["metadata"].(map[string]any)
|
||||||
|
if meta["schema"] != "verae-meta-git/1" {
|
||||||
|
t.Fatalf("metadata=%v", posted["metadata"])
|
||||||
|
}
|
||||||
|
snap, _ := meta["snapshot"].(map[string]any)
|
||||||
|
if snap["title"] != "Deed.pdf" {
|
||||||
|
t.Fatalf("snapshot=%v", snap)
|
||||||
|
}
|
||||||
|
if posted["restamp"] != false {
|
||||||
|
t.Fatalf("restamp=%v", posted["restamp"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStampVeraeOmitsMetadataWithoutTitle(t *testing.T) {
|
||||||
|
var posted map[string]any
|
||||||
|
c := &Client{
|
||||||
|
Channel: auth.ChannelVerae,
|
||||||
|
VeraeBase: "https://api.example.test",
|
||||||
|
HTTPDo: func(req *http.Request) (*http.Response, error) {
|
||||||
|
if req.URL.Path == "/api/timestamp" {
|
||||||
|
raw, _ := io.ReadAll(req.Body)
|
||||||
|
_ = json.Unmarshal(raw, &posted)
|
||||||
|
body := `{"jobId":"job-1","certificate":"QmPlain","existing":false}`
|
||||||
|
return &http.Response{StatusCode: 202, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/api/status/") {
|
||||||
|
body := `{"status":"completed","result":"QmPlain","completedAt":"2026-10-11T01:00:00Z"}`
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(body)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(req.URL.Path, "/api/hash/") || req.URL.Path == "/api/verify" {
|
||||||
|
return &http.Response{StatusCode: 404, Body: io.NopCloser(strings.NewReader(`{}`)), Header: make(http.Header)}, nil
|
||||||
|
}
|
||||||
|
t.Fatalf("path %s", req.URL.Path)
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
}
|
||||||
|
c.PollInterval = time.Millisecond
|
||||||
|
if _, err := c.Stamp(digest64, " "); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, ok := posted["metadata"]; ok {
|
||||||
|
t.Fatalf("metadata=%v", posted["metadata"])
|
||||||
|
}
|
||||||
|
if posted["restamp"] != false {
|
||||||
|
t.Fatalf("restamp=%v", posted["restamp"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestStampVeraeImmediateCertificate(t *testing.T) {
|
func TestStampVeraeImmediateCertificate(t *testing.T) {
|
||||||
c := &Client{
|
c := &Client{
|
||||||
Channel: auth.ChannelVerae,
|
Channel: auth.ChannelVerae,
|
||||||
|
|
|
||||||
117
scripts/deploy-138.sh
Executable file
117
scripts/deploy-138.sh
Executable file
|
|
@ -0,0 +1,117 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Test, build, and install the Linux verae-print binary on ns1.
|
||||||
|
# Does not install a CUPS queue, and does not restart verae-timestamping-svc
|
||||||
|
# or forgejo-runner. Does not read secret files.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
LOCK="${LOCK:-/tmp/verae-print-deploy.lock}"
|
||||||
|
DEST="${DEST:-/opt/verae/printtoveraetimestamp}"
|
||||||
|
BIN_LINK="${BIN_LINK:-/usr/local/bin/verae-print}"
|
||||||
|
BACKUP_ROOT="${BACKUP_ROOT:-/SSD2/verae-deploy-backups}"
|
||||||
|
GO_ROOT="${GO_ROOT:-/usr/local/lib/go1.26.9}"
|
||||||
|
GO_TARBALL="${GO_TARBALL:-go1.26.9.linux-amd64.tar.gz}"
|
||||||
|
GO_SHA256="${GO_SHA256:-42d158b4d8f7b61ac0a830567c940a86098fb7aac52e467a5ebec03ef5cc2f8d}"
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
|
||||||
|
mkdir -p "$(dirname "$LOCK")"
|
||||||
|
exec 9>"$LOCK"
|
||||||
|
if ! flock -w 900 9; then
|
||||||
|
echo "another print deploy holds the lock" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
prepare_cache() {
|
||||||
|
sudo mkdir -p "$BACKUP_ROOT/go-cache" "$BACKUP_ROOT/go-mod"
|
||||||
|
sudo chmod 1777 "$BACKUP_ROOT" "$BACKUP_ROOT/go-cache" "$BACKUP_ROOT/go-mod"
|
||||||
|
export GOCACHE="$BACKUP_ROOT/go-cache"
|
||||||
|
export GOMODCACHE="$BACKUP_ROOT/go-mod"
|
||||||
|
export GOPROXY="${GOPROXY:-https://proxy.golang.org,direct}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_go() {
|
||||||
|
if [[ -x "$GO_ROOT/bin/go" ]] && "$GO_ROOT/bin/go" version | grep -q 'go1.26.9 '; then
|
||||||
|
export PATH="$GO_ROOT/bin:$PATH"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp -d)"
|
||||||
|
curl -fsSL -o "$tmp/go.tgz" "https://go.dev/dl/${GO_TARBALL}"
|
||||||
|
echo "${GO_SHA256} ${tmp}/go.tgz" | sha256sum -c -
|
||||||
|
tar -C "$tmp" -xzf "$tmp/go.tgz"
|
||||||
|
sudo rm -rf "$GO_ROOT"
|
||||||
|
sudo mv "$tmp/go" "$GO_ROOT"
|
||||||
|
rm -rf "$tmp"
|
||||||
|
export PATH="$GO_ROOT/bin:$PATH"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tests() {
|
||||||
|
prepare_cache
|
||||||
|
ensure_go
|
||||||
|
cd "$ROOT"
|
||||||
|
go test ./... -count=1
|
||||||
|
}
|
||||||
|
|
||||||
|
install_tree() {
|
||||||
|
local built stage previous ts sha
|
||||||
|
ts="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||||
|
sha="$(git -C "$ROOT" rev-parse HEAD)"
|
||||||
|
cd "$ROOT"
|
||||||
|
built="$(mktemp)"
|
||||||
|
go build -o "$built" ./cmd/verae-print
|
||||||
|
if ! grep -a -q 'verae-meta-git/1' "$built"; then
|
||||||
|
echo "built binary is missing verae-meta-git/1" >&2
|
||||||
|
rm -f "$built"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if ! "$built" version >/dev/null; then
|
||||||
|
echo "built binary failed version" >&2
|
||||||
|
rm -f "$built"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
stage="$(mktemp -d "$BACKUP_ROOT/print-stage.XXXXXX")"
|
||||||
|
sudo rsync -a \
|
||||||
|
--exclude '.git/' \
|
||||||
|
--exclude 'bin/' \
|
||||||
|
"$ROOT/" "$stage/"
|
||||||
|
sudo mkdir -p "$stage/bin"
|
||||||
|
sudo cp "$built" "$stage/bin/verae-print"
|
||||||
|
sudo chmod 755 "$stage/bin/verae-print"
|
||||||
|
printf '%s\n' "$sha" | sudo tee "$stage/DEPLOYED_SHA" >/dev/null
|
||||||
|
rm -f "$built"
|
||||||
|
if ! sudo "$stage/bin/verae-print" version >/dev/null; then
|
||||||
|
sudo rm -rf "$stage"
|
||||||
|
echo "staged print binary failed version" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
previous=""
|
||||||
|
if [[ -d "$DEST" ]]; then
|
||||||
|
previous="$BACKUP_ROOT/print-src-${ts}"
|
||||||
|
sudo mv "$DEST" "$previous"
|
||||||
|
fi
|
||||||
|
if ! sudo mv "$stage" "$DEST"; then
|
||||||
|
if [[ -n "$previous" ]]; then
|
||||||
|
sudo mv "$previous" "$DEST"
|
||||||
|
fi
|
||||||
|
echo "could not move the print checkout into place" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
sudo ln -sfn "$DEST/bin/verae-print" "$BIN_LINK"
|
||||||
|
if ! "$BIN_LINK" version >/dev/null; then
|
||||||
|
echo "installed verae-print failed version" >&2
|
||||||
|
if [[ -n "$previous" ]]; then
|
||||||
|
sudo rm -rf "$DEST"
|
||||||
|
sudo mv "$previous" "$DEST"
|
||||||
|
sudo ln -sfn "$DEST/bin/verae-print" "$BIN_LINK"
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "deployed ${ts} ${sha} version ok"
|
||||||
|
}
|
||||||
|
|
||||||
|
run_tests
|
||||||
|
if [[ "${1:-}" == "--test-only" ]]; then
|
||||||
|
echo "tests ok"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
install_tree
|
||||||
5
scripts/test-138.sh
Executable file
5
scripts/test-138.sh
Executable file
|
|
@ -0,0 +1,5 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Unit-test PrintToVeraeTimeStamp. Does not install the binary.
|
||||||
|
set -euo pipefail
|
||||||
|
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
exec bash "$ROOT/scripts/deploy-138.sh" --test-only
|
||||||
Loading…
Reference in a new issue