backend requirements onthe xustomer app

This commit is contained in:
2026-09-07 10:55:11 +05:30
parent 35675d8a9b
commit 1b2690b21a
56 changed files with 13342 additions and 1071 deletions

View File

@@ -8,6 +8,7 @@ import (
"doormile/constants"
"doormile/db"
"doormile/internal/cxstage"
"doormile/internal/routing"
"doormile/models"
"doormile/utils"
@@ -227,8 +228,24 @@ func commitAssignment(booking *models.PickupBooking, candidate *milerCandidate,
return fmt.Errorf("update MilerProfile availability: %w", err)
}
// The customer's "Miler assigned" milestone, in the same transaction as the
// assignment it describes. The auto-assignment path is how most B2C
// bookings get a rider, so without this the customer app's timeline would
// only ever advance for manually assigned pickups.
if err := cxstage.Record(tx, cxstage.Event{
BookingID: booking.Bookingid,
Stage: constants.CxStageAssigned,
ActorType: constants.CxActorSystem,
Source: "internal/assignment.commitAssignment",
}); err != nil {
tx.Rollback()
return fmt.Errorf("record assigned stage: %w", err)
}
tx.Commit()
go cxstage.Notify(booking.Bookingid, nil, constants.CxStageAssigned)
utils.Info("CRMAssignment: assigned",
"booking_id", booking.Bookingid,
"miler_id", milerUserID,

425
internal/cxstage/stage.go Normal file
View File

@@ -0,0 +1,425 @@
// Package cxstage derives the customer app's view of a pickup from the writes
// the miler app already makes.
//
// This is the part of the customer API that is not a new endpoint. Every
// operational write it reacts to — accept, reached, parcel, pickup-complete,
// inward-at-hub, start-delivery, deliver, cancel — already existed and was
// already correct. What did not exist was any record of when a parcel reached a
// stage, in the vocabulary the customer is shown. A consignment status says
// where a parcel is now; it cannot say when it got there, and a timeline
// assembled from "now" plus guesses is the thing this package exists to avoid.
//
// So: one append-only row per stage actually reached, with the real time and
// the real actor, and the booking's current stage kept alongside it so a
// tracking poll is one row rather than a replay. Nothing here is backfilled.
// A booking that predates this package has a short history, and a short honest
// history beats a long invented one — the customer cannot tell which entries
// were guessed.
package cxstage
import (
"fmt"
"strconv"
"time"
"doormile/constants"
"doormile/db"
"doormile/internal/notify"
"doormile/models"
"doormile/utils"
"gorm.io/gorm"
)
// Event is one stage transition to record.
type Event struct {
BookingID int
// DestinationID is set for the per-order stages (in_transit onward), which
// may differ between destinations of the same booking, and nil for the
// booking-level stages that apply to the whole pickup.
DestinationID *int
Stage string
ActorType string // constants.CxActor*
ActorID *int
// Source names the write that caused this — the endpoint path or the job.
// It is what makes an audit trail answerable a year later.
Source string
Remarks string
// At is when it actually happened. Zero means now.
At time.Time
}
// Record appends the event and advances the stored stage, inside the caller's
// transaction.
//
// Pass the same *gorm.DB the operational write is using. A stage event that
// commits while the write it describes rolls back is a customer being told
// their parcel was collected when it was not — so the two share a transaction
// or neither happens.
//
// Recording is idempotent per (booking, destination, stage): a rider tapping
// "arrived" twice on bad signal produces one timeline entry, not two.
func Record(tx *gorm.DB, in Event) error {
if tx == nil || in.BookingID == 0 || in.Stage == "" {
return fmt.Errorf("cxstage: incomplete event")
}
if constants.CxStageRank(in.Stage) < 0 {
return fmt.Errorf("cxstage: unknown stage %q", in.Stage)
}
at := in.At
if at.IsZero() {
at = utils.DBNow()
}
if in.ActorType == "" {
in.ActorType = constants.CxActorSystem
}
// Only a booking the customer app is watching gets a projection.
//
// This guard is about blast radius, not tidiness. Record runs inside the
// caller's transaction and returns its error, so a failure here fails the
// operational write — and the operational writes it hooks into
// (assignMilerTx, commitAssignment, pickup-complete) also serve
// console-created express bookings, which no customer app has ever seen.
// Without this, a problem in the customer projection could refuse a hub
// staff member's manual assignment of a booking that has no customer
// attached to it at all.
var booking models.PickupBooking
if err := tx.Select("bookingid, bookingsource, customerstage, customerstatus, status").
First(&booking, in.BookingID).Error; err != nil {
return fmt.Errorf("cxstage: load booking: %w", err)
}
if booking.Bookingsource != constants.BookingSourceCustomerApp {
return nil
}
var existing int64
q := tx.Model(&models.BookingStageEvent{}).
Where("bookingid = ? AND stage = ?", in.BookingID, in.Stage)
if in.DestinationID != nil {
q = q.Where("bookingdestinationid = ?", *in.DestinationID)
} else {
q = q.Where("bookingdestinationid IS NULL")
}
if err := q.Count(&existing).Error; err != nil {
return fmt.Errorf("cxstage: count existing: %w", err)
}
if existing == 0 {
event := models.BookingStageEvent{
Bookingid: in.BookingID,
Bookingdestinationid: in.DestinationID,
Stage: in.Stage,
Actortype: in.ActorType,
Actorid: in.ActorID,
Source: in.Source,
Remarks: in.Remarks,
Occurredat: at,
}
if err := tx.Create(&event).Error; err != nil {
return fmt.Errorf("cxstage: append event: %w", err)
}
}
if in.DestinationID != nil {
if err := advanceDestination(tx, *in.DestinationID, in.Stage, at); err != nil {
return err
}
}
return advanceBooking(tx, &booking, in.Stage)
}
// advanceDestination moves one order forward. Never backwards: a re-delivery
// attempt that re-issues out_for_delivery must not un-deliver a sibling, and a
// late-arriving event must not rewind a parcel the customer has already been
// told arrived.
func advanceDestination(tx *gorm.DB, destinationID int, stage string, at time.Time) error {
var dest models.BookingDestination
if err := tx.First(&dest, destinationID).Error; err != nil {
return fmt.Errorf("cxstage: load destination: %w", err)
}
if constants.CxStageRank(stage) <= constants.CxStageRank(dest.Stage) {
return nil
}
updates := map[string]interface{}{"stage": stage, "updatedat": utils.DBNow()}
if stage == constants.CxStageDelivered {
updates["deliveredat"] = at
}
return tx.Model(&models.BookingDestination{}).
Where("bookingdestinationid = ?", destinationID).Updates(updates).Error
}
// advanceBooking recomputes the booking's own stage and status.
//
// For stages 0-5 that is simply "the furthest stage reached". For 6-8 it is the
// LEAST-advanced destination, because a booking is only out for delivery when
// everything in it is, and only delivered when the last parcel lands. Taking
// the maximum instead would show a customer "Delivered" while one of their
// three parcels was still at a hub.
func advanceBooking(tx *gorm.DB, booking *models.PickupBooking, stage string) error {
bookingID := booking.Bookingid
if booking.Customerstatus == constants.CxStatusCancelled {
// A cancelled pickup is terminal. Late telemetry from a rider who was
// already stood down must not resurrect it.
return nil
}
next := stage
if constants.CxStageRank(stage) >= constants.CxStageOrder[constants.CxStageInTransit] {
var destinations []models.BookingDestination
if err := tx.Select("bookingdestinationid, stage").
Where("bookingid = ?", bookingID).Find(&destinations).Error; err != nil {
return fmt.Errorf("cxstage: load destinations: %w", err)
}
next = slowestDestinationStage(destinations, stage)
}
if constants.CxStageRank(next) <= constants.CxStageRank(booking.Customerstage) {
return nil
}
status := constants.CxStatusActive
if next == constants.CxStageDelivered {
status = constants.CxStatusCompleted
}
return tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", bookingID).
Updates(map[string]interface{}{
"customerstage": next,
"customerstatus": status,
"updatedat": utils.DBNow(),
}).Error
}
// slowestDestinationStage returns the least-advanced stage across a booking's
// orders, which is the booking's real progress.
func slowestDestinationStage(destinations []models.BookingDestination, fallback string) string {
if len(destinations) == 0 {
return fallback
}
slowest := ""
for _, d := range destinations {
s := d.Stage
if s == "" {
// An order that has not reported anything yet holds the booking at
// order_created — it exists, it just has not moved.
s = constants.CxStageOrderCreated
}
if slowest == "" || constants.CxStageRank(s) < constants.CxStageRank(slowest) {
slowest = s
}
}
if slowest == "" {
return fallback
}
return slowest
}
// Release walks a booking BACK to booked because its rider let it go.
//
// A miler cancelling or skipping does not cancel the pickup — it returns it to
// the pool for reassignment. Without this the customer keeps seeing "Miler
// assigned" against a booking that has no miler, and the screen shows a rider
// card for someone who is no longer coming. Stage is the only thing that moves
// backwards: the history entries for assigned and on_the_way stay, because
// those things did happen, and when a new rider accepts, Record's dedupe means
// no second copy is appended.
//
// This is the one exception to "stages only advance", and it is deliberate:
// the alternative is showing the customer a rider who is not on their way.
func Release(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
if !isCustomerBooking(tx, bookingID) {
return nil
}
now := utils.DBNow()
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", bookingID).
Updates(map[string]interface{}{
"customerstage": constants.CxStageBooked,
"customerstatus": constants.CxStatusActive,
"updatedat": now,
}).Error; err != nil {
return fmt.Errorf("cxstage: release booking: %w", err)
}
event := models.BookingStageEvent{
Bookingid: bookingID,
Stage: constants.CxStageBooked,
Actortype: actorType,
Actorid: actorID,
Source: source,
Remarks: "released: " + reason,
Occurredat: now,
}
if err := tx.Create(&event).Error; err != nil {
return fmt.Errorf("cxstage: record release: %w", err)
}
return nil
}
// Cancel marks a whole pickup cancelled and records who did it.
//
// Cancellation is whole-pickup: there is no partial cancel in v1, so every
// destination goes with it. The reason is stored because a customer who did not
// press the button — a miler skip, an ops stand-down — is otherwise told their
// pickup vanished with no explanation.
func Cancel(tx *gorm.DB, bookingID int, reason, actorType string, actorID *int, source string) error {
if !isCustomerBooking(tx, bookingID) {
return nil
}
now := utils.DBNow()
if err := tx.Model(&models.PickupBooking{}).
Where("bookingid = ?", bookingID).
Updates(map[string]interface{}{
"status": constants.BookingCancelled,
"customerstatus": constants.CxStatusCancelled,
"cancelreason": reason,
"updatedat": now,
}).Error; err != nil {
return fmt.Errorf("cxstage: cancel booking: %w", err)
}
// Recorded on the timeline as an event with a real actor, not as a silent
// status flip. "cancelled" is not one of the nine stages the client parses,
// so it rides the event log for the audit trail and the client reads
// status/cancelReason for the display.
event := models.BookingStageEvent{
Bookingid: bookingID,
Stage: constants.CxStageBooked,
Actortype: actorType,
Actorid: actorID,
Source: source,
Remarks: "cancelled: " + reason,
Occurredat: now,
}
// Deliberately not deduped against the booked event: this row is the
// cancellation record, distinguished by its remarks, and losing it would
// leave the cancellation unattributed.
if err := tx.Create(&event).Error; err != nil {
return fmt.Errorf("cxstage: record cancellation: %w", err)
}
return nil
}
// ── Notification ─────────────────────────────────────────────────────────────
// Notify pushes a stage change to the customer's devices. Call it AFTER the
// transaction commits — a notification for a rolled-back write cannot be
// recalled.
//
// Only customer-visible milestones are sent. on_the_way and order_created roll
// up on the timeline rather than earning their own notification: a customer who
// gets a buzz for every operational transition stops reading them, and then
// misses the one that mattered.
func Notify(bookingID int, destinationID *int, stage string) {
title, body, send := milestoneCopy(stage)
if !send {
return
}
var booking models.PickupBooking
if err := db.DB.Select("bookingid, bookingno, appcustomerid").
First(&booking, bookingID).Error; err != nil {
utils.Warn("cxstage: could not load booking for notification", "booking_id", bookingID, "error", err)
return
}
trackingID := ""
if destinationID != nil {
var dest models.BookingDestination
if err := db.DB.Select("trackingno").First(&dest, *destinationID).Error; err == nil {
trackingID = dest.Trackingno
}
}
payload := map[string]string{
"type": "stage_change",
"reference": booking.Bookingno,
"stage": stage,
"title": title,
"body": body,
// Sent from day one even though the client has no intent filter yet —
// the app starts honouring it in a later build, and a notification
// already in someone's tray should still open the right screen then.
"deepLink": "doormile://track/" + booking.Bookingno,
}
if trackingID != "" {
payload["trackingId"] = trackingID
payload["deepLink"] = "doormile://track/" + trackingID
}
payload["booking_id"] = strconv.Itoa(bookingID)
for _, token := range deviceTokens(booking.Appcustomerid) {
if err := notify.SendToDevice(token, title, body, payload); err != nil {
utils.Warn("cxstage: push failed", "booking_id", bookingID, "error", err)
}
}
}
// milestoneCopy maps a stage to the notification the customer reads, and says
// whether it earns one at all.
func milestoneCopy(stage string) (title, body string, send bool) {
switch stage {
case constants.CxStageAssigned:
return "Miler assigned", "Your Miler is on the way to collect your packages.", true
case constants.CxStageArrived:
return "Your Miler has arrived", "They are at your pickup address now.", true
case constants.CxStagePickedUp:
return "Packages collected", "Your Miler has collected and weighed your packages.", true
case constants.CxStageInTransit:
return "In transit", "Your package is on its way to the destination.", true
case constants.CxStageOutForDelivery:
return "Out for delivery", "Arriving today at the delivery address.", true
case constants.CxStageDelivered:
return "Delivered", "Your package has been handed over.", true
default:
// booked (the customer just made it), on_the_way and order_created all
// roll up on the timeline.
return "", "", false
}
}
// deviceTokens returns every device the customer is signed in on. One row per
// device, not one column on the customer, so a phone and a tablet both get the
// update instead of only whichever registered last.
func deviceTokens(customerID int) []string {
var devices []models.CustomerDevice
if err := db.DB.Select("token").Where("appcustomerid = ?", customerID).
Find(&devices).Error; err != nil {
utils.Warn("cxstage: device lookup failed", "customer_id", customerID, "error", err)
return nil
}
tokens := make([]string, 0, len(devices))
for _, d := range devices {
if d.Token != "" {
tokens = append(tokens, d.Token)
}
}
return tokens
}
// RecordAndNotify is the convenience the operational handlers use: record
// inside the transaction, then push once it has committed. The caller is
// responsible for calling the returned function only after a successful commit.
func RecordAndNotify(tx *gorm.DB, in Event) (afterCommit func(), err error) {
if err := Record(tx, in); err != nil {
return func() {}, err
}
bookingID, destinationID, stage := in.BookingID, in.DestinationID, in.Stage
return func() { go Notify(bookingID, destinationID, stage) }, nil
}
// isCustomerBooking reports whether a booking is one the customer app is
// watching. Cancel and Release are called from paths that also serve
// console-created express bookings, and those have no customer projection to
// keep in step — writing one would put stage rows against bookings nobody will
// ever read them for, and would fail an ops action if that write failed.
func isCustomerBooking(tx *gorm.DB, bookingID int) bool {
var booking models.PickupBooking
if err := tx.Select("bookingid, bookingsource").First(&booking, bookingID).Error; err != nil {
utils.Warn("cxstage: could not read booking source", "booking_id", bookingID, "error", err)
return false
}
return booking.Bookingsource == constants.BookingSourceCustomerApp
}

View File

@@ -0,0 +1,126 @@
package cxstage
import (
"testing"
"doormile/constants"
"doormile/models"
)
// A booking's own stage, once its parcels have split into separate orders, is
// the LEAST-advanced of them. Taking the maximum instead would tell a customer
// "Delivered" while one of their three parcels was still sitting at a hub —
// which is the single most damaging thing this projection could get wrong.
func TestSlowestDestinationStageHoldsAtTheLaggingOrder(t *testing.T) {
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Stage: constants.CxStageDelivered},
{Bookingdestinationid: 2, Stage: constants.CxStageInTransit},
{Bookingdestinationid: 3, Stage: constants.CxStageOutForDelivery},
}
got := slowestDestinationStage(destinations, constants.CxStageDelivered)
if got != constants.CxStageInTransit {
t.Errorf("slowestDestinationStage = %q, want %q — a booking is only as far along as its slowest parcel",
got, constants.CxStageInTransit)
}
}
func TestSlowestDestinationStageCompletesOnlyWhenAllLand(t *testing.T) {
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Stage: constants.CxStageDelivered},
{Bookingdestinationid: 2, Stage: constants.CxStageDelivered},
}
if got := slowestDestinationStage(destinations, constants.CxStageDelivered); got != constants.CxStageDelivered {
t.Errorf("slowestDestinationStage = %q, want %q", got, constants.CxStageDelivered)
}
}
func TestSlowestDestinationStageTreatsSilentOrderAsOrderCreated(t *testing.T) {
// An order that exists but has reported nothing yet holds the booking at
// order_created. An empty stage must never sort as "furthest along".
destinations := []models.BookingDestination{
{Bookingdestinationid: 1, Stage: constants.CxStageOutForDelivery},
{Bookingdestinationid: 2, Stage: ""},
}
got := slowestDestinationStage(destinations, constants.CxStageOutForDelivery)
if got != constants.CxStageOrderCreated {
t.Errorf("slowestDestinationStage with a silent order = %q, want %q", got, constants.CxStageOrderCreated)
}
}
func TestSlowestDestinationStageFallsBackWithNoDestinations(t *testing.T) {
// A console-created booking has no destination rows at all. It must keep
// the stage the caller was recording, not collapse to order_created.
got := slowestDestinationStage(nil, constants.CxStageOutForDelivery)
if got != constants.CxStageOutForDelivery {
t.Errorf("slowestDestinationStage(nil) = %q, want the fallback %q", got, constants.CxStageOutForDelivery)
}
}
// The nine stage keys are a wire contract: the client parses them verbatim and
// silently falls back to `booked` on anything it does not recognise, so a
// renamed or reordered key makes a moving parcel look un-started.
func TestStageRankOrderingIsTheContract(t *testing.T) {
ordered := []string{
constants.CxStageBooked,
constants.CxStageAssigned,
constants.CxStageOnTheWay,
constants.CxStageArrived,
constants.CxStagePickedUp,
constants.CxStageOrderCreated,
constants.CxStageInTransit,
constants.CxStageOutForDelivery,
constants.CxStageDelivered,
}
for i := 1; i < len(ordered); i++ {
if constants.CxStageRank(ordered[i]) <= constants.CxStageRank(ordered[i-1]) {
t.Errorf("stage %q does not rank after %q", ordered[i], ordered[i-1])
}
}
// An unknown stage must sort strictly BEHIND booked, not tie with it — a
// booking written before this surface existed has no stage, and a tie would
// let advanceBooking refuse to move it off nothing.
if constants.CxStageRank("") >= constants.CxStageRank(constants.CxStageBooked) {
t.Errorf("an empty stage ranks %d, which is not behind booked (%d)",
constants.CxStageRank(""), constants.CxStageRank(constants.CxStageBooked))
}
if constants.CxStageRank("teleported") != -1 {
t.Errorf("an unknown stage ranks %d, want -1", constants.CxStageRank("teleported"))
}
}
// Cancellation is allowed up to and including arrived, and refused from
// picked_up onward. The UI mirrors this to hide the button; the server is the
// authority, so the boundary is asserted here rather than trusted to a comment.
func TestCancellationClosesAfterArrived(t *testing.T) {
cancellable := []string{
"", // a pre-surface booking is still cancellable
constants.CxStageBooked,
constants.CxStageAssigned,
constants.CxStageOnTheWay,
constants.CxStageArrived,
}
for _, stage := range cancellable {
if !constants.CxCancellable(stage) {
t.Errorf("stage %q should still be cancellable", stage)
}
}
closed := []string{
constants.CxStagePickedUp,
constants.CxStageOrderCreated,
constants.CxStageInTransit,
constants.CxStageOutForDelivery,
constants.CxStageDelivered,
}
for _, stage := range closed {
if constants.CxCancellable(stage) {
t.Errorf("stage %q must not be cancellable — the parcel is already collected", stage)
}
}
}

