package tlog import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "sync" "testing" "time" "basicstack.de/proofedmesh/services/admin-api/internal/attest" ) type fakeTLogServer struct { mu sync.Mutex byHash map[string]attest.LogReceipt nextIdx int64 } func newFakeServer() *fakeTLogServer { return &fakeTLogServer{byHash: make(map[string]attest.LogReceipt)} } func (f *fakeTLogServer) handler(t *testing.T) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path != "/api/v1/log/entries" { t.Errorf("unexpected path %s", r.URL.Path) } var req submitRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, err.Error(), http.StatusBadRequest) return } // Verify content_hash matches the envelope on the wire. recomputed, _ := attest.EnvelopeContentHash(req.Envelope) if recomputed != req.ContentHash { http.Error(w, `{"error":"content_hash mismatch"}`, http.StatusConflict) return } f.mu.Lock() defer f.mu.Unlock() if existing, ok := f.byHash[req.ContentHash]; ok { w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) // idempotent repeat _ = json.NewEncoder(w).Encode(existing) return } f.nextIdx++ receipt := attest.LogReceipt{ LogIndex: f.nextIdx, ContentHash: req.ContentHash, LoggedAt: time.Now().UTC(), InclusionProof: attest.InclusionProof{ TreeSize: f.nextIdx + 1, TreeRootHash: "sha256:" + strings.Repeat("a", 64), SignedTreeHeadRef: "sha256:" + strings.Repeat("b", 64), Hashes: []string{"sha256:" + strings.Repeat("c", 64)}, }, } f.byHash[req.ContentHash] = receipt w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(receipt) }) } func sampleEnvelope() attest.Envelope { return attest.Envelope{ PayloadType: "application/vnd.in-toto+json", Payload: "aGVsbG8", // "hello" (base64url) Signatures: []attest.EnvelopeSignature{{KeyID: "k", Sig: "c2ln"}}, } } func TestSubmit_HappyPathAndIdempotency(t *testing.T) { f := newFakeServer() srv := httptest.NewServer(f.handler(t)) defer srv.Close() s := HTTPSubmitter{BaseURL: srv.URL, TokenSource: attest.StaticToken("t")} env := sampleEnvelope() ch, err := attest.EnvelopeContentHash(env) if err != nil { t.Fatalf("content_hash: %v", err) } r1, err := s.Submit(context.Background(), env, ch) if err != nil { t.Fatalf("first submit: %v", err) } if r1.LogIndex != 1 { t.Errorf("first log_index: %d", r1.LogIndex) } // Re-submit must return the SAME log_index. r2, err := s.Submit(context.Background(), env, ch) if err != nil { t.Fatalf("second submit: %v", err) } if r2.LogIndex != 1 { t.Errorf("idempotent repeat log_index changed: %d", r2.LogIndex) } } func TestSubmit_RejectsContentHashMismatch(t *testing.T) { f := newFakeServer() srv := httptest.NewServer(f.handler(t)) defer srv.Close() s := HTTPSubmitter{BaseURL: srv.URL, TokenSource: attest.StaticToken("t")} env := sampleEnvelope() _, err := s.Submit(context.Background(), env, "sha256:"+strings.Repeat("f", 64)) if err == nil { t.Fatal("expected server to reject content_hash mismatch") } } func TestSubmit_SurfacesServerErrors(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.Error(w, "trillian down", http.StatusServiceUnavailable) })) defer srv.Close() s := HTTPSubmitter{BaseURL: srv.URL, TokenSource: attest.StaticToken("t")} _, err := s.Submit(context.Background(), sampleEnvelope(), "sha256:"+strings.Repeat("a", 64)) if err == nil { t.Fatal("expected error from 503") } if !strings.Contains(err.Error(), "status 503") { t.Errorf("expected status in error, got %v", err) } } func TestSubmit_RejectsEmptyBaseURL(t *testing.T) { s := HTTPSubmitter{} _, err := s.Submit(context.Background(), sampleEnvelope(), "sha256:"+strings.Repeat("a", 64)) if err == nil { t.Fatal("expected error for empty BaseURL") } }