diff --git a/api/handlers/ssh_keys.go b/api/handlers/ssh_keys.go deleted file mode 100644 index b97ac70..0000000 --- a/api/handlers/ssh_keys.go +++ /dev/null @@ -1,58 +0,0 @@ -package handlers - -import ( - "net/http" - "strconv" - "terminalShop/api/middleware" - "terminalShop/pkg/database" - "terminalShop/pkg/models" - "terminalShop/pkg/utils" - - "github.com/go-chi/chi/v5" -) - -type SSHKeyHandler struct{} - -func NewSSHKeyHandler() *SSHKeyHandler { - return &SSHKeyHandler{} -} - -// GetSSHKeys returns all ssh keys for the authenticated user. -func (h *SSHKeyHandler) GetSSHKeys(w http.ResponseWriter, r *http.Request) { - db := database.GetDB() - userID := middleware.UserIDFromContext(r.Context()) - - var keys []models.SSHKey - db.Where("user_id = ?", userID).Order("created_at ASC").Find(&keys) - - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ - "ssh_keys": keys, - }) -} - -// DeleteSSHKeys removes one of the users ssh keys by ID -func (h *SSHKeyHandler) DeleteSSHKeys(w http.ResponseWriter, r *http.Request) { - db := database.GetDB() - userID := middleware.UserIDFromContext(r.Context()) - - idStr := chi.URLParam(r, "id") - id, err := strconv.ParseUint(idStr, 10, 32) - if err != nil { - utils.RespondError(w, http.StatusBadRequest, "INVALID_ID", "invalid ssh key id", nil) - return - } - - var key models.SSHKey - if err := db.Where("id = ? AND user_id = ?", id, userID).First(&key).Error; err != nil { - utils.RespondError(w, http.StatusNotFound, "SSH_KEY_NOT_FOUND", "ssh key not found", nil) - return - } - if err := db.Delete(&key).Error; err != nil { - utils.RespondError(w, http.StatusInternalServerError, "INTERNAL_ERROR", "failed to delete ssh key", nil) - return - } - - utils.RespondSuccess(w, http.StatusOK, map[string]interface{}{ - "message": "ssh key deleted", - }) -} diff --git a/loadtest/overnight.js b/loadtest/overnight.js new file mode 100644 index 0000000..552ad0f --- /dev/null +++ b/loadtest/overnight.js @@ -0,0 +1,225 @@ +import http from 'k6/http'; + +const BASE = __ENV.BASE_URL || 'https://api.sshops.uk'; +const SECRET = __ENV.CLIENT_SECRET || ''; + +// Per-VU JWT cache. JWTs are valid for 30 minutes; we refresh ~5 min early. +let cachedToken = null; +let cachedExpiresAt = 0; +let cachedFingerprint = null; + +// SSHPublicKey has a uniqueIndex constraint, so each VU needs a distinct +// (fake but unique) public key string. Suffixing with VU+timestamp keeps +// the full text unique without affecting the rest of the body. +let cachedPubkey = null; + +function getToken() { + const now = Date.now(); + if (cachedToken && now < cachedExpiresAt) return cachedToken; + + if (!cachedFingerprint) { + cachedFingerprint = `SHA256:k6-${__VU}-${now.toString(36)}`; + cachedPubkey = `ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILoadTest${__VU}-${now.toString(36)} k6@vu${__VU}`; + } + + const res = http.post( + `${BASE}/api/v1/auth/token`, + JSON.stringify({ + client_secret: SECRET, + fingerprint: cachedFingerprint, + ssh_public_key: cachedPubkey, + }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_token_refresh' } }, + ); + + if (res.status !== 200) return null; + const body = JSON.parse(res.body); + cachedToken = body.access_token; + cachedExpiresAt = now + (body.expires_in - 300) * 1000; + return cachedToken; +} + +function authHeaders() { + const tok = getToken(); + return tok ? { Authorization: `Bearer ${tok}`, 'Content-Type': 'application/json' } : null; +} + +export const options = { + discardResponseBodies: true, + scenarios: { + browse: { + executor: 'ramping-arrival-rate', + startRate: 6, + timeUnit: '1m', + preAllocatedVUs: 10, + maxVUs: 50, + stages: [ + { target: 12, duration: '1h' }, + { target: 30, duration: '2h' }, + { target: 90, duration: '2h' }, + { target: 60, duration: '2h' }, + { target: 120, duration: '3h' }, + { target: 30, duration: '2h' }, + ], + exec: 'browse', + }, + authed_browse: { + executor: 'ramping-arrival-rate', + startRate: 2, + timeUnit: '1m', + preAllocatedVUs: 5, + maxVUs: 20, + stages: [ + { target: 10, duration: '4h' }, + { target: 30, duration: '4h' }, + { target: 8, duration: '4h' }, + ], + exec: 'authedBrowse', + }, + convert_fail: { + // /cart/convert is rate-limited 10/min per user. We share one loadtest + // user across all VUs so ceiling is 10/min; we stay under at 4/min. + executor: 'ramping-arrival-rate', + startRate: 1, + timeUnit: '1m', + preAllocatedVUs: 3, + maxVUs: 5, + stages: [ + { target: 2, duration: '4h' }, + { target: 4, duration: '4h' }, + { target: 2, duration: '4h' }, + ], + exec: 'convertFail', + }, + address_bad: { + executor: 'ramping-arrival-rate', + startRate: 1, + timeUnit: '1m', + preAllocatedVUs: 3, + maxVUs: 5, + stages: [ + { target: 3, duration: '12h' }, + ], + exec: 'addressBad', + }, + auth_warn: { + executor: 'ramping-arrival-rate', + startRate: 1, + timeUnit: '1m', + preAllocatedVUs: 5, + maxVUs: 10, + stages: [ + { target: 3, duration: '4h' }, + { target: 10, duration: '4h' }, + { target: 5, duration: '4h' }, + ], + exec: 'authBad', + }, + auth_lookup: { + executor: 'ramping-arrival-rate', + startRate: 1, + timeUnit: '1m', + preAllocatedVUs: 5, + maxVUs: 10, + stages: [ + { target: 4, duration: '6h' }, + { target: 8, duration: '6h' }, + ], + exec: 'authLookup', + }, + err_404: { + executor: 'ramping-arrival-rate', + startRate: 1, + timeUnit: '1m', + preAllocatedVUs: 3, + maxVUs: 5, + stages: [ + { target: 5, duration: '12h' }, + ], + exec: 'notFound', + }, + }, +}; + +export default function () { + // Smoke-test entry point for `--vus N --duration T` runs. + browse(); + authBad(); + authLookup(); + notFound(); + if (SECRET) { + authedBrowse(); + convertFail(); + addressBad(); + } +} + +export function browse() { + const r = Math.random(); + if (r < 0.6) http.get(`${BASE}/api/v1/products`, { tags: { name: 'list_products' } }); + else if (r < 0.8) http.get(`${BASE}/api/v1/products/1`, { tags: { name: 'get_product' } }); + else if (r < 0.95) http.get(`${BASE}/api/v1/health`, { tags: { name: 'health' } }); + else http.get(`${BASE}/api/v1/ping`, { tags: { name: 'ping' } }); +} + +export function authedBrowse() { + const h = authHeaders(); + if (!h) return; + const r = Math.random(); + if (r < 0.4) http.get(`${BASE}/api/v1/cart`, { headers: h, tags: { name: 'authed_get_cart' } }); + else if (r < 0.6) http.get(`${BASE}/api/v1/cards`, { headers: h, tags: { name: 'authed_list_cards' } }); + else if (r < 0.8) http.get(`${BASE}/api/v1/addresses`, { headers: h, tags: { name: 'authed_list_addresses' } }); + else http.get(`${BASE}/api/v1/orders`, { headers: h, tags: { name: 'authed_list_orders' } }); +} + +export function convertFail() { + const h = authHeaders(); + if (!h) return; + // Reset cart, drop one item in, attempt convert. No card/address selected + // → ConvertCart hits validation_missing_address → cart_conversion_total + // outcome=validation_missing_address + audit.OrderFailed warn. + http.del(`${BASE}/api/v1/cart`, null, { headers: h, tags: { name: 'cart_clear' } }); + http.put( + `${BASE}/api/v1/cart/item`, + JSON.stringify({ coffee_id: 1, quantity: 1 }), + { headers: h, tags: { name: 'cart_add_item' } }, + ); + http.post(`${BASE}/api/v1/cart/convert`, null, { headers: h, tags: { name: 'cart_convert_fail' } }); +} + +export function addressBad() { + const h = authHeaders(); + if (!h) return; + // Junk US zip + street → shippo validation fails → addressLog.Warn. + http.post( + `${BASE}/api/v1/addresses`, + JSON.stringify({ + name: 'Load Test', + street: 'asdfasdfasdf', + city: 'Nowhere', + state: 'XX', + zip: '00000', + country: 'US', + phone: '+15555550000', + }), + { headers: h, tags: { name: 'address_create_bad' } }, + ); +} + +export function authBad() { + http.post( + `${BASE}/api/v1/auth/token`, + JSON.stringify({ client_secret: 'definitely_wrong', fingerprint: 'SHA256:fake' }), + { headers: { 'Content-Type': 'application/json' }, tags: { name: 'auth_bad_creds' } }, + ); +} + +export function authLookup() { + const fp = `SHA256:${Math.random().toString(36).slice(2, 14)}`; + http.get(`${BASE}/api/v1/auth/user?fingerprint=${fp}`, { tags: { name: 'auth_user_lookup' } }); +} + +export function notFound() { + const id = 99000 + Math.floor(Math.random() * 1000); + http.get(`${BASE}/api/v1/products/${id}`, { tags: { name: 'product_not_found' } }); +} diff --git a/main.go b/main.go index f6126cd..d88edc0 100644 --- a/main.go +++ b/main.go @@ -22,10 +22,6 @@ import ( "terminalShop/pkg/tui" ) -const ( - port = "23456" -) - func listenHost() string { if h := os.Getenv("SSH_HOST"); h != "" { return h @@ -33,13 +29,6 @@ func listenHost() string { return "localhost" } -func listenPort() string { - if p := os.Getenv("SSH_PORT"); p != "" { - return p - } - return port -} - var ( apiURL string authFingerprintKey string @@ -78,7 +67,7 @@ func main() { // Create SSH server host := listenHost() - p := listenPort() + p := cfg.SSHPort s, err := wish.NewServer( wish.WithAddress(fmt.Sprintf("%s:%s", host, p)), wish.WithHostKeyPath(".ssh/term_info_ed25519"), diff --git a/pkg/tui/confirmation.go b/pkg/tui/confirmation.go index 4151ec3..bf0e1cc 100644 --- a/pkg/tui/confirmation.go +++ b/pkg/tui/confirmation.go @@ -155,7 +155,7 @@ func (m Model) generateReviewContent() string { view.WriteString(fmt.Sprintf("subtotal: %s\n", formatUSD(subtotal))) view.WriteString(m.theme.TextAccent().Render(fmt.Sprintf("total: %s", formatUSD(subtotal))) + "\n") view.WriteString("\n") - view.WriteString(m.theme.TextError().Render("press enter to confirm") + "\n") + view.WriteString(m.theme.TextBrand().Render("press enter to confirm") + "\n") return m.theme.Base().Padding(0, 1).Render(view.String()) }