2026-10-25 18:08:53 +00:00
|
|
|
package httpapi
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"encoding/json"
|
|
|
|
|
"io"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/http/httptest"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
|
|
|
|
|
"basicstack.de/proofedmesh/services/transparency-log/pkg/backend"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func fixtureEnvelope() backend.Envelope {
|
|
|
|
|
return backend.Envelope{
|
|
|
|
|
PayloadType: "application/vnd.in-toto+json",
|
|
|
|
|
Payload: "aGVsbG8",
|
|
|
|
|
Signatures: []backend.EnvelopeSignature{{KeyID: "k", Sig: "c2ln"}},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func newServer(t *testing.T) *httptest.Server {
|
|
|
|
|
t.Helper()
|
|
|
|
|
s := &Server{Backend: backend.NewMemory()}
|
|
|
|
|
return httptest.NewServer(s.Handler())
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubmit_HappyPath_201(t *testing.T) {
|
|
|
|
|
srv := newServer(t)
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
env := fixtureEnvelope()
|
|
|
|
|
ch, err := backend.EnvelopeContentHash(env)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("hash: %v", err)
|
|
|
|
|
}
|
|
|
|
|
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: ch})
|
|
|
|
|
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusCreated {
|
|
|
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
|
t.Fatalf("want 201, got %d: %s", resp.StatusCode, raw)
|
|
|
|
|
}
|
|
|
|
|
var rct backend.Receipt
|
|
|
|
|
if err := json.NewDecoder(resp.Body).Decode(&rct); err != nil {
|
|
|
|
|
t.Fatalf("decode: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if rct.ContentHash != ch {
|
|
|
|
|
t.Errorf("content_hash echoed wrong: %q vs %q", rct.ContentHash, ch)
|
|
|
|
|
}
|
|
|
|
|
if rct.LogIndex != 0 {
|
|
|
|
|
t.Errorf("log_index: %d", rct.LogIndex)
|
|
|
|
|
}
|
|
|
|
|
if rct.InclusionProof.TreeSize != 1 {
|
|
|
|
|
t.Errorf("tree_size: %d", rct.InclusionProof.TreeSize)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubmit_Idempotent_200_SameIndex(t *testing.T) {
|
|
|
|
|
srv := newServer(t)
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
env := fixtureEnvelope()
|
|
|
|
|
ch, _ := backend.EnvelopeContentHash(env)
|
|
|
|
|
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: ch})
|
|
|
|
|
|
|
|
|
|
// First call -> 201
|
|
|
|
|
resp1, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post1: %v", err)
|
|
|
|
|
}
|
|
|
|
|
resp1.Body.Close()
|
|
|
|
|
if resp1.StatusCode != http.StatusCreated {
|
|
|
|
|
t.Fatalf("first status: %d", resp1.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Second call -> 200
|
|
|
|
|
resp2, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post2: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp2.Body.Close()
|
|
|
|
|
if resp2.StatusCode != http.StatusOK {
|
|
|
|
|
raw, _ := io.ReadAll(resp2.Body)
|
|
|
|
|
t.Fatalf("idempotent repeat status: want 200, got %d, body=%s", resp2.StatusCode, raw)
|
|
|
|
|
}
|
|
|
|
|
var rct2 backend.Receipt
|
|
|
|
|
if err := json.NewDecoder(resp2.Body).Decode(&rct2); err != nil {
|
|
|
|
|
t.Fatalf("decode: %v", err)
|
|
|
|
|
}
|
|
|
|
|
if rct2.LogIndex != 0 {
|
|
|
|
|
t.Errorf("idempotent log_index: want 0, got %d", rct2.LogIndex)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubmit_ContentHashMismatch_409(t *testing.T) {
|
|
|
|
|
srv := newServer(t)
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
env := fixtureEnvelope()
|
|
|
|
|
bogus := "sha256:" + strings.Repeat("0", 64)
|
|
|
|
|
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: bogus})
|
|
|
|
|
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusConflict {
|
|
|
|
|
raw, _ := io.ReadAll(resp.Body)
|
|
|
|
|
t.Fatalf("want 409, got %d: %s", resp.StatusCode, raw)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubmit_RejectsMissingContentHash(t *testing.T) {
|
|
|
|
|
srv := newServer(t)
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
body := `{"envelope":{"payloadType":"application/vnd.in-toto+json","payload":"aGVsbG8","signatures":[]}}`
|
|
|
|
|
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(body))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusBadRequest {
|
|
|
|
|
t.Fatalf("want 400, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestSubmit_UnknownRoute_404(t *testing.T) {
|
|
|
|
|
srv := newServer(t)
|
|
|
|
|
defer srv.Close()
|
|
|
|
|
resp, err := http.Post(srv.URL+"/api/v1/log/other", "application/json", strings.NewReader("{}"))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
|
|
|
t.Fatalf("want 404, got %d", resp.StatusCode)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604)
- services/transparency-log/pkg/backend/trillian.go: real gRPC client
on github.com/google/trillian v1.6.1. Submit() does QueueLeaf with the
canonical DSSE envelope as LeafValue and the raw content_hash bytes as
LeafIdentityHash, then polls GetLatestSignedLogRoot +
GetInclusionProofByHash until the leaf is sequenced. Idempotency uses
Trillian's ALREADY_EXISTS signal on the inner QueuedLogLeaf.Status, so
a repeated submit still returns 200 OK with the originally-assigned
log_index + a valid proof against the current STH.
- Unconfigured backend (missing endpoint or LogID) returns the historical
ErrTrillianUnavailable so the httpapi layer keeps mapping it to 503
instead of silently 500. Existing stub-returns-503 test updated.
- cmd/transparency-log: pointer-receiver Trillian wiring; flag docs
mention the production SRV name.
- services/transparency-log/Dockerfile: distroless multi-stage build
used by deploy/trillian/40-transparency-log.yaml.
- deploy/trillian/: full K8s stack for the agent-trust namespace —
MySQL 8.4 StatefulSet (schema init ConfigMap vendored from upstream
storage.sql v1.6.1), Trillian LogServer + LogSigner Deployments on
gcr.io/trillian-opensource-ci:v1.6.1, createtree Job that writes
the TreeID to a ConfigMap (re-apply safe), transparency-log
Deployment with --backend=trillian pulling TRILLIAN_LOG_ID from
the ConfigMap.
- deploy/trillian/README.md: first-time apply + smoke-test + secret
rotation + signer-key clarification (STH key is Trillian-internal;
DSSE envelope signing stays with signing-service / OpenBao).
- services/admin-api: go.mod / go.sum pick up the new transitive deps
(trillian + grpc) via the local replace on transparency-log.
- services/transparency-log/README.md: Backends table updated; next-
steps list no longer carries Trillian-deployment as a follow-up.
Deployed to the live cluster from this heartbeat: MySQL + LogServer +
LogSigner running in agent-trust, tree id 8482455225499059043, service
smoke-tested end-to-end (201 on first POST, 200 on idempotent repeat,
409 on content_hash mismatch, tree_size=2 with correct audit path).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:39:44 +00:00
|
|
|
func TestSubmit_TrillianUnconfigured_Returns503(t *testing.T) {
|
|
|
|
|
// An un-configured Trillian backend (no endpoint, no LogID) must map to
|
|
|
|
|
// 503 Service Unavailable, not 500. This keeps us from silently serving
|
|
|
|
|
// writes against a mis-deployed prod backend.
|
|
|
|
|
srv := httptest.NewServer((&Server{Backend: &backend.Trillian{}}).Handler())
|
2026-10-25 18:08:53 +00:00
|
|
|
defer srv.Close()
|
|
|
|
|
env := fixtureEnvelope()
|
|
|
|
|
ch, _ := backend.EnvelopeContentHash(env)
|
|
|
|
|
body, _ := json.Marshal(submitRequest{Envelope: env, ContentHash: ch})
|
|
|
|
|
resp, err := http.Post(srv.URL+"/api/v1/log/entries", "application/json", strings.NewReader(string(body)))
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("post: %v", err)
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
|
|
|
raw, _ := io.ReadAll(resp.Body)
|
transparency-log: real Trillian backend wiring + agent-trust deploy (DEV-604)
- services/transparency-log/pkg/backend/trillian.go: real gRPC client
on github.com/google/trillian v1.6.1. Submit() does QueueLeaf with the
canonical DSSE envelope as LeafValue and the raw content_hash bytes as
LeafIdentityHash, then polls GetLatestSignedLogRoot +
GetInclusionProofByHash until the leaf is sequenced. Idempotency uses
Trillian's ALREADY_EXISTS signal on the inner QueuedLogLeaf.Status, so
a repeated submit still returns 200 OK with the originally-assigned
log_index + a valid proof against the current STH.
- Unconfigured backend (missing endpoint or LogID) returns the historical
ErrTrillianUnavailable so the httpapi layer keeps mapping it to 503
instead of silently 500. Existing stub-returns-503 test updated.
- cmd/transparency-log: pointer-receiver Trillian wiring; flag docs
mention the production SRV name.
- services/transparency-log/Dockerfile: distroless multi-stage build
used by deploy/trillian/40-transparency-log.yaml.
- deploy/trillian/: full K8s stack for the agent-trust namespace —
MySQL 8.4 StatefulSet (schema init ConfigMap vendored from upstream
storage.sql v1.6.1), Trillian LogServer + LogSigner Deployments on
gcr.io/trillian-opensource-ci:v1.6.1, createtree Job that writes
the TreeID to a ConfigMap (re-apply safe), transparency-log
Deployment with --backend=trillian pulling TRILLIAN_LOG_ID from
the ConfigMap.
- deploy/trillian/README.md: first-time apply + smoke-test + secret
rotation + signer-key clarification (STH key is Trillian-internal;
DSSE envelope signing stays with signing-service / OpenBao).
- services/admin-api: go.mod / go.sum pick up the new transitive deps
(trillian + grpc) via the local replace on transparency-log.
- services/transparency-log/README.md: Backends table updated; next-
steps list no longer carries Trillian-deployment as a follow-up.
Deployed to the live cluster from this heartbeat: MySQL + LogServer +
LogSigner running in agent-trust, tree id 8482455225499059043, service
smoke-tested end-to-end (201 on first POST, 200 on idempotent repeat,
409 on content_hash mismatch, tree_size=2 with correct audit path).
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-25 18:39:44 +00:00
|
|
|
t.Fatalf("trillian unconfigured want 503, got %d: %s", resp.StatusCode, raw)
|
2026-10-25 18:08:53 +00:00
|
|
|
}
|
|
|
|
|
}
|