Compare commits

...

4 Commits

Author SHA1 Message Date
abb52c7db2 fix(webhook): fail closed when stripe webhook secret is not set 2026-06-10 11:01:56 +02:00
c9f8b1e674 test(test): smoke test deploy script with dirty box tree (#44) 2026-06-09 19:39:09 +02:00
d592249efb fix: fail deploy loudly on stale pull or HEAD mismatch (#43) 2026-06-09 19:20:08 +02:00
cfc97eb1ff fix/temp disable sshCardAdd button (#42)
* fix: disable add-card-via-ssh, mark coming soo, skip in cursor nav

* style(tui): reword (coming soon) to (WIP)
2026-06-09 14:33:09 +02:00
7 changed files with 175 additions and 28 deletions

View File

@@ -22,6 +22,15 @@ jobs:
key: ${{ secrets.SERVER_SSH_KEY }} key: ${{ secrets.SERVER_SSH_KEY }}
port: ${{ secrets.SERVER_PORT }} port: ${{ secrets.SERVER_PORT }}
script: | script: |
set -euo pipefail
cd ~/terminalShop cd ~/terminalShop
git pull git fetch origin master
git reset --hard origin/master
deployed=$(git rev-parse HEAD)
expected="${{ github.sha }}"
if [ "$deployed" != "$expected" ]; then
echo "::error::deploy HEAD mismatch: box=$deployed expected=$expected"
exit 1
fi
docker compose -f docker-compose.prod.yml up -d --build docker compose -f docker-compose.prod.yml up -d --build
echo "deploy verified: HEAD=$deployed"

View File

@@ -71,3 +71,4 @@ uses SQLite by default so there's nothing to spin up.
## inspiration ## inspiration
[terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading. [terminal.shop](https://terminal.shop) for the original idea and most of the patterns in `pkg/tui/`. their source is open and worth reading.
# something

View File

@@ -7,6 +7,7 @@ import (
"io" "io"
"log/slog" "log/slog"
"net/http" "net/http"
"os"
"strings" "strings"
"time" "time"
@@ -52,8 +53,17 @@ func (h *WebhookHandler) HandleStripe(w http.ResponseWriter, r *http.Request) {
// Verify the Stripe signature. Reject anything that doesn't match. // Verify the Stripe signature. Reject anything that doesn't match.
if h.webhookSecret == "" { if h.webhookSecret == "" {
// No secret configured — log a loud warning but still process in dev. // Fail closed in production. config.validate() requires STRIPE_WEBHOOK_SECRET
webhookLog().Warn("stripe webhook secret not set; skipping signature verification") // in production, so the server should never boot into this state; this is
// defense in depth against a misconfigured deploy. Mirrors the Shippo and
// Slack handlers, which already refuse to run without their secret.
if os.Getenv("ENVIRONMENT") == "production" {
webhookLog().Error("stripe webhook secret not set in production; refusing to process event")
utils.RespondError(w, http.StatusServiceUnavailable, "WEBHOOK_NOT_CONFIGURED", "webhook verification not configured", nil)
return
}
// Development only; warn loudly but still process so local testing works.
webhookLog().Warn("stripe webhook secret not set; skipping signature verification (development only)")
} else { } else {
sig := r.Header.Get("Stripe-Signature") sig := r.Header.Get("Stripe-Signature")
if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{ if _, err := webhook.ConstructEventWithOptions(body, sig, h.webhookSecret, webhook.ConstructEventOptions{
@@ -124,6 +134,20 @@ func (h *WebhookHandler) handlePaymentIntentSucceeded(ctx context.Context, event
return return
} }
// Defense in depth: even behind a valid signature, refuse to mark an order
// paid unless the PaymentIntent's amount and currency match what we charged.
// Under ConvertCart these always match (Amount == order.Total at creation), so
// a mismatch means a malformed or unexpected event we should not trust.
if pi.Amount != int64(order.Total) {
webhookLog().Error("payment intent amount mismatch; refusing to mark paid", "order_id", orderIDStr, "pi", pi.ID, "pi_amount", pi.Amount, "order_total", order.Total)
return
}
if pi.Currency != "" && pi.Currency != stripe.CurrencyUSD {
webhookLog().Error("payment intent currency mismatch; refusing to mark paid",
"order_id", orderIDStr, "pi", pi.ID, "currency", string(pi.Currency))
return
}
txErr := db.Transaction(func(tx *gorm.DB) error { txErr := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&order).Updates(map[string]any{ if err := tx.Model(&order).Updates(map[string]any{
"status": models.OrderStatusPaid, "status": models.OrderStatusPaid,

View File

@@ -222,3 +222,38 @@ func TestHandlePaymentIntentSucceeded_RequiresActionFlipsToPaid(t *testing.T) {
t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID) t.Fatalf("stripe_payment_id: want %q, got %q", order.StripePaymentID, reloaded.StripePaymentID)
} }
} }
func TestHandlePaymentIntentSucceeded_AmountMismatchDoesNotPay(t *testing.T) {
testDB, user := setupCartTestDB(t)
defer func() { _ = os.Remove(testDB) }()
defer database.ResetForTesting()
order := seedOrderForWebhook(t, user.ID, models.OrderStatusRequiresAction, "pi_test_amount_mismatch")
// PI claims 1 cent; seed order.Total is 500.
pi := map[string]any{
"id": order.StripePaymentID,
"amount": 1,
"metadata": map[string]string{"order_id": fmt.Sprintf("%d", order.ID)},
}
raw, err := json.Marshal(pi)
if err != nil {
t.Fatalf("marshal pi: %v", err)
}
evt := stripe.Event{
Type: "payment_intent.succeeded",
Data: &stripe.EventData{Raw: raw},
}
h := NewWebhookHandler("", "")
h.handlePaymentIntentSucceeded(context.Background(), evt)
db := database.GetDB()
var reloaded models.Order
if err := db.First(&reloaded, order.ID).Error; err != nil {
t.Fatalf("reloaded order: %v", err)
}
if reloaded.Status == models.OrderStatusPaid {
t.Fatalf("order marked paid despite amount mismatch")
}
}

View File

@@ -92,6 +92,7 @@ func (c *Config) validate() error {
{"JWT_SECRET", c.JWTSecret}, {"JWT_SECRET", c.JWTSecret},
{"STRIPE_SECRET_KEY", c.StripeSecretKey}, {"STRIPE_SECRET_KEY", c.StripeSecretKey},
{"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey}, {"AUTH_FINGERPRINT_KEY", c.AuthFingerprintKey},
{"STRIPE_WEBHOOK_SECRET", c.StripeWebhookSecret},
} }
var missing []string var missing []string

View File

@@ -355,11 +355,16 @@ func (m Model) RenderCardList() string {
boxes = append(boxes, box) boxes = append(boxes, box)
} }
sshFocused := m.payment.cardCursor == len(m.SavedCards) // SSH inline card-add is dormant until i have a norwegian org
sshBox := m.CreateBox(m.formatListItem("+ add card via ssh", sshFocused), sshFocused) // number wired to stripe, something i dont have since its $300+. Keep the row visible so the option is
// discoverable, but render it dimmed and exclude it from cursor space
// (handled in PaymentUpdate below) so it can not be picked
dim := m.theme.TextDim().Render
sshLabel := dim("+ add card via ssh") + " " + dim("(WIP)")
sshBox := m.CreateBox(m.formatListItem(sshLabel, false), false)
boxes = append(boxes, sshBox) boxes = append(boxes, sshBox)
httpsFocused := m.payment.cardCursor == len(m.SavedCards)+1 httpsFocused := m.payment.cardCursor == len(m.SavedCards)
httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused) httpsBox := m.CreateBox(m.formatListItem("+ add payment via browser", httpsFocused), httpsFocused)
boxes = append(boxes, httpsBox) boxes = append(boxes, httpsBox)
@@ -501,7 +506,7 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.payment.cardCursor-- m.payment.cardCursor--
} }
case "down", "j": case "down", "j":
if m.payment.cardCursor < len(m.SavedCards)+1 { if m.payment.cardCursor < len(m.SavedCards) {
m.payment.cardCursor++ m.payment.cardCursor++
} }
@@ -511,17 +516,8 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.SelectedCard = &selected m.SelectedCard = &selected
return m.ReviewSwitch() return m.ReviewSwitch()
} }
if m.payment.cardCursor == len(m.SavedCards) { // Browser flow, the only reachable add-card path while the SSH
m.payment.view = 1 // inline form is dormant (see RenderCardList)
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, m.payment.form.form.Init()
}
// Browser payment
m.payment.view = 2 m.payment.view = 2
m.payment.collectURL = nil m.payment.collectURL = nil
m.payment.collectTimeOut = false m.payment.collectTimeOut = false
@@ -546,16 +542,6 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 { if m.payment.cardCursor >= len(m.SavedCards) && m.payment.cardCursor > 0 {
m.payment.cardCursor-- m.payment.cardCursor--
} }
if len(m.SavedCards) == 0 {
m.payment.view = 1
m.payment.form = m.InitPaymentForm()
m.footer = []footerCommand{
{key: "tab", value: "next"},
{key: "enter", value: "submit"},
{key: "esc", value: "back"},
}
return m, tea.Batch(m.payment.form.form.Init(), m.deleteCardCmd(card.ID))
}
return m, m.deleteCardCmd(card.ID) return m, m.deleteCardCmd(card.ID)
} }
} }

View File

@@ -0,0 +1,91 @@
package tui
import (
"strings"
"testing"
"terminalShop/pkg/models"
tea "charm.land/bubbletea/v2"
)
// newPaymentCardListModel parks a Model on the payment page card-list view
// with n synthetic saved cards. Baseline for assertint that the dormant
// "+ add card via ssh" row is dimmed and unreachable by cursor navigation.
func newPaymentCardListModel(t *testing.T, n int) Model {
t.Helper()
m := NewModel("test")
m = m.updateLayout(120, 40)
m.currentPage = paymentPage
m.payment.view = 0
m.payment.form = nil
m.payment.cardCursor = 0
for i := range n {
m.SavedCards = append(m.SavedCards, models.Card{
ID: uint(i + 1),
Brand: "visa",
Last4: "4242",
ExpMonth: 12,
ExpYear: 2030,
})
}
return m
}
func keyEnter() tea.KeyMsg { return tea.KeyPressMsg{Code: tea.KeyEnter} }
func TestRenderCardList_SshOptionComingSoon(t *testing.T) {
m := newPaymentCardListModel(t, 1)
out := m.RenderCardList()
if !strings.Contains(out, "add card via ssh") {
t.Fatalf("ssh option missing from card list output:\n%s", out)
}
if !strings.Contains(out, "WIP") {
t.Fatalf("want 'WIP' marked on ssh option:\n%s", out)
}
}
func TestPaymentUpdate_DownNavigationStopsAtBrowserSlot(t *testing.T) {
m := newPaymentCardListModel(t, 2)
for range 10 {
var cmd tea.Cmd
m, cmd = m.PaymentUpdate(keyJ())
_ = cmd
}
want := len(m.SavedCards) // browser slot; ssh slot in unreachable
if m.payment.cardCursor != want {
t.Fatalf("cursor stopped at %d, want %d (browser slot)", m.payment.cardCursor, want)
}
}
func TestPaymentUpdate_EnterOnBrowserSlotLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 1)
m.payment.cardCursor = len(m.SavedCards) // browser slot
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil after enter; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}
func TestPaymentUpdate_EnterWithZeroCardsLaunchesBrowserFlow(t *testing.T) {
m := newPaymentCardListModel(t, 0)
if m.payment.cardCursor != 0 {
t.Fatalf("default cursor = %d, want 0 ", m.payment.cardCursor)
}
newM, cmd := m.PaymentUpdate(keyEnter())
if newM.payment.view != 2 {
t.Fatalf("payment.view = %d, want 2 (browser flow)", newM.payment.view)
}
if newM.payment.form != nil {
t.Fatalf("payment.form must be nil; ssh inline form is dormant")
}
if cmd == nil {
t.Fatalf("expected collectCardCmd, got nil")
}
}