This commit is contained in:
2026-05-02 11:28:28 +02:00
parent 9a5fff81bf
commit 327bb9c867
4 changed files with 227 additions and 71 deletions

View File

@@ -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",
})
}

225
loadtest/overnight.js Normal file
View File

@@ -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' } });
}

13
main.go
View File

@@ -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"),

View File

@@ -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())
}