cart and checkout
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user