105
internal/sms/sms.go Normal file
View File

@@ -0,0 +1,105 @@
// Package sms delivers one-time codes to a phone number.
//
// There is no SMS provider wired into this backend yet — the miler app
// authenticates on a PIN and the console on a password, so nothing has ever
// needed to send a text. The customer app's only credential is a code sent to a
// phone, which makes this the one piece of the auth flow that cannot be
// finished from inside this repository.
//
// So this package is the seam, not the integration: a small interface, a
// logging sink that lets the whole flow be exercised end to end without a
// provider, and a fixed-code mode for staging. Plugging in a real gateway
// (MSG91, Gupshup, Twilio) means adding one Sender and selecting it here —
// nothing above this package changes.
package sms
import (
"fmt"
"os"
"strings"
"doormile/utils"
)
// Sender delivers a message to an E.164 phone number.
type Sender interface {
Send(phone, message string) error
// Name identifies the transport in logs and in the readiness probe, so
// "OTP not arriving" can be answered without reading code.
Name() string
}
// logSender writes the code to the application log instead of sending it.
//
// This is what runs until a gateway is configured. It is deliberately loud and
// deliberately marked: an OTP in a log file is a credential in a log file, and
// nobody should be able to reach production with this active and not know.
type logSender struct{}
func (logSender) Name() string { return "log" }
func (logSender) Send(phone, message string) error {
utils.Warn("SMS NOT CONFIGURED — code written to the log instead of being sent",
"phone", maskPhone(phone), "message", message)
return nil
}
var active Sender = logSender{}
// Register installs the real gateway. Call it from main() once a provider is
// configured; until then the log sink stays in place.
func Register(s Sender) {
if s == nil {
return
}
active = s
utils.Info("SMS sender registered", "transport", s.Name())
}
// Transport reports which sender is active, for the readiness probe.
func Transport() string { return active.Name() }
// Configured reports whether a real gateway is in place. False means codes are
// only reaching the log.
func Configured() bool { return active.Name() != "log" }
// SendOTP delivers a login code.
func SendOTP(phone, code string) error {
if strings.TrimSpace(phone) == "" {
return fmt.Errorf("sms: empty phone number")
}
msg := fmt.Sprintf("%s is your Doormile verification code. It expires in 5 minutes. Do not share it with anyone.", code)
return active.Send(phone, msg)
}
// maskPhone keeps the country code and the last two digits so a log line can be
// matched to a support call without recording the number itself.
func maskPhone(phone string) string {
if len(phone) < 5 {
return "***"
}
return phone[:3] + strings.Repeat("*", len(phone)-5) + phone[len(phone)-2:]
}
// StagingCode returns the fixed verification code for non-production
// environments, or "" when none is set.
//
// Automated tests and design QA cannot receive a real text, and the previous
// end-to-end attempt on this system stalled for exactly that reason: customer
// login needed an OTP on a real handset and could not be scripted. CX_STAGING_OTP
// closes that.
//
// It is refused outright when ENV is production, because a fixed code is a
// permanent skeleton key for every account on the platform.
func StagingCode() string {
code := strings.TrimSpace(os.Getenv("CX_STAGING_OTP"))
if code == "" {
return ""
}
if strings.EqualFold(strings.TrimSpace(os.Getenv("ENV")), "production") {
utils.Error("CX_STAGING_OTP is set in a production environment and has been ignored — " +
"a fixed verification code would accept a login for every account on the platform")
return ""
}
return code
}

