backend requirements onthe xustomer app
This commit is contained in:
@@ -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
425
internal/cxstage/stage.go
Normal 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
|
||||
}
|
||||
126
internal/cxstage/stage_test.go
Normal file
126
internal/cxstage/stage_test.go
Normal 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
105
internal/sms/sms.go
Normal 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
140
internal/sms/sms_test.go
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
182
internal/storage/spaces_test.go
Normal file
182
internal/storage/spaces_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user