cart and checkout
Build and Publish / BuildAndDeployAmd64 (push) Failing after 4s
Build and Publish / BuildAndDeployArm64 (push) Failing after 6s

This commit is contained in:
2026-06-27 19:49:00 +02:00
parent 492f54ff45
commit 528c59bfd3
67 changed files with 3618 additions and 1031 deletions
+3 -2
View File
@@ -554,7 +554,8 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: name,
Quantity: int32(it.Quantity),
UnitPrice: it.Price.IncVat,
TaxRate: int32(it.Tax),
// CartItem.Tax is percentage x 100 (e.g. 2500 = 25.00%). Convert to raw percent (25).
TaxRate: int32(it.Tax / 100),
})
}
for _, d := range g.Deliveries {
@@ -567,7 +568,7 @@ func buildOrderLines(g *checkout.CheckoutGrain) []orderLine {
Name: "Delivery",
Quantity: 1,
UnitPrice: d.Price.IncVat,
TaxRate: 2500,
TaxRate: 25,
})
}
return lines
+63 -3
View File
@@ -7,6 +7,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"time"
"git.k6n.net/mats/go-cart-actor/pkg/profile"
@@ -15,12 +16,18 @@ import (
// CustomerServer is the UCP REST adapter over the profile grain pool.
type CustomerServer struct {
applier ProfileApplier
applier ProfileApplier
deleter CredentialDeleter
auditLogPath string
}
// NewCustomerServer builds a UCP REST adapter over a profile grain pool.
func NewCustomerServer(applier ProfileApplier) *CustomerServer {
return &CustomerServer{applier: applier}
func NewCustomerServer(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) *CustomerServer {
var del CredentialDeleter
if len(deleter) > 0 {
del = deleter[0]
}
return &CustomerServer{applier: applier, deleter: del, auditLogPath: auditLogPath}
}
// ---------------------------------------------------------------------------
@@ -276,6 +283,59 @@ func (s *CustomerServer) handleRemoveAddress(w http.ResponseWriter, r *http.Requ
writeJSON(w, http.StatusOK, customerGrainToResponse(r.PathValue("id"), g))
}
// handleDeleteCustomer handles DELETE /customers/{id}.
func (s *CustomerServer) handleDeleteCustomer(w http.ResponseWriter, r *http.Request) {
id, ok := parseCustomerID(r)
if !ok {
writeError(w, http.StatusBadRequest, "invalid customer id")
return
}
// 1. Fetch current profile grain to get the email address.
g, err := s.applier.Get(r.Context(), id)
if err != nil {
writeError(w, http.StatusNotFound, "customer not found")
return
}
email := g.Email
// 2. Apply the AnonymizeProfile mutation to clear all PII.
msg := &messages.AnonymizeProfile{}
if _, err := s.applier.Apply(r.Context(), id, msg); err != nil {
writeError(w, http.StatusInternalServerError, "failed to anonymize customer: "+err.Error())
return
}
// 3. Delete the credentials from the auth store if we have a deleter.
if email != "" && s.deleter != nil {
if _, err := s.deleter.Delete(r.Context(), email); err != nil {
fmt.Printf("ucp: failed to delete credentials for email %s: %v\n", email, err)
}
}
// 4. Log audit trail to file (GDPR requirement, append-only, readable, no PII)
if s.auditLogPath != "" {
logLine := fmt.Sprintf("[%s] ACTION=GDPR_ERASURE PROFILE_ID=%s STATUS=SUCCESS\n",
time.Now().UTC().Format(time.RFC3339), r.PathValue("id"))
// Import "os" package is handled or we can open it directly.
// Since we need to write to the file, let's open it in append-only mode.
// To ensure directory exists, we write to the file.
if f, err := os.OpenFile(s.auditLogPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644); err == nil {
_, _ = f.WriteString(logLine)
_ = f.Close()
} else {
fmt.Printf("ucp: failed to open audit log file %s: %v\n", s.auditLogPath, err)
}
}
// Log audit trail to stdout (GDPR requirement)
fmt.Printf("AUDIT: GDPR right to erasure executed for customer profile id %d\n", id)
w.WriteHeader(http.StatusNoContent)
}
// ---------------------------------------------------------------------------
// Identity linking helpers
// ---------------------------------------------------------------------------
+87 -10
View File
@@ -6,6 +6,8 @@ import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -105,6 +107,12 @@ func (a *testProfileApplier) Apply(ctx context.Context, id uint64, msgs ...proto
g.Checkouts = append(g.Checkouts, profile.LinkedCheckout{CheckoutId: m.CheckoutId, CartId: m.CartId})
case *messages.LinkOrder:
g.Orders = append(g.Orders, profile.LinkedOrder{OrderReference: m.OrderReference, CartId: m.CartId, Status: m.Status})
case *messages.AnonymizeProfile:
g.Name = ""
g.Email = ""
g.Phone = ""
g.AvatarUrl = ""
g.Addresses = nil
}
}
return &actor.MutationResult[profile.ProfileGrain]{
@@ -138,7 +146,7 @@ func testProfileID(t *testing.T, applier *testProfileApplier) string {
func TestGetCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
// Set up some profile data
pid, _ := profile.ParseProfileId(id)
@@ -173,7 +181,7 @@ func TestGetCustomer(t *testing.T) {
func TestGetCustomerNotFound(t *testing.T) {
applier := newTestProfileApplier()
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
// "nonexistent" is actually valid base62, so use an ID with characters outside the alphabet.
req := httptest.NewRequest("GET", "/!invalid!", nil)
@@ -188,7 +196,7 @@ func TestGetCustomerNotFound(t *testing.T) {
func TestUpdateCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
body := `{"name": "Bob", "email": "bob@example.com", "phone": "+46701234567", "language": "sv", "currency": "SEK"}`
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s", id), strings.NewReader(body))
@@ -224,7 +232,7 @@ func TestUpdateCustomer(t *testing.T) {
func TestAddAddress(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
body := `{
"label": "Home",
@@ -266,7 +274,7 @@ func TestAddAddress(t *testing.T) {
func TestAddAddressMissingRequired(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
// Missing addressLine1
body := `{"city": "Stockholm", "zip": "111 22", "country": "SE"}`
@@ -283,7 +291,7 @@ func TestAddAddressMissingRequired(t *testing.T) {
func TestUpdateAddress(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
// First add an address
pid, _ := profile.ParseProfileId(id)
@@ -337,7 +345,7 @@ func TestUpdateAddress(t *testing.T) {
func TestUpdateAddressInvalidID(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
body := `{"label": "New Home"}`
req := httptest.NewRequest("PUT", fmt.Sprintf("/%s/addresses/abc", id), strings.NewReader(body))
@@ -353,7 +361,7 @@ func TestUpdateAddressInvalidID(t *testing.T) {
func TestRemoveAddress(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
// First add an address
pid, _ := profile.ParseProfileId(id)
@@ -398,7 +406,7 @@ func TestRemoveAddressNotFound(t *testing.T) {
applier.Get(context.Background(), uint64(pid))
id := pid.String()
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s/addresses/999", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
@@ -444,7 +452,7 @@ func TestRemoveAddressNotFoundMutation(t *testing.T) {
func TestCustomerHandlerInvalidID(t *testing.T) {
applier := newTestProfileApplier()
handler := CustomerHandler(applier)
handler := CustomerHandler(applier, "")
// Path "/" now has POST / registered (create customer), so GET / is 405.
// Paths with invalid base62 characters should get 400.
@@ -496,3 +504,72 @@ func TestCustomerHandlerThroughCombinedMount(t *testing.T) {
t.Fatalf("expected id %q, got %q", id, resp.Id)
}
}
type testCredentialDeleter struct {
deletedEmail string
}
func (d *testCredentialDeleter) Delete(_ context.Context, email string) (bool, error) {
d.deletedEmail = email
return true, nil
}
func TestDeleteCustomer(t *testing.T) {
applier := newTestProfileApplier()
id := testProfileID(t, applier)
deleter := &testCredentialDeleter{}
auditPath := filepath.Join(t.TempDir(), "audit.log")
handler := CustomerHandler(applier, auditPath, deleter)
// Set up some profile data
pid, _ := profile.ParseProfileId(id)
applier.Apply(context.Background(), uint64(pid), &messages.SetProfile{
Name: proto.String("Alice"),
Email: proto.String("alice@example.com"),
})
applier.Apply(context.Background(), uint64(pid), &messages.AddAddress{
Address: &messages.Address{
Label: "Home",
AddressLine1: "Storgatan 1",
City: "Stockholm",
Zip: "111 22",
Country: "SE",
},
})
// DELETE /customers/{id}
req := httptest.NewRequest("DELETE", fmt.Sprintf("/%s", id), nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Fatalf("expected 204 No Content, got %d. Body: %s", rec.Code, rec.Body.String())
}
// Verify credentials deleted
if deleter.deletedEmail != "alice@example.com" {
t.Fatalf("expected deleted email 'alice@example.com', got %q", deleter.deletedEmail)
}
// Verify profile is anonymized
g, _ := applier.Get(context.Background(), uint64(pid))
if g.Name != "" || g.Email != "" || g.Addresses != nil {
t.Fatalf("expected profile to be anonymized, got Name=%q, Email=%q, Addresses=%v", g.Name, g.Email, g.Addresses)
}
// Verify audit log has the correct entry and no PII
logBytes, err := os.ReadFile(auditPath)
if err != nil {
t.Fatalf("failed to read audit log: %v", err)
}
logContent := string(logBytes)
if !strings.Contains(logContent, "ACTION=GDPR_ERASURE") {
t.Fatal("expected audit log to record erasure action")
}
if !strings.Contains(logContent, "PROFILE_ID="+id) {
t.Fatal("expected audit log to record profile ID")
}
if strings.Contains(logContent, "Alice") || strings.Contains(logContent, "alice@example.com") {
t.Fatal("expected audit log to exclude PII")
}
}
+10 -6
View File
@@ -86,12 +86,13 @@ func CheckoutHandler(applier CheckoutApplier, orderSvc ...OrderApplier) http.Han
// To sign responses with RFC 9421 HTTP Message Signatures, wrap with WithSigning:
//
// mux.Handle("/ucp/v1/customers", ucp.WithSigning(ucp.CustomerHandler(pool), signer))
func CustomerHandler(applier ProfileApplier) http.Handler {
s := NewCustomerServer(applier)
func CustomerHandler(applier ProfileApplier, auditLogPath string, deleter ...CredentialDeleter) http.Handler {
s := NewCustomerServer(applier, auditLogPath, deleter...)
mux := http.NewServeMux()
mux.HandleFunc("POST /", s.handleCreateCustomer)
mux.HandleFunc("GET /{id}", s.handleGetCustomer)
mux.HandleFunc("PUT /{id}", s.handleUpdateCustomer)
mux.HandleFunc("DELETE /{id}", s.handleDeleteCustomer)
mux.HandleFunc("POST /{id}/addresses", s.handleAddAddress)
mux.HandleFunc("PUT /{id}/addresses/{addressId}", s.handleUpdateAddress)
mux.HandleFunc("DELETE /{id}/addresses/{addressId}", s.handleRemoveAddress)
@@ -104,9 +105,11 @@ func CustomerHandler(applier ProfileApplier) http.Handler {
// Options controls optional features of the combined handler.
type Options struct {
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
OrderApplier OrderApplier // optional order creation for complete endpoint
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
CheckoutApplier CheckoutApplier // when set, mounts /checkout-sessions
OrderApplier OrderApplier // optional order creation for complete endpoint
ProfileApplier ProfileApplier // when set, mounts /customers + enables identity linking
CredentialDeleter CredentialDeleter // optional, for deleting login credentials during GDPR erasure
ProfileAuditLog string // file path for GDPR right-to-erasure audit log
}
// Handler returns an http.Handler that serves all mounted UCP endpoints under
@@ -148,10 +151,11 @@ func Handler(cartApplier CartApplier, opts Options) http.Handler {
// Customer endpoints (optional)
if opts.ProfileApplier != nil {
customerSrv := NewCustomerServer(opts.ProfileApplier)
customerSrv := NewCustomerServer(opts.ProfileApplier, opts.ProfileAuditLog, opts.CredentialDeleter)
mux.HandleFunc("POST /customers", customerSrv.handleCreateCustomer)
mux.HandleFunc("GET /customers/{id}", customerSrv.handleGetCustomer)
mux.HandleFunc("PUT /customers/{id}", customerSrv.handleUpdateCustomer)
mux.HandleFunc("DELETE /customers/{id}", customerSrv.handleDeleteCustomer)
mux.HandleFunc("POST /customers/{id}/addresses", customerSrv.handleAddAddress)
mux.HandleFunc("PUT /customers/{id}/addresses/{addressId}", customerSrv.handleUpdateAddress)
mux.HandleFunc("DELETE /customers/{id}/addresses/{addressId}", customerSrv.handleRemoveAddress)
+5
View File
@@ -56,6 +56,11 @@ type ProfileApplier interface {
Apply(ctx context.Context, id uint64, mutation ...proto.Message) (*actor.MutationResult[profile.ProfileGrain], error)
}
// CredentialDeleter represents the subset of the credential store needed to delete credentials.
type CredentialDeleter interface {
Delete(ctx context.Context, email string) (bool, error)
}
// OrderApplier is the interface the UCP checkout adapter uses to create a real
// order from a completed checkout session. It abstracts over the transport
// (direct grain pool, HTTP, etc.) so the adapter is portable.