140
internal/sms/sms_test.go Normal file
View File

@@ -0,0 +1,140 @@
package sms
import (
"os"
"strings"
"testing"
)
// StagingCode is a permanent skeleton key for every account on the platform if
// it ever reaches production. The guard against that is the only thing standing
// between a convenience for QA and a total auth bypass, so it is tested rather
// than trusted.
func setEnv(t *testing.T, key, value string) {
t.Helper()
previous, had := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
// A fixed OTP is refused in production however the environment is spelt.
func TestStagingCodeIsRefusedInProduction(t *testing.T) {
for _, env := range []string{"production", "PRODUCTION", "Production", " production "} {
setEnv(t, "CX_STAGING_OTP", "1234")
setEnv(t, "ENV", env)
if got := StagingCode(); got != "" {
t.Errorf("ENV=%q returned the fixed code %q — that is a skeleton key "+
"for every account on the platform", env, got)
}
}
}
// And is available everywhere else, which is what unblocks automated sign-in.
func TestStagingCodeIsAvailableOutsideProduction(t *testing.T) {
for _, env := range []string{"development", "staging", ""} {
setEnv(t, "CX_STAGING_OTP", "1234")
setEnv(t, "ENV", env)
if got := StagingCode(); got != "1234" {
t.Errorf("ENV=%q returned %q, want the configured staging code", env, got)
}
}
}
// Unset means unset — no accidental default.
func TestStagingCodeIsEmptyWhenNotConfigured(t *testing.T) {
setEnv(t, "ENV", "development")
setEnv(t, "CX_STAGING_OTP", "")
if got := StagingCode(); got != "" {
t.Errorf("StagingCode() = %q with nothing configured, want empty", got)
}
}
// Until a gateway is registered, Configured() must report false. Shipping while
// this quietly said true would mean nobody noticed OTP codes were only reaching
// the application log.
func TestTransportReportsThatNoGatewayIsWired(t *testing.T) {
if Configured() {
t.Error("Configured() = true with no gateway registered — " +
"the log sink must never claim to be a real transport")
}
if Transport() != "log" {
t.Errorf("Transport() = %q, want \"log\"", Transport())
}
}
// Registering a gateway flips both, and Register(nil) is ignored rather than
// silently disabling delivery.
func TestRegisterInstallsAGatewayAndIgnoresNil(t *testing.T) {
original := active
t.Cleanup(func() { active = original })
Register(nil)
if Transport() != "log" {
t.Errorf("Register(nil) changed the transport to %q", Transport())
}
fake := &recordingSender{}
Register(fake)
if !Configured() || Transport() != "test" {
t.Fatalf("after Register: configured=%v transport=%q", Configured(), Transport())
}
if err := SendOTP("+919876543210", "4821"); err != nil {
t.Fatalf("SendOTP: %v", err)
}
if fake.phone != "+919876543210" {
t.Errorf("phone = %q, want the number passed in", fake.phone)
}
if !strings.Contains(fake.message, "4821") {
t.Errorf("message %q does not carry the code", fake.message)
}
if !strings.Contains(fake.message, "Do not share") {
t.Errorf("message %q is missing the do-not-share warning", fake.message)
}
}
// An empty number is refused rather than handed to a gateway that will bill for
// it and fail.
func TestSendOTPRefusesAnEmptyNumber(t *testing.T) {
if err := SendOTP(" ", "4821"); err == nil {
t.Error("SendOTP accepted an empty phone number")
}
}
// The log sink masks the number. An OTP in a log file is already bad enough
// without the number it belongs to sitting beside it.
func TestMaskPhoneHidesTheSubscriberDigits(t *testing.T) {
got := maskPhone("+919876543210")
if strings.Contains(got, "9876543") {
t.Errorf("maskPhone = %q, still exposes the subscriber digits", got)
}
if !strings.HasSuffix(got, "10") {
t.Errorf("maskPhone = %q, should keep the last two digits so a support "+
"call can be matched", got)
}
}
type recordingSender struct {
phone string
message string
}
func (r *recordingSender) Name() string { return "test" }
func (r *recordingSender) Send(phone, message string) error {
r.phone, r.message = phone, message
return nil
}

View File

@@ -223,3 +223,77 @@ func awsEncode(s string, encodeSlash bool) string {
}
return b.String()
}
// PresignGet issues a short-lived, signed GET URL for one object.
//
// Parcel photographs are shown to the customer on the receipt, and a parcel
// photograph frames the inside of someone's doorway. A permanent CDN link to
// one is a permanent link anybody who ever saw it can keep, so the customer
// surface serves these through a signature that expires instead.
//
// Falls back to the plain CDN URL when the bucket credentials are not
// configured: an unsigned photo the customer can see beats a receipt with a
// missing image, and the objects are currently written public-read anyway.
// Once parcel photos are switched to a private ACL this becomes the only way
// to read one — which is the point of routing them through here now.
func PresignGet(objectKey string, expiry time.Duration) (string, error) {
cfg := loadSpacesConfig()
if cfg.accessKey == "" || cfg.secretKey == "" || cfg.bucket == "" {
if cfg.cdnBase != "" {
return cfg.cdnBase + "/" + objectKey, nil
}
return "", fmt.Errorf("object storage not configured")
}
const (
service = "s3"
algorithm = "AWS4-HMAC-SHA256"
)
host := cfg.bucket + "." + cfg.endpoint
now := time.Now().UTC()
amzDate := now.Format("20060102T150405Z")
dateStamp := now.Format("20060102")
expSecs := int(expiry.Seconds())
if expSecs <= 0 {
expSecs = 900
}
canonicalURI := "/" + encodePath(objectKey)
credentialScope := dateStamp + "/" + cfg.region + "/" + service + "/aws4_request"
credential := cfg.accessKey + "/" + credentialScope
signedHeaders := "host"
q := [][2]string{
{"X-Amz-Algorithm", algorithm},
{"X-Amz-Credential", credential},
{"X-Amz-Date", amzDate},
{"X-Amz-Expires", fmt.Sprintf("%d", expSecs)},
{"X-Amz-SignedHeaders", signedHeaders},
}
canonicalQuery := canonicalizeQuery(q)
canonicalHeaders := "host:" + host + "\n"
canonicalRequest := strings.Join([]string{
"GET",
canonicalURI,
canonicalQuery,
canonicalHeaders,
signedHeaders,
"UNSIGNED-PAYLOAD",
}, "\n")
stringToSign := strings.Join([]string{
algorithm,
amzDate,
credentialScope,
hexSHA256(canonicalRequest),
}, "\n")
signingKey := deriveSigningKey(cfg.secretKey, dateStamp, cfg.region, service)
signature := hex.EncodeToString(hmacSHA256(signingKey, stringToSign))
return "https://" + host + canonicalURI + "?" + canonicalQuery +
"&X-Amz-Signature=" + signature, nil
}

