all the refactor
Build and Publish / BuildAndDeployAmd64 (push) Failing after 5s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-28 17:51:52 +02:00
parent 7db0d236c7
commit aa8b2bdedc
84 changed files with 4328 additions and 2344 deletions
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
messages "git.k6n.net/mats/go-cart-actor/proto/cart"
"git.k6n.net/mats/platform/money"
"google.golang.org/protobuf/proto"
)
@@ -230,7 +231,7 @@ func TestCartResponse_Conversion(t *testing.T) {
func mustPrice(incVat int64, totalVat int64) *cart.Price {
return &cart.Price{
IncVat: incVat,
VatRates: map[float32]int64{25: totalVat},
IncVat: money.Cents(incVat),
VatRates: map[int]money.Cents{2500: money.Cents(totalVat)}, // 25% in basis points
}
}
+14 -12
View File
@@ -15,6 +15,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/cart"
profilePkg "git.k6n.net/mats/go-cart-actor/pkg/profile"
checkoutMessages "git.k6n.net/mats/go-cart-actor/proto/checkout"
"git.k6n.net/mats/platform/money"
"google.golang.org/protobuf/types/known/anypb"
"google.golang.org/protobuf/types/known/timestamppb"
)
@@ -447,15 +448,15 @@ type orderLine struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name"`
Quantity int32 `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
Quantity int32 `json:"quantity"`
UnitPrice money.Cents `json:"unitPrice"`
TaxRate int32 `json:"taxRate"`
}
type orderPayment struct {
Provider string `json:"provider"`
Reference string `json:"reference"`
Amount int64 `json:"amount"`
Provider string `json:"provider"`
Reference string `json:"reference"`
Amount money.Cents `json:"amount"`
}
// CreateOrder implements OrderApplier. It builds a from-checkout request from
@@ -476,10 +477,10 @@ func (c *OrderHTTPClient) CreateOrder(ctx context.Context, checkoutID uint64, g
}
// Compute total from cart items + deliveries.
var totalAmount int64
var totalAmount money.Cents
lines := buildOrderLines(g)
for _, l := range lines {
totalAmount += l.UnitPrice * int64(l.Quantity)
totalAmount += l.UnitPrice.Mul(int64(l.Quantity))
}
req := fromCheckoutReq{
@@ -554,8 +555,9 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat,
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
TaxRate: int32(it.Tax / 100),
// CartItem.Tax and OrderLine.TaxRate share the platform basis-point scale
// (2500 = 25%), so the rate passes through unchanged.
TaxRate: int32(it.Tax),
})
}
for _, d := range g.Deliveries {
@@ -568,7 +570,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: "Delivery",
Quantity: 1,
UnitPrice: d.Price.IncVat,
TaxRate: 25,
TaxRate: 2500, // 25% in basis points
})
}
return lines
@@ -672,7 +674,7 @@ func checkoutGrainToResponse(id string, g *checkout.CheckoutGrain) CheckoutRespo
resp.Payments = append(resp.Payments, PaymentResponse{
Id: p.PaymentId,
Provider: p.Provider,
Amount: p.Amount,
Amount: money.Cents(p.Amount),
Currency: p.Currency,
Status: string(p.Status),
})
+1 -1
View File
@@ -175,7 +175,7 @@ func (s *OrderServer) handleIssueRefund(w http.ResponseWriter, r *http.Request)
amount := req.Amount
if amount <= 0 {
amount = g.CapturedAmount - g.RefundedAmount
amount = (g.CapturedAmount - g.RefundedAmount).Int64()
}
if amount <= 0 {
writeError(w, http.StatusBadRequest, "no captured amount to refund")
+3 -2
View File
@@ -12,6 +12,7 @@ import (
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/order"
messages "git.k6n.net/mats/go-cart-actor/proto/order"
"git.k6n.net/mats/platform/money"
"google.golang.org/protobuf/proto"
)
@@ -86,10 +87,10 @@ func (a *testOrderApplier) Apply(_ context.Context, id uint64, msgs ...proto.Mes
case *messages.IssueRefund:
g.Refunds = append(g.Refunds, order.Refund{
Provider: m.Provider,
Amount: m.Amount,
Amount: money.Cents(m.Amount),
Reference: m.Reference,
})
g.RefundedAmount += m.Amount
g.RefundedAmount += money.Cents(m.Amount)
if g.RefundedAmount >= g.CapturedAmount {
g.Status = order.StatusRefunded
}
+61 -24
View File
@@ -1,6 +1,7 @@
package ucp
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
@@ -31,8 +32,8 @@ type SigningConfig struct {
// NewSigningConfigFromPEM parses a PEM-encoded ECDSA P-256 private key and
// returns a SigningConfig ready for use.
//
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
// keyID — the kid matched by the UCP profile signing_keys entry
// data — PEM block containing an EC PRIVATE KEY (PKCS#8 or SEC1)
// keyID — the kid matched by the UCP profile signing_keys entry
func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, error) {
block, _ := pem.Decode(pemData)
if block == nil {
@@ -72,8 +73,8 @@ func NewSigningConfigFromPEM(pemData []byte, keyID string) (*SigningConfig, erro
// WithSigning wraps h with an HTTP middleware that adds RFC 9421 HTTP Message
// Signatures to every response. The Signature-Input and Signature headers are
// computed over @status, content-type, and x-ucp-timestamp, signed with the
// configured ECDSA P-256 key.
// computed over @status, content-type, x-ucp-timestamp, and content-digest,
// signed with the configured ECDSA P-256 key.
//
// Mount it on any existing UCP handler:
//
@@ -83,11 +84,9 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
return h // pass through without signing
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sw := &signedWriter{
ResponseWriter: w,
cfg: cfg,
}
sw := newSignedWriter(w, cfg)
h.ServeHTTP(sw, r)
sw.flush()
})
}
@@ -96,10 +95,24 @@ func WithSigning(h http.Handler, cfg *SigningConfig) http.Handler {
// ---------------------------------------------------------------------------
type signedWriter struct {
http.ResponseWriter
cfg *SigningConfig
statusCode int
wroteHeader bool
responseWriter http.ResponseWriter
header http.Header
body bytes.Buffer
cfg *SigningConfig
statusCode int
wroteHeader bool
}
func newSignedWriter(w http.ResponseWriter, cfg *SigningConfig) *signedWriter {
return &signedWriter{
responseWriter: w,
header: make(http.Header),
cfg: cfg,
}
}
func (w *signedWriter) Header() http.Header {
return w.header
}
func (w *signedWriter) WriteHeader(statusCode int) {
@@ -108,28 +121,44 @@ func (w *signedWriter) WriteHeader(statusCode int) {
}
w.wroteHeader = true
w.statusCode = statusCode
created := time.Now().Unix()
w.ResponseWriter.Header().Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
// Add signature headers before flushing.
w.addSignatureHeaders(created)
w.ResponseWriter.WriteHeader(statusCode)
}
func (w *signedWriter) Write(p []byte) (int, error) {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
return w.ResponseWriter.Write(p)
return w.body.Write(p)
}
func (w *signedWriter) flush() {
if !w.wroteHeader {
w.WriteHeader(http.StatusOK)
}
if w.header.Get("Content-Type") == "" && w.body.Len() > 0 {
w.header.Set("Content-Type", http.DetectContentType(w.body.Bytes()))
}
created := time.Now().Unix()
w.header.Set("x-ucp-timestamp", strconv.FormatInt(created, 10))
w.header.Set("Content-Digest", buildContentDigest(w.body.Bytes()))
// Add signature headers before flushing.
w.addSignatureHeaders(created)
dst := w.responseWriter.Header()
for k, vals := range w.header {
dst[k] = append([]string(nil), vals...)
}
w.responseWriter.WriteHeader(w.statusCode)
_, _ = w.responseWriter.Write(w.body.Bytes())
}
// addSignatureHeaders computes and writes the Signature-Input and Signature
// headers per RFC 9421 §3.2 / §3.3.
func (w *signedWriter) addSignatureHeaders(created int64) {
contentType := w.ResponseWriter.Header().Get("Content-Type")
contentType := w.header.Get("Content-Type")
sigTimestamp := strconv.FormatInt(created, 10)
contentDigest := w.header.Get("Content-Digest")
// Covered components (in order). RFC 9421 §3.2: derived component names
// (@status) are bare identifiers; HTTP header field names are sf-strings
@@ -138,6 +167,7 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
"@status",
`"content-type"`,
`"x-ucp-timestamp"`,
`"content-digest"`,
}
// Build the signature base string (RFC 9421 §2.2).
@@ -148,6 +178,8 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
base.WriteByte('\n')
base.WriteString(fmt.Sprintf(`"x-ucp-timestamp": %s`, sigTimestamp))
base.WriteByte('\n')
base.WriteString(fmt.Sprintf(`"content-digest": %s`, contentDigest))
base.WriteByte('\n')
// Append the signature-params pseudo-line (RFC 9421 §2.2).
compList := strings.Join(components, " ")
@@ -178,6 +210,11 @@ func (w *signedWriter) addSignatureHeaders(created int64) {
// Signature: sig1=:base64url:
sigValue := fmt.Sprintf("sig1=:%s:", sigB64)
w.ResponseWriter.Header().Set("Signature-Input", sigInput)
w.ResponseWriter.Header().Set("Signature", sigValue)
w.header.Set("Signature-Input", sigInput)
w.header.Set("Signature", sigValue)
}
func buildContentDigest(body []byte) string {
sum := sha256.Sum256(body)
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}
+8 -1
View File
@@ -63,6 +63,7 @@ func TestWithSigning_AddsHeaders(t *testing.T) {
sigInput := rec.Header().Get("Signature-Input")
sig := rec.Header().Get("Signature")
ts := rec.Header().Get("x-ucp-timestamp")
digest := rec.Header().Get("Content-Digest")
if sigInput == "" {
t.Fatal("expected Signature-Input header")
@@ -73,9 +74,12 @@ func TestWithSigning_AddsHeaders(t *testing.T) {
if ts == "" {
t.Fatal("expected x-ucp-timestamp header")
}
if digest == "" {
t.Fatal("expected Content-Digest header")
}
// Verify the signature input format.
if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp");`) {
if !strings.HasPrefix(sigInput, `sig1=(@status "content-type" "x-ucp-timestamp" "content-digest");`) {
t.Fatalf("unexpected Signature-Input format: %q", sigInput)
}
if !strings.Contains(sigInput, `keyid="k6n-ecdsa-2026"`) {
@@ -155,6 +159,9 @@ func TestWithSigning_WithUCPCartHandler(t *testing.T) {
if rec.Header().Get("x-ucp-timestamp") == "" {
t.Fatal("expected x-ucp-timestamp on UCP cart response")
}
if rec.Header().Get("Content-Digest") == "" {
t.Fatal("expected Content-Digest on UCP cart response")
}
}
// mustLoadTestKey loads the test PEM for signing tests.
+90 -78
View File
@@ -19,11 +19,12 @@ import (
"encoding/json"
"net/http"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/go-cart-actor/pkg/cart"
"git.k6n.net/mats/go-cart-actor/pkg/checkout"
"git.k6n.net/mats/go-cart-actor/pkg/order"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
"git.k6n.net/mats/go-cart-actor/pkg/actor"
"git.k6n.net/mats/platform/money"
"google.golang.org/protobuf/proto"
)
@@ -105,8 +106,8 @@ type CartItemInput struct {
Sku string `json:"sku"`
Quantity int `json:"quantity,omitempty"`
Name string `json:"name,omitempty"`
Price int64 `json:"price,omitempty"` // inc-vat in minor units (öre)
TaxRate float64 `json:"taxRate,omitempty"` // e.g. 25 or 12.5
Price money.Cents `json:"price,omitempty"` // inc-vat in minor units (öre / money.Cents on the wire)
TaxRate int `json:"taxRate,omitempty"` // basis points: 2500 = 25%, 1250 = 12.5% (matches OrderLine.TaxRate / CartItem.Tax)
Image string `json:"image,omitempty"`
StoreId *string `json:"storeId,omitempty"`
Children []CartItemInput `json:"children,omitempty"`
@@ -115,16 +116,16 @@ type CartItemInput struct {
// VoucherInput is the UCP wire format for a voucher code to apply.
type VoucherInput struct {
Code string `json:"code"`
Value int64 `json:"value,omitempty"`
Code string `json:"code"`
Value money.Cents `json:"value,omitempty"`
}
// CartResponse is the UCP cart response body.
type CartResponse struct {
Id string `json:"id"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Status string `json:"status"`
Id string `json:"id"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Status string `json:"status"`
}
// CartItem is the UCP wire format for a cart line item in responses.
@@ -132,8 +133,8 @@ type CartItem struct {
Sku string `json:"sku"`
Quantity int `json:"quantity"`
Name string `json:"name,omitempty"`
UnitPrice int64 `json:"unitPrice"` // inc-vat minor units
TotalPrice int64 `json:"totalPrice"` // inc-vat minor units
UnitPrice money.Cents `json:"unitPrice"` // inc-vat minor units
TotalPrice money.Cents `json:"totalPrice"` // inc-vat minor units
TaxRate int `json:"taxRate"`
Image string `json:"image,omitempty"`
ItemId uint32 `json:"itemId,omitempty"`
@@ -142,11 +143,11 @@ type CartItem struct {
// Totals is the UCP wire format for price breakdown.
type Totals struct {
TotalIncVat int64 `json:"totalIncVat"`
TotalExVat int64 `json:"totalExVat"`
TotalVat int64 `json:"totalVat"`
Currency string `json:"currency"`
Discount int64 `json:"discount,omitempty"`
TotalIncVat money.Cents `json:"totalIncVat"`
TotalExVat money.Cents `json:"totalExVat"`
TotalVat money.Cents `json:"totalVat"`
Currency string `json:"currency"`
Discount money.Cents `json:"discount,omitempty"`
}
// ---------------------------------------------------------------------------
@@ -205,24 +206,24 @@ type CompleteCheckoutRequest struct {
// CheckoutResponse is the UCP checkout session response.
type CheckoutResponse struct {
Id string `json:"id"`
CartId string `json:"cartId"`
Status string `json:"status"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Deliveries []DeliveryResponse `json:"deliveries,omitempty"`
Contact *ContactResponse `json:"contact,omitempty"`
OrderId *string `json:"orderId,omitempty"`
Payments []PaymentResponse `json:"payments,omitempty"`
Id string `json:"id"`
CartId string `json:"cartId"`
Status string `json:"status"`
Items []CartItem `json:"items,omitempty"`
Totals Totals `json:"totals"`
Deliveries []DeliveryResponse `json:"deliveries,omitempty"`
Contact *ContactResponse `json:"contact,omitempty"`
OrderId *string `json:"orderId,omitempty"`
Payments []PaymentResponse `json:"payments,omitempty"`
}
// DeliveryResponse is the UCP wire format for a delivery option.
type DeliveryResponse struct {
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price int64 `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
Id uint32 `json:"id"`
Provider string `json:"provider"`
Price money.Cents `json:"price"`
Items []uint32 `json:"items"`
PickupPoint *PickupPointResp `json:"pickupPoint,omitempty"`
}
// PickupPointResp is the UCP wire format for a pickup point.
@@ -244,11 +245,11 @@ type ContactResponse struct {
// PaymentResponse is the UCP wire format for a payment record.
type PaymentResponse struct {
Id string `json:"id"`
Provider string `json:"provider"`
Amount int64 `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
Id string `json:"id"`
Provider string `json:"provider"`
Amount money.Cents `json:"amount"`
Currency string `json:"currency"`
Status string `json:"status"`
}
// ---------------------------------------------------------------------------
@@ -257,24 +258,24 @@ type PaymentResponse struct {
// OrderResponse is the UCP order response body.
type OrderResponse struct {
OrderId string `json:"orderId"`
Reference string `json:"reference,omitempty"`
CartId string `json:"cartId,omitempty"`
Status string `json:"status"`
Currency string `json:"currency,omitempty"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
Lines []OrderLineResp `json:"lines,omitempty"`
Payments []OrderPaymentResp `json:"payments,omitempty"`
Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"`
Returns []OrderReturnResp `json:"returns,omitempty"`
Refunds []OrderRefundResp `json:"refunds,omitempty"`
CapturedAmount int64 `json:"capturedAmount"`
RefundedAmount int64 `json:"refundedAmount"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
PlacedAt string `json:"placedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
OrderId string `json:"orderId"`
Reference string `json:"reference,omitempty"`
CartId string `json:"cartId,omitempty"`
Status string `json:"status"`
Currency string `json:"currency,omitempty"`
TotalAmount money.Cents `json:"totalAmount"`
TotalTax money.Cents `json:"totalTax"`
Lines []OrderLineResp `json:"lines,omitempty"`
Payments []OrderPaymentResp `json:"payments,omitempty"`
Fulfillments []OrderFulfillmentResp `json:"fulfillments,omitempty"`
Returns []OrderReturnResp `json:"returns,omitempty"`
Refunds []OrderRefundResp `json:"refunds,omitempty"`
CapturedAmount money.Cents `json:"capturedAmount"`
RefundedAmount money.Cents `json:"refundedAmount"`
CustomerEmail string `json:"customerEmail,omitempty"`
CustomerName string `json:"customerName,omitempty"`
PlacedAt string `json:"placedAt,omitempty"`
UpdatedAt string `json:"updatedAt,omitempty"`
}
// OrderLineResp represents a single order line item in responses.
@@ -282,21 +283,21 @@ type OrderLineResp struct {
Reference string `json:"reference"`
Sku string `json:"sku"`
Name string `json:"name,omitempty"`
Quantity int `json:"quantity"`
UnitPrice int64 `json:"unitPrice"`
TaxRate int `json:"taxRate"`
TotalAmount int64 `json:"totalAmount"`
TotalTax int64 `json:"totalTax"`
Fulfilled int `json:"fulfilled,omitempty"`
Quantity int `json:"quantity"`
UnitPrice money.Cents `json:"unitPrice"`
TaxRate int `json:"taxRate"`
TotalAmount money.Cents `json:"totalAmount"`
TotalTax money.Cents `json:"totalTax"`
Fulfilled int `json:"fulfilled,omitempty"`
}
// OrderPaymentResp represents a payment record on an order.
type OrderPaymentResp struct {
Provider string `json:"provider"`
Authorized int64 `json:"authorized"`
Captured int64 `json:"captured"`
Refunded int64 `json:"refunded"`
AuthRef string `json:"authRef,omitempty"`
Provider string `json:"provider"`
Authorized money.Cents `json:"authorized"`
Captured money.Cents `json:"captured"`
Refunded money.Cents `json:"refunded"`
AuthRef string `json:"authRef,omitempty"`
CaptureRef string `json:"captureRef,omitempty"`
}
@@ -318,9 +319,9 @@ type OrderReturnResp struct {
// OrderRefundResp represents a refund record.
type OrderRefundResp struct {
Provider string `json:"provider"`
Amount int64 `json:"amount"`
Reference string `json:"reference,omitempty"`
Provider string `json:"provider"`
Amount money.Cents `json:"amount"`
Reference string `json:"reference,omitempty"`
}
// OrderLineEntry is a lightweight reference+quantity pair used in fulfillment
@@ -408,17 +409,18 @@ type UpdateAddressRequest struct {
// ProfileData is the UCP business profile served at /.well-known/ucp.
type ProfileData struct {
UCP Profile `json:"ucp"`
UCP Profile `json:"ucp"`
SigningKeys []SigningKey `json:"signing_keys,omitempty"`
}
// Profile is the core UCP profile object.
type Profile struct {
Version string `json:"version"`
Business BusinessInfo `json:"business,omitempty"`
Services map[string][]ServiceBinding `json:"services"`
Capabilities map[string][]CapabilityDecl `json:"capabilities"`
PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"`
SupportedVersions []string `json:"supported_versions,omitempty"`
Version string `json:"version"`
Business BusinessInfo `json:"business,omitempty"`
Services map[string][]ServiceBinding `json:"services"`
Capabilities map[string][]CapabilityDecl `json:"capabilities"`
PaymentHandlers map[string][]PaymentHandlerDecl `json:"payment_handlers,omitempty"`
SupportedVersions []string `json:"supported_versions,omitempty"`
}
// BusinessInfo describes the business behind a UCP profile.
@@ -455,13 +457,23 @@ type OpDef struct {
// PaymentHandlerDecl describes a payment handler specification.
type PaymentHandlerDecl struct {
ID string `json:"id"`
Version string `json:"version"`
Spec string `json:"spec"`
Schema string `json:"schema"`
ID string `json:"id"`
Version string `json:"version"`
Spec string `json:"spec"`
Schema string `json:"schema"`
Description string `json:"description,omitempty"`
}
// SigningKey describes a published verification key for signed UCP responses.
type SigningKey struct {
Kid string `json:"kid"`
Kty string `json:"kty"`
Crv string `json:"crv,omitempty"`
X string `json:"x,omitempty"`
Y string `json:"y,omitempty"`
Alg string `json:"alg,omitempty"`
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------