- services/attest-reconciler/ as its own go.mod (admin-api internal/ is not importable; shared code gets extracted when a 3rd consumer appears). - Scheduler accepts "@every <dur>" or a bare Go duration; context-cancellation stops the loop cleanly. - Store client defines PendingRateStore + Postgres skeleton for SELECT ... WHERE rate_change_attestation_ref IS NULL. - Orchestrator + HTTPSigner + tlog.HTTPSubmitter mirror admin-api byte-for-byte so the TLog idempotency key collides on resubmits (kein Doppelsignieren). - Prometheus text-format /metrics endpoint, dependency-free: attest_reconciler_dirty_rows_total, _reconciliation_attempts_total, _reconciliation_failures_total, _idempotent_noop_total. - Tests with fake store + fake signer + fake submitter cover: happy path, already-attested rows are NOT re-signed, TLog idempotency across ticks, TLog-down failure path, pre-cancelled ctx. - Top-level README reflects the new service; CI auto-discovers services/*/go.mod so no workflow change needed. Co-Authored-By: Paperclip <noreply@paperclip.ing>
71 lines
2.2 KiB
Go
71 lines
2.2 KiB
Go
package attest
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestBuildStatement_StableBytes locks the canonical statement shape so a
|
|
// future accidental field reorder cannot silently diverge the reconciler's
|
|
// signatures from admin-api's. The expected bytes were produced by this
|
|
// package itself on first commit; if they change, admin-api's statement must
|
|
// change in lockstep.
|
|
func TestBuildStatement_StableBytes(t *testing.T) {
|
|
city := "Berlin"
|
|
ev := RateChangeEvent{
|
|
Kind: ChangeCreate,
|
|
Rate: Rate{
|
|
RateID: "PDR-1",
|
|
Country: "DE",
|
|
City: &city,
|
|
Currency: "EUR",
|
|
FullDay: "28.00",
|
|
PartialDayMin8h: "14.00",
|
|
ArrivalDay: "14.00",
|
|
DepartureDay: "14.00",
|
|
ValidFrom: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
SourceRegulation: "BRKG § 9 Abs. 1",
|
|
CreatedBy: "admin",
|
|
CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC),
|
|
},
|
|
PerformedBy: PerformedBy{
|
|
Issuer: "https://auth.basicstack.de",
|
|
Subject: "attest-reconciler@proofedmesh",
|
|
},
|
|
PerformedAt: time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC),
|
|
}
|
|
stmt, err := BuildStatement(ev)
|
|
if err != nil {
|
|
t.Fatalf("BuildStatement err: %v", err)
|
|
}
|
|
if stmt.Type != "https://in-toto.io/Statement/v1" {
|
|
t.Errorf("unexpected statement type %q", stmt.Type)
|
|
}
|
|
if stmt.PredicateType != "https://basicstack.de/attestations/rate_change/v1" {
|
|
t.Errorf("unexpected predicateType %q", stmt.PredicateType)
|
|
}
|
|
if len(stmt.Subject) != 1 || stmt.Subject[0].Name != "per_diem_rate/PDR-1" {
|
|
t.Fatalf("unexpected subject: %+v", stmt.Subject)
|
|
}
|
|
digest := stmt.Subject[0].Digest["sha256"]
|
|
if !strings.HasPrefix(digest, "sha256:") || len(digest) != len("sha256:")+64 {
|
|
t.Errorf("subject.digest not sha256:<64hex>: %q", digest)
|
|
}
|
|
|
|
// Re-call must be stable (bit-for-bit reproducible — the whole point).
|
|
stmt2, _ := BuildStatement(ev)
|
|
b1, _ := canonicalJSON(stmt)
|
|
b2, _ := canonicalJSON(stmt2)
|
|
if string(b1) != string(b2) {
|
|
t.Errorf("BuildStatement is not deterministic")
|
|
}
|
|
}
|
|
|
|
func TestOrchestrator_RejectsMissingDeps(t *testing.T) {
|
|
o := Orchestrator{KeyRef: "k"}
|
|
_, _, err := o.SignAndLog(nil, RateChangeEvent{})
|
|
if err == nil {
|
|
t.Fatal("expected error from Orchestrator with nil Signer")
|
|
}
|
|
}
|