Compare commits
9 commits
4d44e7045c
...
596754acce
| Author | SHA1 | Date | |
|---|---|---|---|
| 596754acce | |||
| 4442c57223 | |||
| 134aef6ab0 | |||
| 27e73e969a | |||
| a2f881e805 | |||
| a6304e1c23 | |||
| 678fdfe388 | |||
| bb3ec5ec6a | |||
| a803afa101 |
|
|
@ -273,14 +273,6 @@ p a, blockquote a {
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
/* ---- Nuxt UI placeholder contrast ----
|
||||
Default Nuxt UI placeholder uses `text-dimmed` (#a6a09b) which fails WCAG
|
||||
AA on cream and white backgrounds (≈2.4:1). Override globally to --text-dim
|
||||
so USelect/USelectMenu placeholders meet the 4.5:1 ratio. */
|
||||
[data-slot="placeholder"] {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
/* ---- SHARED USelectMenu STYLES ----
|
||||
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" />
|
||||
Classes are global (not scoped) because Nuxt UI portals the popup content to body. */
|
||||
|
|
|
|||
|
|
@ -131,10 +131,12 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
|
|||
|
||||
const { data: wikiFeature } = await useFetch(
|
||||
"/api/site-content/homepage.wiki_feature",
|
||||
{ default: () => ({ title: "", body: "" }) },
|
||||
{ default: () => ({ title: "", body: "" }) }
|
||||
);
|
||||
|
||||
const hasCustomWikiFeature = computed(() => !!wikiFeature.value?.body?.trim());
|
||||
const hasCustomWikiFeature = computed(
|
||||
() => !!wikiFeature.value?.body?.trim()
|
||||
);
|
||||
|
||||
const customWikiParagraphs = computed(() => {
|
||||
const body = wikiFeature.value?.body?.trim() || "";
|
||||
|
|
@ -164,7 +166,7 @@ const circleData = [
|
|||
label: "Practitioner",
|
||||
metaphor: "The alcove",
|
||||
blurb:
|
||||
"Where experience is shared and knowledge given back. You're here to support newcomers, help shape the Cooperative Foundations program, and find peers.",
|
||||
"Where experience is shared and knowledge given back. You're here to teach, advise, mentor, and help shape the program itself. Alumni welcome.",
|
||||
},
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -64,37 +64,26 @@
|
|||
<!-- Left: Monthly Contribution -->
|
||||
<div class="join-col">
|
||||
<div class="section-label" style="margin-bottom: 12px">
|
||||
{{
|
||||
cadence === "annual"
|
||||
? "Annual Contribution"
|
||||
: "Monthly Contribution"
|
||||
}}
|
||||
{{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
|
||||
</div>
|
||||
<h2>Pay what you can</h2>
|
||||
<ul class="tier-list">
|
||||
<li><span class="tier-amt">$0</span> I need support right now</li>
|
||||
<li><span class="tier-amt">{{ formatContributionAmount(5) }}</span> I can contribute</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I
|
||||
can contribute
|
||||
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
|
||||
(suggested)
|
||||
</li>
|
||||
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I
|
||||
can sustain the community (suggested)
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(30) }}</span> I
|
||||
can support others too
|
||||
</li>
|
||||
<li>
|
||||
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I
|
||||
want to sponsor multiple members
|
||||
<span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
|
||||
members
|
||||
</li>
|
||||
</ul>
|
||||
<p class="charity-note">
|
||||
Baby Ghosts Studio Development Fund is a registered Canadian
|
||||
charity. Members who file Canadian taxes can claim their
|
||||
contributions. We'll help you set up tax receipts once you've
|
||||
joined.
|
||||
Baby Ghosts Studio Development Fund is a registered Canadian charity.
|
||||
Members who file Canadian taxes can claim their contributions.
|
||||
We'll help you set up tax receipts once you've joined.
|
||||
</p>
|
||||
<p class="solidarity-note">
|
||||
Pay what you can. If you can pay more, you're making room for
|
||||
|
|
@ -129,7 +118,7 @@
|
|||
type="text"
|
||||
placeholder="Your name"
|
||||
required
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label" for="join-email">Email Address</label>
|
||||
|
|
@ -140,7 +129,7 @@
|
|||
type="email"
|
||||
placeholder="you@example.com"
|
||||
required
|
||||
/>
|
||||
>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="form-label">Circle</label>
|
||||
|
|
@ -152,7 +141,7 @@
|
|||
type="radio"
|
||||
name="circle"
|
||||
value="community"
|
||||
/>
|
||||
>
|
||||
<label for="circle-community">
|
||||
<span
|
||||
class="circle-label-name"
|
||||
|
|
@ -169,7 +158,7 @@
|
|||
type="radio"
|
||||
name="circle"
|
||||
value="founder"
|
||||
/>
|
||||
>
|
||||
<label for="circle-founder">
|
||||
<span
|
||||
class="circle-label-name"
|
||||
|
|
@ -186,7 +175,7 @@
|
|||
type="radio"
|
||||
name="circle"
|
||||
value="practitioner"
|
||||
/>
|
||||
>
|
||||
<label for="circle-practitioner">
|
||||
<span
|
||||
class="circle-label-name"
|
||||
|
|
@ -208,7 +197,7 @@
|
|||
type="radio"
|
||||
name="cadence"
|
||||
value="monthly"
|
||||
/>
|
||||
>
|
||||
<label for="cadence-monthly">
|
||||
<span class="circle-label-name">Per Month</span>
|
||||
</label>
|
||||
|
|
@ -220,7 +209,7 @@
|
|||
type="radio"
|
||||
name="cadence"
|
||||
value="annual"
|
||||
/>
|
||||
>
|
||||
<label for="cadence-annual">
|
||||
<span class="circle-label-name">Per Year</span>
|
||||
</label>
|
||||
|
|
@ -241,13 +230,9 @@
|
|||
step="1"
|
||||
inputmode="numeric"
|
||||
class="contribution-input"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="contribution-presets"
|
||||
role="group"
|
||||
aria-label="Suggested amounts"
|
||||
>
|
||||
</div>
|
||||
<div class="contribution-presets" role="group" aria-label="Suggested amounts">
|
||||
<button
|
||||
v-for="preset in CONTRIBUTION_PRESETS"
|
||||
:key="preset.amount"
|
||||
|
|
@ -258,30 +243,24 @@
|
|||
${{ preset.amount }}
|
||||
</button>
|
||||
</div>
|
||||
<p v-if="guidanceLabel" class="contribution-guidance">
|
||||
{{ guidanceLabel }}
|
||||
</p>
|
||||
<p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
|
||||
</div>
|
||||
<div v-if="form.contributionAmount > 0" class="form-group">
|
||||
<div class="billing-summary">
|
||||
<p class="billing-summary-line">
|
||||
You'll be charged <strong>${{ firstCharge }} today</strong
|
||||
><span v-if="cadence === 'annual'">
|
||||
(${{ form.contributionAmount }}/month × 12)</span
|
||||
>.
|
||||
You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month × 12)</span>.
|
||||
</p>
|
||||
<p class="billing-summary-line">
|
||||
Then
|
||||
<strong
|
||||
>${{ firstCharge }} every
|
||||
{{ cadence === "annual" ? "year" : "month" }}</strong
|
||||
>, until you cancel.
|
||||
Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group full-width">
|
||||
<label class="checkbox-label">
|
||||
<input v-model="form.agreedToGuidelines" type="checkbox" />
|
||||
<input
|
||||
v-model="form.agreedToGuidelines"
|
||||
type="checkbox"
|
||||
>
|
||||
<span>
|
||||
I agree to the Ghost Guild
|
||||
<NuxtLink to="/community-guidelines" target="_blank"
|
||||
|
|
@ -359,11 +338,12 @@
|
|||
<h2>Practicing</h2>
|
||||
<p>
|
||||
For those already running cooperative studios or with deep
|
||||
experience in cooperative practice. You're here to support newcomers
|
||||
and help shape the Cooperative Foundations program.
|
||||
experience in cooperative practice. You are here to teach, advise,
|
||||
mentor, and help shape the program itself. Alumni.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
<!-- Flow overlay: covers the page from form submit through redirect.
|
||||
|
|
@ -454,8 +434,7 @@ const isFormValid = computed(() => {
|
|||
form.name &&
|
||||
form.email &&
|
||||
form.circle &&
|
||||
Number.isInteger(form.contributionAmount) &&
|
||||
form.contributionAmount >= 0 &&
|
||||
Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
|
||||
form.agreedToGuidelines
|
||||
);
|
||||
});
|
||||
|
|
@ -851,7 +830,7 @@ onUnmounted(() => {
|
|||
padding: 0.5rem 0.75rem;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--parch);
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.contribution-input:focus {
|
||||
|
|
@ -868,7 +847,7 @@ onUnmounted(() => {
|
|||
padding: 0.25rem 0.75rem;
|
||||
background: transparent;
|
||||
border: 1px dashed var(--parch);
|
||||
font-family: "Commit Mono", monospace;
|
||||
font-family: 'Commit Mono', monospace;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -1038,7 +1017,6 @@ onUnmounted(() => {
|
|||
.checkbox-label a,
|
||||
.checkbox-label :deep(a) {
|
||||
color: var(--candle);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ---- ERROR & SUCCESS BOXES ---- */
|
||||
|
|
@ -1148,4 +1126,5 @@ onUnmounted(() => {
|
|||
align-items: stretch;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -712,6 +712,10 @@ useHead({
|
|||
|
||||
.posts-empty-link {
|
||||
color: var(--candle);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.posts-empty-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 315 KiB After Width: | Height: | Size: 323 KiB |
|
Before Width: | Height: | Size: 167 KiB After Width: | Height: | Size: 166 KiB |
|
Before Width: | Height: | Size: 168 KiB After Width: | Height: | Size: 168 KiB |
|
Before Width: | Height: | Size: 237 KiB After Width: | Height: | Size: 247 KiB |
|
Before Width: | Height: | Size: 290 KiB After Width: | Height: | Size: 279 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 201 KiB After Width: | Height: | Size: 160 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 181 KiB After Width: | Height: | Size: 236 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 121 KiB |
|
Before Width: | Height: | Size: 287 KiB After Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 142 KiB After Width: | Height: | Size: 148 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 154 KiB |
|
Before Width: | Height: | Size: 267 KiB After Width: | Height: | Size: 297 KiB |
|
Before Width: | Height: | Size: 244 KiB After Width: | Height: | Size: 251 KiB |
|
Before Width: | Height: | Size: 138 KiB After Width: | Height: | Size: 135 KiB |
|
Before Width: | Height: | Size: 253 KiB After Width: | Height: | Size: 343 KiB |
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 132 KiB |
|
Before Width: | Height: | Size: 282 KiB After Width: | Height: | Size: 203 KiB |
|
Before Width: | Height: | Size: 194 KiB After Width: | Height: | Size: 195 KiB |
|
Before Width: | Height: | Size: 154 KiB After Width: | Height: | Size: 48 KiB |
|
|
@ -11,7 +11,6 @@ test.describe('Admin board channels page', () => {
|
|||
|
||||
test('create, edit, and delete a channel', async ({ adminPage }) => {
|
||||
await adminPage.goto('/admin/board-channels')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -19,14 +18,14 @@ test.describe('Admin board channels page', () => {
|
|||
const suffix = Date.now().toString().slice(-6)
|
||||
const channelName = `e2e-channel-${suffix}`
|
||||
const editedName = `e2e-channel-${suffix}-edited`
|
||||
const slackId = `C${suffix}XYZ`
|
||||
|
||||
// --- Create ---
|
||||
// Create flow only takes a name; the Slack channel ID is auto-assigned on
|
||||
// creation and only becomes editable in the Edit modal.
|
||||
await adminPage.getByRole('button', { name: '+ New Channel' }).click()
|
||||
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible()
|
||||
|
||||
await adminPage.locator('input[placeholder="e.g., coop-formation"]').fill(channelName)
|
||||
await adminPage.locator('input[placeholder="e.g., #coop-formation"]').fill(channelName)
|
||||
await adminPage.locator('input[placeholder="C0123456789"]').fill(slackId)
|
||||
|
||||
// Select the first available cooperative tag if any are present
|
||||
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first()
|
||||
|
|
@ -45,7 +44,7 @@ test.describe('Admin board channels page', () => {
|
|||
await row.getByRole('button', { name: 'Edit' }).click()
|
||||
|
||||
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible()
|
||||
const nameInput = adminPage.locator('input[placeholder="e.g., coop-formation"]')
|
||||
const nameInput = adminPage.locator('input[placeholder="e.g., #coop-formation"]')
|
||||
await nameInput.fill(editedName)
|
||||
await adminPage.getByRole('button', { name: 'Save Changes' }).click()
|
||||
|
||||
|
|
|
|||
|
|
@ -44,7 +44,6 @@ test.describe('Authentication flows', () => {
|
|||
test('logout clears auth', async ({ page }) => {
|
||||
await loginAsAdmin(page)
|
||||
await page.goto('/admin')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.locator('.admin-tag')).toBeVisible({ timeout: 15000 })
|
||||
|
||||
// Set up response listener BEFORE clicking to avoid race
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ test.describe('Board page', () => {
|
|||
|
||||
test('clicking New Post reveals the form', async ({ memberPage }) => {
|
||||
await memberPage.goto('/board')
|
||||
await memberPage.waitForLoadState('networkidle')
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -41,7 +40,6 @@ test.describe('Board page', () => {
|
|||
|
||||
test('create, edit, and delete own post', async ({ memberPage }) => {
|
||||
await memberPage.goto('/board')
|
||||
await memberPage.waitForLoadState('networkidle')
|
||||
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
|
||||
timeout: 15000,
|
||||
})
|
||||
|
|
@ -57,7 +55,7 @@ test.describe('Board page', () => {
|
|||
await memberPage.locator('#post-title').fill(originalTitle)
|
||||
await memberPage.locator('#post-seeking').fill('Playwright test seeking text')
|
||||
|
||||
await memberPage.getByRole('button', { name: 'Post', exact: true }).click()
|
||||
await memberPage.getByRole('button', { name: 'Post' }).click()
|
||||
|
||||
await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
|
||||
timeout: 10000,
|
||||
|
|
@ -77,10 +75,10 @@ test.describe('Board page', () => {
|
|||
timeout: 10000,
|
||||
})
|
||||
|
||||
// --- Delete (in-card two-step confirm; not a native dialog) ---
|
||||
// --- Delete (confirm dialog) ---
|
||||
memberPage.once('dialog', (dialog) => dialog.accept())
|
||||
const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
|
||||
await editedCard.getByRole('button', { name: 'Delete' }).click()
|
||||
await editedCard.getByRole('button', { name: 'Confirm' }).click()
|
||||
|
||||
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
|
||||
timeout: 10000,
|
||||
|
|
|
|||
|
|
@ -68,12 +68,8 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.locator('#join-name').fill('Test User')
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
|
||||
// Fill email — agreement still unchecked, so still disabled
|
||||
// Fill email too — now all fields are populated and button should be enabled
|
||||
await page.locator('#join-email').fill('incomplete-test@example.com')
|
||||
await expect(page.locator('.form-submit')).toBeDisabled()
|
||||
|
||||
// Check the Community Guidelines agreement — now all required fields satisfied
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
})
|
||||
|
||||
|
|
@ -87,9 +83,8 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.locator('#join-name').fill('E2E Test User')
|
||||
await page.locator('#join-email').fill(uniqueEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
// Contribution is now a numeric input with preset chips, not a select
|
||||
await page.locator('#join-contribution').fill('0')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await page.locator('#join-contribution').click()
|
||||
await page.getByRole('option', { name: '$0/mo' }).click()
|
||||
|
||||
await expect(page.locator('.form-submit')).toBeEnabled()
|
||||
|
||||
|
|
@ -98,10 +93,8 @@ test.describe('Join page — member signup flow', () => {
|
|||
|
||||
await page.locator('.form-submit').click()
|
||||
|
||||
// Free tier flips the SignupFlowOverlay into its success state
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
|
||||
).toBeVisible({ timeout: 15000 })
|
||||
// Free tier creates subscription then shows confirmation (step 3)
|
||||
await expect(page.locator('.success-box')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('duplicate email shows error', async ({ page }) => {
|
||||
|
|
@ -116,13 +109,12 @@ test.describe('Join page — member signup flow', () => {
|
|||
await page.locator('#join-name').fill('Dup Test User')
|
||||
await page.locator('#join-email').fill(duplicateEmail)
|
||||
await page.locator('#circle-community').check({ force: true })
|
||||
await page.locator('#join-contribution').fill('0')
|
||||
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
|
||||
await page.locator('#join-contribution').click()
|
||||
await page.getByRole('option', { name: '$0/mo' }).click()
|
||||
await page.locator('.form-submit').click()
|
||||
|
||||
// Helcim 409 puts SignupFlowOverlay into its error state
|
||||
const overlayError = page.locator('.signup-flow-overlay .error-box')
|
||||
await expect(overlayError).toBeVisible({ timeout: 10000 })
|
||||
await expect(overlayError).toContainText(/already/i)
|
||||
// Should show an error about the email already existing
|
||||
await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
|
||||
await expect(page.locator('.error-box')).toContainText(/already/i)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -3,11 +3,9 @@ import { test, expect } from './helpers/fixtures.js'
|
|||
test.describe('Member profile page', () => {
|
||||
test('profile page loads', async ({ adminPage }) => {
|
||||
await adminPage.goto('/member/profile')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
// Auth is checked client-side in onMounted — wait for profile form to render
|
||||
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
|
||||
// Verify a stable structural section label, not transient marketing copy
|
||||
await expect(adminPage.getByText('Show in Member Directory')).toBeVisible()
|
||||
await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
|
||||
})
|
||||
|
||||
test('form fields are present', async ({ adminPage }) => {
|
||||
|
|
@ -26,7 +24,6 @@ test.describe('Member profile page', () => {
|
|||
|
||||
test('bio field accepts input', async ({ adminPage }) => {
|
||||
await adminPage.goto('/member/profile')
|
||||
await adminPage.waitForLoadState('networkidle')
|
||||
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
|
||||
|
||||
const bio = adminPage.locator('textarea[placeholder*="Share your background"]')
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import { RateLimiterMemory } from 'rate-limiter-flexible'
|
||||
|
||||
// Strict rate limit for auth endpoints
|
||||
const authLimiter = new RateLimiterMemory({
|
||||
points: 5, // 5 requests
|
||||
duration: 300, // per 5 minutes
|
||||
keyPrefix: 'rl_auth'
|
||||
})
|
||||
|
||||
// Moderate rate limit for payment endpoints
|
||||
const paymentLimiter = new RateLimiterMemory({
|
||||
points: 10,
|
||||
|
|
@ -28,6 +35,7 @@ function getClientIp(event) {
|
|||
|| 'unknown'
|
||||
}
|
||||
|
||||
const AUTH_PATHS = new Set(['/api/auth/login', '/api/auth/verify'])
|
||||
const PAYMENT_PREFIXES = ['/api/helcim/']
|
||||
const UPLOAD_PATHS = new Set(['/api/upload/image'])
|
||||
|
||||
|
|
@ -35,15 +43,12 @@ export default defineEventHandler(async (event) => {
|
|||
const path = getRequestURL(event).pathname
|
||||
if (!path.startsWith('/api/')) return
|
||||
|
||||
// Bypass rate limiting in test/dev opt-in mode so parallel E2E runs from a
|
||||
// single IP (127.0.0.1) do not exhaust the per-IP budget. Mirrors the gate
|
||||
// used by /api/dev/* endpoints — only set in development and by Playwright.
|
||||
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') return
|
||||
|
||||
const ip = getClientIp(event)
|
||||
|
||||
try {
|
||||
if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
|
||||
if (AUTH_PATHS.has(path)) {
|
||||
await authLimiter.consume(ip)
|
||||
} else if (PAYMENT_PREFIXES.some(p => path.startsWith(p))) {
|
||||
await paymentLimiter.consume(ip)
|
||||
} else if (UPLOAD_PATHS.has(path)) {
|
||||
await uploadLimiter.consume(ip)
|
||||
|
|
|
|||
|
|
@ -4,11 +4,6 @@
|
|||
const buckets = new Map()
|
||||
|
||||
export function rateLimit(key, { max, windowMs }) {
|
||||
// Bypass in test/dev opt-in mode so parallel E2E runs from a single IP
|
||||
// (127.0.0.1) don't exhaust per-IP/email budgets. Mirrors the gate used by
|
||||
// /api/dev/* endpoints and server/middleware/03.rate-limit.js.
|
||||
if (process.env.ALLOW_DEV_TEST_ENDPOINTS === 'true') return true
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
// Probabilistic sweep: ~1% of calls evict keys whose newest entry has fully
|
||||
|
|
|
|||
|
|
@ -18,6 +18,35 @@ describe('rate-limit middleware', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('auth endpoint limiting (5 per 5 min)', () => {
|
||||
it('allows 5 requests then blocks the 6th', async () => {
|
||||
const ip = '10.0.1.1'
|
||||
|
||||
// First 5 should succeed
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
remoteAddress: ip
|
||||
})
|
||||
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
|
||||
}
|
||||
|
||||
// 6th should be rate limited
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/auth/login',
|
||||
remoteAddress: ip
|
||||
})
|
||||
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
|
||||
// Check Retry-After header was set
|
||||
expect(event._testSetHeaders['retry-after']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('payment endpoint limiting (10 per min)', () => {
|
||||
it('allows 10 requests then blocks the 11th', async () => {
|
||||
const ip = '10.0.2.1'
|
||||
|
|
@ -39,19 +68,16 @@ describe('rate-limit middleware', () => {
|
|||
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
|
||||
statusCode: 429
|
||||
})
|
||||
|
||||
// Check Retry-After header was set
|
||||
expect(event._testSetHeaders['retry-after']).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('IP isolation', () => {
|
||||
it('different IPs have separate rate limit counters', async () => {
|
||||
// Exhaust payment limit for IP A
|
||||
for (let i = 0; i < 10; i++) {
|
||||
// Exhaust limit for IP A
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
path: '/api/auth/login',
|
||||
remoteAddress: '10.0.3.1'
|
||||
})
|
||||
await rateLimitMiddleware(event)
|
||||
|
|
@ -60,7 +86,7 @@ describe('rate-limit middleware', () => {
|
|||
// IP B should still be able to make requests
|
||||
const event = createMockEvent({
|
||||
method: 'POST',
|
||||
path: '/api/helcim/initialize-payment',
|
||||
path: '/api/auth/login',
|
||||
remoteAddress: '10.0.3.2'
|
||||
})
|
||||
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()
|
||||
|
|
|
|||