View File

@@ -0,0 +1,182 @@
package storage
import (
"net/url"
"os"
"strings"
"testing"
"time"
)
// PresignGet is what serves a customer their parcel photographs. A parcel photo
// frames the inside of someone's doorway, so the properties tested here are
// privacy properties, not formatting ones: the link must expire, and it must
// never carry the bucket's secret key.
func withSpaces(t *testing.T, access, secret string) {
t.Helper()
for _, kv := range [][2]string{
{"DO_SPACES_ACCESS_KEY", access},
{"DO_SPACES_SECRET_KEY", secret},
{"DO_SPACES_REGION", "sgp1"},
{"DO_SPACES_ENDPOINT", "sgp1.digitaloceanspaces.com"},
{"DO_SPACES_BUCKET", "nearle"},
{"DO_SPACES_CDN_BASE", "https://images.nearle.app"},
} {
key, value := kv[0], kv[1]
previous, had := os.LookupEnv(key)
if value == "" {
_ = os.Unsetenv(key)
} else {
_ = os.Setenv(key, value)
}
t.Cleanup(func() {
if had {
_ = os.Setenv(key, previous)
} else {
_ = os.Unsetenv(key)
}
})
}
}
// The signed URL must never contain the secret key. A leaked secret is the
// whole bucket, not one photo — and this is exactly the mistake the legacy
// rider app made by shipping the key inside the binary.
func TestPresignGetNeverLeaksTheSecretKey(t *testing.T) {
const secret = "s3cr3t-do-not-emit-this-anywhere"
withSpaces(t, "AKIAEXAMPLE", secret)
signed, err := PresignGet("pv/booking-70/parcel-1.jpg", 30*time.Minute)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
if strings.Contains(signed, secret) {
t.Fatal("the signed URL contains the secret key")
}
if strings.Contains(strings.ToLower(signed), "secret") {
t.Errorf("suspicious content in the signed URL: %s", signed)
}
// The ACCESS key is expected — it identifies the caller, it is not a
// credential on its own.
if !strings.Contains(signed, "AKIAEXAMPLE") {
t.Error("the signed URL carries no credential scope, so it cannot authenticate")
}
}
// A link that does not expire is a permanent link, which defeats the point of
// signing it at all.
func TestPresignGetCarriesAnExpiry(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "shhh")
signed, err := PresignGet("pv/abc.jpg", 30*time.Minute)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
parsed, err := url.Parse(signed)
if err != nil {
t.Fatalf("the signed URL does not parse: %v", err)
}
q := parsed.Query()
if got := q.Get("X-Amz-Expires"); got != "1800" {
t.Errorf("X-Amz-Expires = %q, want 1800 (30 minutes)", got)
}
for _, param := range []string{"X-Amz-Algorithm", "X-Amz-Credential", "X-Amz-Date", "X-Amz-SignedHeaders", "X-Amz-Signature"} {
if q.Get(param) == "" {
t.Errorf("missing %s — the URL would be rejected by the object store", param)
}
}
if q.Get("X-Amz-Algorithm") != "AWS4-HMAC-SHA256" {
t.Errorf("unexpected algorithm %q", q.Get("X-Amz-Algorithm"))
}
}
// A non-positive expiry must fall back to a real one rather than minting a link
// that is already dead, or worse, one the store treats as unbounded.
func TestPresignGetDefaultsAZeroExpiry(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "shhh")
signed, err := PresignGet("pv/abc.jpg", 0)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
parsed, _ := url.Parse(signed)
if got := parsed.Query().Get("X-Amz-Expires"); got != "900" {
t.Errorf("X-Amz-Expires = %q on a zero expiry, want the 900s default", got)
}
}
// Two different objects must produce different signatures. A signature that
// does not cover the key would let one link fetch any file in the bucket.
func TestPresignGetSignatureCoversTheObjectKey(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "shhh")
a, err := PresignGet("pv/booking-70/parcel-1.jpg", time.Hour)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
b, err := PresignGet("pv/booking-99/parcel-4.jpg", time.Hour)
if err != nil {
t.Fatalf("PresignGet: %v", err)
}
sigA, _ := url.Parse(a)
sigB, _ := url.Parse(b)
if sigA.Query().Get("X-Amz-Signature") == sigB.Query().Get("X-Amz-Signature") {
t.Fatal("two different objects produced the same signature — the key is not signed")
}
if !strings.Contains(a, "booking-70") || !strings.Contains(b, "booking-99") {
t.Error("the object key is missing from the URL path")
}
}
// With no credentials configured it degrades to the plain CDN link rather than
// failing. An unsigned photo the customer can see beats a receipt with a broken
// image — and the objects are currently written public-read anyway.
func TestPresignGetFallsBackToTheCdnWhenUnconfigured(t *testing.T) {
withSpaces(t, "", "")
got, err := PresignGet("pv/abc.jpg", time.Hour)
if err != nil {
t.Fatalf("PresignGet should degrade, not fail: %v", err)
}
if got != "https://images.nearle.app/pv/abc.jpg" {
t.Errorf("fallback URL = %q, want the plain CDN link", got)
}
}
// Configured() gates the presign path; it must not claim to be configured on a
// half-set environment.
func TestConfiguredRequiresBothKeys(t *testing.T) {
withSpaces(t, "AKIAEXAMPLE", "")
if Configured() {
t.Error("Configured() = true with no secret key")
}
withSpaces(t, "", "shhh")
if Configured() {
t.Error("Configured() = true with no access key")
}
withSpaces(t, "AKIAEXAMPLE", "shhh")
if !Configured() {
t.Error("Configured() = false with both keys present")
}
}
// Object keys reach this from user-influenced paths, so the encoder has to
// survive spaces and reserved characters without breaking the signature.
func TestEncodePathHandlesAwkwardKeys(t *testing.T) {
cases := []struct{ in, want string }{
{"pv/abc.jpg", "pv/abc.jpg"},
{"pv/a b.jpg", "pv/a%20b.jpg"},
{"pv/a+b.jpg", "pv/a%2Bb.jpg"},
{"pv/sub dir/x.jpg", "pv/sub%20dir/x.jpg"},
}
for _, tc := range cases {
if got := encodePath(tc.in); got != tc.want {
t.Errorf("encodePath(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}