niko_trust/internal/server/server_test.go
Niko Marmeladkov 9d66003689
Initial commit: signed-object trust relay, verifier, and docs
- server: relay storing signed objects (PUT/GET), per-IP rate limiting,
  per-subject quota (1000), one-response-per-request, pagination,
  /v1/healthz /v1/readyz /v1/metrics
- verify: signature-verifying trust evaluator; every object is checked via
  env.Verify(), approvals via VerifyApprovalResponse, revocations via
  VerifyRevocationOf; k-of-n approval quorum
- docs: TRUST-MODEL.md and API.md describing issuer-anchored signatures and
  the endpoint/status-code contract
- tests: server, verify, and ratelimit packages
2026-08-12 22:36:49 +03:00

884 lines
25 KiB
Go

package server_test
import (
"bytes"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"git.n1ko.dev/Niko/niko_trust/internal/identity/signer"
"git.n1ko.dev/Niko/niko_trust/internal/protocol"
"git.n1ko.dev/Niko/niko_trust/internal/server"
"git.n1ko.dev/Niko/niko_trust/internal/verify"
"git.n1ko.dev/Niko/niko_trust/internal/tce"
"git.n1ko.dev/Niko/niko_trust/internal/transport"
)
func newTestServer(t *testing.T) *httptest.Server {
t.Helper()
srv := server.New("trust.n1ko.dev", "")
return httptest.NewServer(srv.Handler())
}
func postEnvelope(t *testing.T, url string, env *transport.Envelope) *http.Response {
t.Helper()
var buf bytes.Buffer
if err := json.NewEncoder(&buf).Encode(env); err != nil {
t.Fatal(err)
}
resp, err := http.Post(url, "application/json", &buf)
if err != nil {
t.Fatal(err)
}
return resp
}
// authToken performs the challenge/assert handshake and returns the session
// token, for use by tests that need an authenticated read.
func authToken(t *testing.T, baseURL string, key *signer.Signer) string {
t.Helper()
cr, err := http.Post(baseURL+"/v1/auth/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
var chRes struct{ Challenge string `json:"challenge"` }
_ = json.NewDecoder(cr.Body).Decode(&chRes)
cr.Body.Close()
ch, err := hex.DecodeString(chRes.Challenge)
if err != nil {
t.Fatal(err)
}
a := &protocol.AuthAssertion{
PubKey: key.Public(),
Challenge: ch,
Scope: "read",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, err := protocol.EncodeAuthAssertion(a)
if err != nil {
t.Fatal(err)
}
sig := key.Sign(tceBytes)
resp := postEnvelope(t, baseURL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusOK {
t.Fatalf("assert status %d", resp.StatusCode)
}
var out struct {
SessionToken string `json:"session_token"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
if out.SessionToken == "" {
t.Fatal("no session token")
}
return out.SessionToken
}
func TestPublishAndGetClaim(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"flag.example": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x01}, tce.NonceSize),
}
tceBytes, err := protocol.EncodeClaim(c)
if err != nil {
t.Fatal(err)
}
sig := issuer.Sign(tceBytes)
// Publish.
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusOK {
t.Fatalf("publish status %d", resp.StatusCode)
}
var pub struct {
ObjectID string `json:"object_id"`
}
_ = json.NewDecoder(resp.Body).Decode(&pub)
resp.Body.Close()
if pub.ObjectID == "" {
t.Fatal("no object_id returned")
}
// The returned ID must equal SHA-256(tce).
if pub.ObjectID != tce.ComputeID(tceBytes).String() {
t.Fatal("object_id mismatch")
}
// Fetch it back.
got, err := http.Get(ts.URL + "/v1/objects/" + pub.ObjectID)
if err != nil {
t.Fatal(err)
}
defer got.Body.Close()
if got.StatusCode != http.StatusOK {
t.Fatalf("get status %d", got.StatusCode)
}
var env transport.Envelope
if err := json.NewDecoder(got.Body).Decode(&env); err != nil {
t.Fatal(err)
}
// The verifier checks the signature over the exact TCE bytes.
if typ, err := env.Verify(); err != nil {
t.Fatalf("verify %s: %v", typ, err)
}
// Query by subject. The read endpoint now requires an authenticated
// session; authenticate as the subject itself with scope read:claims.
tok := authToken(t, ts.URL, subject)
q, err := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String(), nil)
if err != nil {
t.Fatal(err)
}
q.Header.Set("Authorization", "Bearer "+tok)
qresp, err := http.DefaultClient.Do(q)
if err != nil {
t.Fatal(err)
}
defer qresp.Body.Close()
if qresp.StatusCode != http.StatusOK {
t.Fatalf("claims status %d", qresp.StatusCode)
}
var list struct {
Claims []json.RawMessage `json:"claims"`
}
if err := json.NewDecoder(qresp.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if len(list.Claims) != 1 {
t.Fatalf("expected 1 claim, got %d", len(list.Claims))
}
}
func TestAuthHandshake(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
// Challenge.
cr, err := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
if err != nil {
t.Fatal(err)
}
var chRes struct{ Challenge string `json:"challenge"` }
_ = json.NewDecoder(cr.Body).Decode(&chRes)
cr.Body.Close()
ch, err := hex.DecodeString(chRes.Challenge)
if err != nil {
t.Fatal(err)
}
// Assertion.
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: ch,
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
b, err := protocol.EncodeAuthAssertion(a)
if err != nil {
t.Fatal(err)
}
sig := client.Sign(b)
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: b, Signature: sig})
if resp.StatusCode != http.StatusOK {
t.Fatalf("assert status %d", resp.StatusCode)
}
var out struct {
SessionToken string `json:"session_token"`
Identity string `json:"identity"`
}
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
if out.SessionToken == "" {
t.Fatal("no session token")
}
if out.Identity != client.Address().String() {
t.Fatalf("identity mismatch: %s != %s", out.Identity, client.Address().String())
}
}
func TestRejectsBadObjectID(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
}
tceBytes, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(tceBytes)
// Lie about the object_id.
resp := postEnvelope(t, ts.URL+"/v1/objects", &transport.Envelope{
TCE: tceBytes, Signature: sig, ObjectID: "deadbeef",
})
if resp.StatusCode != http.StatusUnprocessableEntity {
t.Fatalf("expected 422, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestGetMissingReturns404(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/v1/objects/" + hex.EncodeToString(bytes.Repeat([]byte{0}, 32)))
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestDuplicatePutIsIdempotent(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x03}, tce.NonceSize),
}
tceBytes, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
for i := 0; i < 2; i++ {
resp := postEnvelope(t, ts.URL+"/v1/objects", env)
if resp.StatusCode != http.StatusOK {
t.Fatalf("put %d: expected 200, got %d", i, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestAssertRejectsBadSignature(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
claimer, _ := signer.Generate()
impostor, _ := signer.Generate()
a := &protocol.AuthAssertion{
PubKey: claimer.Public(),
Challenge: bytes.Repeat([]byte{0x07}, tce.ChallengeSize),
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
// Sign with the wrong key.
sig := impostor.Sign(tceBytes)
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestAssertRejectsUnissuedChallenge(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
// Assertion bound to a challenge the server never issued.
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: bytes.Repeat([]byte{0x09}, tce.ChallengeSize),
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
sig := client.Sign(tceBytes)
resp := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401 for unissued challenge, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestAssertChallengeIsSingleUse(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
chRes := struct{ Challenge string `json:"challenge"` }{}
resp, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
_ = json.NewDecoder(resp.Body).Decode(&chRes)
resp.Body.Close()
ch, _ := hex.DecodeString(chRes.Challenge)
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: ch,
Scope: "read:claims",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
sig := client.Sign(tceBytes)
env := &transport.Envelope{TCE: tceBytes, Signature: sig}
// First use succeeds.
r1 := postEnvelope(t, ts.URL+"/v1/auth/assert", env)
if r1.StatusCode != http.StatusOK {
t.Fatalf("first assert: expected 200, got %d", r1.StatusCode)
}
r1.Body.Close()
// Replaying the same challenge (captured assertion) is rejected.
r2 := postEnvelope(t, ts.URL+"/v1/auth/assert", env)
if r2.StatusCode != http.StatusUnauthorized {
t.Fatalf("replay: expected 401, got %d", r2.StatusCode)
}
r2.Body.Close()
}
func TestClaimsRequiresAuth(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/v1/claims?subject=" + hex.EncodeToString(bytes.Repeat([]byte{0}, 32)))
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("expected 401, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestClaimsInsufficientScope(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
client, _ := signer.Generate()
// Authenticate with a scope that does not cover read:claims.
cr, _ := http.Post(ts.URL+"/v1/auth/challenge", "application/json", nil)
var chRes struct{ Challenge string `json:"challenge"` }
_ = json.NewDecoder(cr.Body).Decode(&chRes)
cr.Body.Close()
ch, _ := hex.DecodeString(chRes.Challenge)
a := &protocol.AuthAssertion{
PubKey: client.Public(),
Challenge: ch,
Scope: "read:requests",
Audience: "trust.n1ko.dev",
CreatedAt: uint64(time.Now().Unix()),
}
tceBytes, _ := protocol.EncodeAuthAssertion(a)
sig := client.Sign(tceBytes)
ar := postEnvelope(t, ts.URL+"/v1/auth/assert", &transport.Envelope{TCE: tceBytes, Signature: sig})
var out struct{ SessionToken string `json:"session_token"` }
_ = json.NewDecoder(ar.Body).Decode(&out)
ar.Body.Close()
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject=x", nil)
req.Header.Set("Authorization", "Bearer "+out.SessionToken)
resp, _ := http.DefaultClient.Do(req)
if resp.StatusCode != http.StatusForbidden {
t.Fatalf("expected 403, got %d", resp.StatusCode)
}
resp.Body.Close()
}
func TestStorePersistsAcrossRestart(t *testing.T) {
dir := t.TempDir()
// Start one server, publish an object.
s1 := server.New("trust.n1ko.dev", dir)
issuer, _ := signer.Generate()
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: issuer.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: 1_700_000_000,
ExpiresAt: 1_700_086_400,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x04}, tce.NonceSize),
}
tceBytes, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(tceBytes)
id, err := s1.Store().Put(tceBytes, sig, "")
if err != nil {
t.Fatal(err)
}
// A fresh server over the same directory must recover the object.
s2 := server.New("trust.n1ko.dev", dir)
env, err := s2.Store().Get(id)
if err != nil {
t.Fatalf("object not recovered after restart: %v", err)
}
if _, err := env.Verify(); err != nil {
t.Fatalf("recovered object failed verify: %v", err)
}
if got, _ := s2.Store().ClaimsBySubject(issuer.Address().String()); len(got) != 1 {
t.Fatalf("expected 1 recovered claim, got %d", len(got))
}
}
func TestConfigReportsAudience(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
resp, _ := http.Get(ts.URL + "/v1/config")
if resp.StatusCode != http.StatusOK {
t.Fatalf("config status %d", resp.StatusCode)
}
var cfg struct{ Audience string `json:"audience"` }
_ = json.NewDecoder(resp.Body).Decode(&cfg)
resp.Body.Close()
if cfg.Audience != "trust.n1ko.dev" {
t.Fatalf("audience = %q", cfg.Audience)
}
}
func TestReadEndpointsRequireAuth(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
for _, ep := range []string{
"/v1/claims?subject=x",
"/v1/requests?recipient=x",
"/v1/responses?request=x",
"/v1/revocations?claim=x",
} {
resp, _ := http.Get(ts.URL + ep)
if resp.StatusCode != http.StatusUnauthorized {
t.Fatalf("%s: expected 401, got %d", ep, resp.StatusCode)
}
resp.Body.Close()
}
}
func TestApprovalFlow(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
Message: "approve",
CreatedAt: now,
ExpiresAt: now + 30,
Nonce: bytes.Repeat([]byte{0x11}, tce.NonceSize),
}
reqTCE, _ := protocol.EncodeApprovalRequest(req)
reqID := tce.ComputeID(reqTCE).String()
if _, err := sPut(ts, reqTCE, issuer.Sign(reqTCE)); err != nil {
t.Fatal(err)
}
resp := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqTCE),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: now + 5,
Nonce: bytes.Repeat([]byte{0x22}, tce.NonceSize),
}
respTCE, _ := protocol.EncodeApprovalResponse(resp)
if _, err := sPut(ts, respTCE, approver.Sign(respTCE)); err != nil {
t.Fatal(err)
}
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"admin": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: bytes.Repeat([]byte{0x33}, tce.NonceSize),
}
claimTCE, _ := protocol.EncodeClaim(c)
claimID, err := sPut(ts, claimTCE, issuer.Sign(claimTCE))
if err != nil {
t.Fatal(err)
}
rv := &protocol.Revocation{
Issuer: issuer.Public(),
ClaimID: tce.ComputeID(claimTCE),
Reason: "bad",
CreatedAt: now,
Nonce: bytes.Repeat([]byte{0x44}, tce.NonceSize),
}
rvTCE, _ := protocol.EncodeRevocation(rv)
if _, err := sPut(ts, rvTCE, issuer.Sign(rvTCE)); err != nil {
t.Fatal(err)
}
// Authenticate with a generic read token.
tok := authToken(t, ts.URL, subject)
check := func(ep string, want int) []json.RawMessage {
r, _ := http.NewRequest(http.MethodGet, ts.URL+ep, nil)
r.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(r)
if resp.StatusCode != want {
t.Fatalf("%s: expected %d, got %d", ep, want, resp.StatusCode)
}
var wrap struct {
Requests []json.RawMessage `json:"requests"`
Responses []json.RawMessage `json:"responses"`
Revocations []json.RawMessage `json:"revocations"`
}
_ = json.NewDecoder(resp.Body).Decode(&wrap)
resp.Body.Close()
switch {
case wrap.Requests != nil:
return wrap.Requests
case wrap.Responses != nil:
return wrap.Responses
case wrap.Revocations != nil:
return wrap.Revocations
}
return nil
}
if got := check("/v1/requests?recipient="+approver.Address().String(), http.StatusOK); len(got) != 1 {
t.Fatalf("requests: expected 1, got %d", len(got))
}
if got := check("/v1/responses?request="+reqID, http.StatusOK); len(got) != 1 {
t.Fatalf("responses: expected 1, got %d", len(got))
}
if got := check("/v1/revocations?claim="+claimID, http.StatusOK); len(got) != 1 {
t.Fatalf("revocations: expected 1, got %d", len(got))
}
}
// sPut publishes a raw envelope to the relay.
func sPut(ts *httptest.Server, tceBytes, sig []byte) (string, error) {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(&transport.Envelope{TCE: tceBytes, Signature: sig})
resp, err := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
if err != nil {
return "", err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return "", fmt.Errorf("put status %d", resp.StatusCode)
}
var out struct{ ObjectID string `json:"object_id"` }
_ = json.NewDecoder(resp.Body).Decode(&out)
resp.Body.Close()
return out.ObjectID, nil
}
func TestPutStoresUnverifiedEnvelope(t *testing.T) {
// The relay is a dumb store: it accepts any well-formed envelope regardless
// of signature validity. Signature checking is deferred to the verifier
// (verify.Graph.Add), which is what decides "should I believe it".
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(cb)
// Tamper with the signature so verification must fail downstream.
bad := make([]byte, len(sig))
copy(bad, sig)
bad[len(bad)-1] ^= 0xff
env := &transport.Envelope{TCE: cb, Signature: bad}
resp := postEnvelope(t, ts.URL+"/v1/objects", env)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("relay should store unverified envelope, got %d", resp.StatusCode)
}
// The verifier must reject the tampered envelope.
if err := verify.NewGraph().Add(env); err == nil {
t.Fatal("expected verify.Graph.Add to reject tampered signature")
}
}
func TestIdempotentPut(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
sig := issuer.Sign(cb)
id1, err := sPut(ts, cb, sig)
if err != nil {
t.Fatal(err)
}
id2, err := sPut(ts, cb, sig)
if err != nil {
t.Fatal(err)
}
if id1 != id2 {
t.Fatalf("expected identical object id on replay, got %q and %q", id1, id2)
}
// A replayed claim must not create a duplicate: only one claim for subject.
tok := authToken(t, ts.URL, subject)
r, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String(), nil)
r.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(r)
var list struct {
Claims []json.RawMessage `json:"claims"`
}
_ = json.NewDecoder(resp.Body).Decode(&list)
resp.Body.Close()
if len(list.Claims) != 1 {
t.Fatalf("expected exactly 1 stored claim after replay, got %d", len(list.Claims))
}
}
func TestHealthMetricsEndpoints(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
for _, ep := range []string{"/v1/healthz", "/v1/readyz"} {
resp, _ := http.Get(ts.URL + ep)
if resp.StatusCode != http.StatusOK {
t.Fatalf("%s: expected 200, got %d", ep, resp.StatusCode)
}
resp.Body.Close()
}
resp, _ := http.Get(ts.URL + "/v1/metrics")
if resp.StatusCode != http.StatusOK {
t.Fatalf("metrics: expected 200, got %d", resp.StatusCode)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
if !strings.Contains(string(body), "trust_objects_stored") {
t.Fatalf("metrics missing objects_stored: %s", body)
}
}
func TestPagination(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
// Publish three distinct claims about the same subject.
for i := 0; i < 3; i++ {
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Number(fmt.Sprintf("%d", i))},
CreatedAt: now - 10,
Serial: uint64(i + 1),
Nonce: bytes.Repeat([]byte{byte(i + 1)}, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
if _, err := sPut(ts, cb, issuer.Sign(cb)); err != nil {
t.Fatal(err)
}
}
tok := authToken(t, ts.URL, subject)
r, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/claims?subject="+subject.Address().String()+"&limit=1&offset=1", nil)
r.Header.Set("Authorization", "Bearer "+tok)
resp, _ := http.DefaultClient.Do(r)
var list struct {
Claims []json.RawMessage `json:"claims"`
}
_ = json.NewDecoder(resp.Body).Decode(&list)
resp.Body.Close()
if len(list.Claims) != 1 {
t.Fatalf("expected 1 paginated claim, got %d", len(list.Claims))
}
}
func TestPerSubjectQuota(t *testing.T) {
// Exercise handlePut directly (bypassing the HTTP rate limiter) so we can
// publish enough distinct claims to reach the per-subject quota.
srv := server.New("trust.n1ko.dev", "")
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
const n = 1000
for i := 0; i < n; i++ {
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Number(fmt.Sprintf("%d", i))},
CreatedAt: now - 10,
Serial: uint64(i + 1),
Nonce: bytes.Repeat([]byte{byte((i + 1) & 0xff)}, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
env := &transport.Envelope{TCE: cb, Signature: issuer.Sign(cb)}
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/v1/objects", &buf)
srv.HandlePut(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("claim %d: expected 200, got %d", i, rec.Code)
}
}
// The next distinct claim exceeds the per-subject quota.
over := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Number("9999")},
CreatedAt: now - 10,
Serial: 9999,
Nonce: bytes.Repeat([]byte{0xee}, tce.NonceSize),
}
ob, _ := protocol.EncodeClaim(over)
env := &transport.Envelope{TCE: ob, Signature: issuer.Sign(ob)}
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/v1/objects", &buf)
srv.HandlePut(rec, req)
if rec.Code != http.StatusUnprocessableEntity {
t.Fatalf("expected 422 for quota exceeded, got %d", rec.Code)
}
}
func TestOneResponsePerRequest(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
approver, _ := signer.Generate()
now := uint64(time.Now().Unix())
req := &protocol.ApprovalRequest{
Sender: issuer.Public(),
Recipient: approver.Public(),
Action: "admin",
CreatedAt: now,
ExpiresAt: now + 30,
Nonce: make([]byte, tce.NonceSize),
}
reqb, _ := protocol.EncodeApprovalRequest(req)
resp1 := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: now + 5,
Nonce: bytes.Repeat([]byte{0x01}, tce.NonceSize),
}
r1, _ := protocol.EncodeApprovalResponse(resp1)
if _, err := sPut(ts, r1, approver.Sign(r1)); err != nil {
t.Fatal(err)
}
resp2 := &protocol.ApprovalResponse{
RequestHash: tce.ComputeID(reqb),
Responder: approver.Public(),
Decision: protocol.Allow,
CreatedAt: now + 6,
Nonce: bytes.Repeat([]byte{0x02}, tce.NonceSize),
}
r2, _ := protocol.EncodeApprovalResponse(resp2)
if _, err := sPut(ts, r2, approver.Sign(r2)); err == nil {
t.Fatal("expected second response to same request to be rejected")
}
}
func TestRateLimit(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
issuer, _ := signer.Generate()
subject, _ := signer.Generate()
now := uint64(time.Now().Unix())
c := &protocol.Claim{
Issuer: issuer.Public(),
Subject: subject.Public(),
Claims: map[string]tce.Value{"x": tce.Bool(true)},
CreatedAt: now - 10,
Serial: 1,
Nonce: make([]byte, tce.NonceSize),
}
cb, _ := protocol.EncodeClaim(c)
env := &transport.Envelope{TCE: cb, Signature: issuer.Sign(cb)}
got429 := false
for i := 0; i < 65; i++ {
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
resp, _ := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
if resp.StatusCode == http.StatusTooManyRequests {
got429 = true
}
resp.Body.Close()
}
if !got429 {
t.Fatal("expected rate limit (429) after bursting PUT")
}
}
func TestRejectsOversizeBody(t *testing.T) {
ts := newTestServer(t)
defer ts.Close()
// An envelope whose base64 TCE exceeds the server body cap.
env := &transport.Envelope{TCE: bytes.Repeat([]byte{0xff}, 10000)}
var buf bytes.Buffer
_ = json.NewEncoder(&buf).Encode(env)
resp, _ := http.Post(ts.URL+"/v1/objects", "application/json", &buf)
if resp.StatusCode != http.StatusRequestEntityTooLarge {
t.Fatalf("expected 413, got %d", resp.StatusCode)
}
resp.Body.Close()
}