Compare commits

..

9 commits

35 changed files with 104 additions and 116 deletions

View file

@ -273,14 +273,6 @@ p a, blockquote a {
min-width: 0; 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 ---- /* ---- SHARED USelectMenu STYLES ----
Apply via: <USelectMenu class="zine-select" :ui="{ content: 'tz-content', item: 'tz-item', input: 'tz-input' }" /> 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. */ Classes are global (not scoped) because Nuxt UI portals the popup content to body. */

View file

@ -131,10 +131,12 @@ const DEFAULT_WIKI_FEATURE_TITLE = "What is a cooperative studio?";
const { data: wikiFeature } = await useFetch( const { data: wikiFeature } = await useFetch(
"/api/site-content/homepage.wiki_feature", "/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 customWikiParagraphs = computed(() => {
const body = wikiFeature.value?.body?.trim() || ""; const body = wikiFeature.value?.body?.trim() || "";
@ -164,7 +166,7 @@ const circleData = [
label: "Practitioner", label: "Practitioner",
metaphor: "The alcove", metaphor: "The alcove",
blurb: 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.",
}, },
]; ];

View file

@ -64,37 +64,26 @@
<!-- Left: Monthly Contribution --> <!-- Left: Monthly Contribution -->
<div class="join-col"> <div class="join-col">
<div class="section-label" style="margin-bottom: 12px"> <div class="section-label" style="margin-bottom: 12px">
{{ {{ cadence === 'annual' ? 'Annual Contribution' : 'Monthly Contribution' }}
cadence === "annual"
? "Annual Contribution"
: "Monthly Contribution"
}}
</div> </div>
<h2>Pay what you can</h2> <h2>Pay what you can</h2>
<ul class="tier-list"> <ul class="tier-list">
<li><span class="tier-amt">$0</span> I need support right now</li> <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> <li>
<span class="tier-amt">{{ formatContributionAmount(5) }}</span> I <span class="tier-amt">{{ formatContributionAmount(15) }}</span> I can sustain the community
can contribute (suggested)
</li> </li>
<li><span class="tier-amt">{{ formatContributionAmount(30) }}</span> I can support others too</li>
<li> <li>
<span class="tier-amt">{{ formatContributionAmount(15) }}</span> I <span class="tier-amt">{{ formatContributionAmount(50) }}</span> I want to sponsor multiple
can sustain the community (suggested) members
</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
</li> </li>
</ul> </ul>
<p class="charity-note"> <p class="charity-note">
Baby Ghosts Studio Development Fund is a registered Canadian Baby Ghosts Studio Development Fund is a registered Canadian charity.
charity. Members who file Canadian taxes can claim their Members who file Canadian taxes can claim their contributions.
contributions. We'll help you set up tax receipts once you've We'll help you set up tax receipts once you've joined.
joined.
</p> </p>
<p class="solidarity-note"> <p class="solidarity-note">
Pay what you can. If you can pay more, you're making room for Pay what you can. If you can pay more, you're making room for
@ -129,7 +118,7 @@
type="text" type="text"
placeholder="Your name" placeholder="Your name"
required required
/> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label" for="join-email">Email Address</label> <label class="form-label" for="join-email">Email Address</label>
@ -140,7 +129,7 @@
type="email" type="email"
placeholder="you@example.com" placeholder="you@example.com"
required required
/> >
</div> </div>
<div class="form-group"> <div class="form-group">
<label class="form-label">Circle</label> <label class="form-label">Circle</label>
@ -152,7 +141,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="community" value="community"
/> >
<label for="circle-community"> <label for="circle-community">
<span <span
class="circle-label-name" class="circle-label-name"
@ -169,7 +158,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="founder" value="founder"
/> >
<label for="circle-founder"> <label for="circle-founder">
<span <span
class="circle-label-name" class="circle-label-name"
@ -186,7 +175,7 @@
type="radio" type="radio"
name="circle" name="circle"
value="practitioner" value="practitioner"
/> >
<label for="circle-practitioner"> <label for="circle-practitioner">
<span <span
class="circle-label-name" class="circle-label-name"
@ -208,7 +197,7 @@
type="radio" type="radio"
name="cadence" name="cadence"
value="monthly" value="monthly"
/> >
<label for="cadence-monthly"> <label for="cadence-monthly">
<span class="circle-label-name">Per Month</span> <span class="circle-label-name">Per Month</span>
</label> </label>
@ -220,7 +209,7 @@
type="radio" type="radio"
name="cadence" name="cadence"
value="annual" value="annual"
/> >
<label for="cadence-annual"> <label for="cadence-annual">
<span class="circle-label-name">Per Year</span> <span class="circle-label-name">Per Year</span>
</label> </label>
@ -241,13 +230,9 @@
step="1" step="1"
inputmode="numeric" inputmode="numeric"
class="contribution-input" class="contribution-input"
/> >
</div> </div>
<div <div class="contribution-presets" role="group" aria-label="Suggested amounts">
class="contribution-presets"
role="group"
aria-label="Suggested amounts"
>
<button <button
v-for="preset in CONTRIBUTION_PRESETS" v-for="preset in CONTRIBUTION_PRESETS"
:key="preset.amount" :key="preset.amount"
@ -258,30 +243,24 @@
${{ preset.amount }} ${{ preset.amount }}
</button> </button>
</div> </div>
<p v-if="guidanceLabel" class="contribution-guidance"> <p v-if="guidanceLabel" class="contribution-guidance">{{ guidanceLabel }}</p>
{{ guidanceLabel }}
</p>
</div> </div>
<div v-if="form.contributionAmount > 0" class="form-group"> <div v-if="form.contributionAmount > 0" class="form-group">
<div class="billing-summary"> <div class="billing-summary">
<p class="billing-summary-line"> <p class="billing-summary-line">
You'll be charged <strong>${{ firstCharge }} today</strong You'll be charged <strong>${{ firstCharge }} today</strong><span v-if="cadence === 'annual'"> (${{ form.contributionAmount }}/month &times; 12)</span>.
><span v-if="cadence === 'annual'">
(${{ form.contributionAmount }}/month &times; 12)</span
>.
</p> </p>
<p class="billing-summary-line"> <p class="billing-summary-line">
Then Then <strong>${{ firstCharge }} every {{ cadence === 'annual' ? 'year' : 'month' }}</strong>, until you cancel.
<strong
>${{ firstCharge }} every
{{ cadence === "annual" ? "year" : "month" }}</strong
>, until you cancel.
</p> </p>
</div> </div>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label class="checkbox-label"> <label class="checkbox-label">
<input v-model="form.agreedToGuidelines" type="checkbox" /> <input
v-model="form.agreedToGuidelines"
type="checkbox"
>
<span> <span>
I agree to the Ghost Guild I agree to the Ghost Guild
<NuxtLink to="/community-guidelines" target="_blank" <NuxtLink to="/community-guidelines" target="_blank"
@ -359,11 +338,12 @@
<h2>Practicing</h2> <h2>Practicing</h2>
<p> <p>
For those already running cooperative studios or with deep For those already running cooperative studios or with deep
experience in cooperative practice. You're here to support newcomers experience in cooperative practice. You are here to teach, advise,
and help shape the Cooperative Foundations program. mentor, and help shape the program itself. Alumni.
</p> </p>
</div> </div>
</div> </div>
</template> </template>
<!-- Flow overlay: covers the page from form submit through redirect. <!-- Flow overlay: covers the page from form submit through redirect.
@ -454,8 +434,7 @@ const isFormValid = computed(() => {
form.name && form.name &&
form.email && form.email &&
form.circle && form.circle &&
Number.isInteger(form.contributionAmount) && Number.isInteger(form.contributionAmount) && form.contributionAmount >= 0 &&
form.contributionAmount >= 0 &&
form.agreedToGuidelines form.agreedToGuidelines
); );
}); });
@ -851,7 +830,7 @@ onUnmounted(() => {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
background: var(--input-bg); background: var(--input-bg);
border: 1px solid var(--parch); border: 1px solid var(--parch);
font-family: "Commit Mono", monospace; font-family: 'Commit Mono', monospace;
font-size: 1rem; font-size: 1rem;
} }
.contribution-input:focus { .contribution-input:focus {
@ -868,7 +847,7 @@ onUnmounted(() => {
padding: 0.25rem 0.75rem; padding: 0.25rem 0.75rem;
background: transparent; background: transparent;
border: 1px dashed var(--parch); border: 1px dashed var(--parch);
font-family: "Commit Mono", monospace; font-family: 'Commit Mono', monospace;
font-size: 0.875rem; font-size: 0.875rem;
cursor: pointer; cursor: pointer;
} }
@ -1038,7 +1017,6 @@ onUnmounted(() => {
.checkbox-label a, .checkbox-label a,
.checkbox-label :deep(a) { .checkbox-label :deep(a) {
color: var(--candle); color: var(--candle);
text-decoration: underline;
} }
/* ---- ERROR & SUCCESS BOXES ---- */ /* ---- ERROR & SUCCESS BOXES ---- */
@ -1148,4 +1126,5 @@ onUnmounted(() => {
align-items: stretch; align-items: stretch;
} }
} }
</style> </style>

View file

@ -712,6 +712,10 @@ useHead({
.posts-empty-link { .posts-empty-link {
color: var(--candle); color: var(--candle);
text-decoration: none;
}
.posts-empty-link:hover {
text-decoration: underline; text-decoration: underline;
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 315 KiB

After

Width:  |  Height:  |  Size: 323 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 167 KiB

After

Width:  |  Height:  |  Size: 166 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 168 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 237 KiB

After

Width:  |  Height:  |  Size: 247 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 290 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 15 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 201 KiB

After

Width:  |  Height:  |  Size: 160 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 51 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 236 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 121 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 287 KiB

After

Width:  |  Height:  |  Size: 285 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 148 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 280 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 154 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 267 KiB

After

Width:  |  Height:  |  Size: 297 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 244 KiB

After

Width:  |  Height:  |  Size: 251 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 343 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 132 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 282 KiB

After

Width:  |  Height:  |  Size: 203 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 194 KiB

After

Width:  |  Height:  |  Size: 195 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 154 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Before After
Before After

View file

@ -11,7 +11,6 @@ test.describe('Admin board channels page', () => {
test('create, edit, and delete a channel', async ({ adminPage }) => { test('create, edit, and delete a channel', async ({ adminPage }) => {
await adminPage.goto('/admin/board-channels') await adminPage.goto('/admin/board-channels')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({ await expect(adminPage.getByRole('heading', { name: 'Board Channels' })).toBeVisible({
timeout: 15000, timeout: 15000,
}) })
@ -19,14 +18,14 @@ test.describe('Admin board channels page', () => {
const suffix = Date.now().toString().slice(-6) const suffix = Date.now().toString().slice(-6)
const channelName = `e2e-channel-${suffix}` const channelName = `e2e-channel-${suffix}`
const editedName = `e2e-channel-${suffix}-edited` const editedName = `e2e-channel-${suffix}-edited`
const slackId = `C${suffix}XYZ`
// --- Create --- // --- 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 adminPage.getByRole('button', { name: '+ New Channel' }).click()
await expect(adminPage.getByRole('heading', { name: 'New Channel' })).toBeVisible() 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 // Select the first available cooperative tag if any are present
const firstTagCheckbox = adminPage.locator('.tag-select input[type="checkbox"]').first() 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 row.getByRole('button', { name: 'Edit' }).click()
await expect(adminPage.getByRole('heading', { name: 'Edit Channel' })).toBeVisible() 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 nameInput.fill(editedName)
await adminPage.getByRole('button', { name: 'Save Changes' }).click() await adminPage.getByRole('button', { name: 'Save Changes' }).click()

View file

@ -44,7 +44,6 @@ test.describe('Authentication flows', () => {
test('logout clears auth', async ({ page }) => { test('logout clears auth', async ({ page }) => {
await loginAsAdmin(page) await loginAsAdmin(page)
await page.goto('/admin') await page.goto('/admin')
await page.waitForLoadState('networkidle')
await expect(page.locator('.admin-tag')).toBeVisible({ timeout: 15000 }) await expect(page.locator('.admin-tag')).toBeVisible({ timeout: 15000 })
// Set up response listener BEFORE clicking to avoid race // Set up response listener BEFORE clicking to avoid race

View file

@ -9,7 +9,6 @@ test.describe('Board page', () => {
test('clicking New Post reveals the form', async ({ memberPage }) => { test('clicking New Post reveals the form', async ({ memberPage }) => {
await memberPage.goto('/board') await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
timeout: 15000, timeout: 15000,
}) })
@ -41,7 +40,6 @@ test.describe('Board page', () => {
test('create, edit, and delete own post', async ({ memberPage }) => { test('create, edit, and delete own post', async ({ memberPage }) => {
await memberPage.goto('/board') await memberPage.goto('/board')
await memberPage.waitForLoadState('networkidle')
await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({ await expect(memberPage.getByRole('button', { name: '+ New Post' }).first()).toBeVisible({
timeout: 15000, timeout: 15000,
}) })
@ -57,7 +55,7 @@ test.describe('Board page', () => {
await memberPage.locator('#post-title').fill(originalTitle) await memberPage.locator('#post-title').fill(originalTitle)
await memberPage.locator('#post-seeking').fill('Playwright test seeking text') 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({ await expect(memberPage.getByRole('heading', { name: originalTitle })).toBeVisible({
timeout: 10000, timeout: 10000,
@ -77,10 +75,10 @@ test.describe('Board page', () => {
timeout: 10000, 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 }) const editedCard = memberPage.locator('article.board-post', { hasText: editedTitle })
await editedCard.getByRole('button', { name: 'Delete' }).click() await editedCard.getByRole('button', { name: 'Delete' }).click()
await editedCard.getByRole('button', { name: 'Confirm' }).click()
await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({ await expect(memberPage.getByRole('heading', { name: editedTitle })).not.toBeVisible({
timeout: 10000, timeout: 10000,

View file

@ -68,12 +68,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('#join-name').fill('Test User') await page.locator('#join-name').fill('Test User')
await expect(page.locator('.form-submit')).toBeDisabled() 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 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() 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-name').fill('E2E Test User')
await page.locator('#join-email').fill(uniqueEmail) await page.locator('#join-email').fill(uniqueEmail)
await page.locator('#circle-community').check({ force: true }) 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').click()
await page.locator('#join-contribution').fill('0') await page.getByRole('option', { name: '$0/mo' }).click()
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check()
await expect(page.locator('.form-submit')).toBeEnabled() await expect(page.locator('.form-submit')).toBeEnabled()
@ -98,10 +93,8 @@ test.describe('Join page — member signup flow', () => {
await page.locator('.form-submit').click() await page.locator('.form-submit').click()
// Free tier flips the SignupFlowOverlay into its success state // Free tier creates subscription then shows confirmation (step 3)
await expect( await expect(page.locator('.success-box')).toBeVisible({ timeout: 15000 })
page.getByRole('heading', { name: 'Welcome to Ghost Guild!' })
).toBeVisible({ timeout: 15000 })
}) })
test('duplicate email shows error', async ({ page }) => { 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-name').fill('Dup Test User')
await page.locator('#join-email').fill(duplicateEmail) await page.locator('#join-email').fill(duplicateEmail)
await page.locator('#circle-community').check({ force: true }) await page.locator('#circle-community').check({ force: true })
await page.locator('#join-contribution').fill('0') await page.locator('#join-contribution').click()
await page.getByRole('checkbox', { name: /Community Guidelines/ }).check() await page.getByRole('option', { name: '$0/mo' }).click()
await page.locator('.form-submit').click() await page.locator('.form-submit').click()
// Helcim 409 puts SignupFlowOverlay into its error state // Should show an error about the email already existing
const overlayError = page.locator('.signup-flow-overlay .error-box') await expect(page.locator('.error-box')).toBeVisible({ timeout: 10000 })
await expect(overlayError).toBeVisible({ timeout: 10000 }) await expect(page.locator('.error-box')).toContainText(/already/i)
await expect(overlayError).toContainText(/already/i)
}) })
}) })

View file

@ -3,11 +3,9 @@ import { test, expect } from './helpers/fixtures.js'
test.describe('Member profile page', () => { test.describe('Member profile page', () => {
test('profile page loads', async ({ adminPage }) => { test('profile page loads', async ({ adminPage }) => {
await adminPage.goto('/member/profile') await adminPage.goto('/member/profile')
await adminPage.waitForLoadState('networkidle')
// Auth is checked client-side in onMounted — wait for profile form to render // Auth is checked client-side in onMounted — wait for profile form to render
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 }) await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
// Verify a stable structural section label, not transient marketing copy await expect(adminPage.getByText('How you appear to other members')).toBeVisible()
await expect(adminPage.getByText('Show in Member Directory')).toBeVisible()
}) })
test('form fields are present', async ({ adminPage }) => { test('form fields are present', async ({ adminPage }) => {
@ -26,7 +24,6 @@ test.describe('Member profile page', () => {
test('bio field accepts input', async ({ adminPage }) => { test('bio field accepts input', async ({ adminPage }) => {
await adminPage.goto('/member/profile') await adminPage.goto('/member/profile')
await adminPage.waitForLoadState('networkidle')
await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 }) await expect(adminPage.getByText('Edit Profile')).toBeVisible({ timeout: 15000 })
const bio = adminPage.locator('textarea[placeholder*="Share your background"]') const bio = adminPage.locator('textarea[placeholder*="Share your background"]')

View file

@ -1,5 +1,12 @@
import { RateLimiterMemory } from 'rate-limiter-flexible' 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 // Moderate rate limit for payment endpoints
const paymentLimiter = new RateLimiterMemory({ const paymentLimiter = new RateLimiterMemory({
points: 10, points: 10,
@ -28,6 +35,7 @@ function getClientIp(event) {
|| 'unknown' || 'unknown'
} }
const AUTH_PATHS = new Set(['/api/auth/login', '/api/auth/verify'])
const PAYMENT_PREFIXES = ['/api/helcim/'] const PAYMENT_PREFIXES = ['/api/helcim/']
const UPLOAD_PATHS = new Set(['/api/upload/image']) const UPLOAD_PATHS = new Set(['/api/upload/image'])
@ -35,15 +43,12 @@ export default defineEventHandler(async (event) => {
const path = getRequestURL(event).pathname const path = getRequestURL(event).pathname
if (!path.startsWith('/api/')) return 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) const ip = getClientIp(event)
try { 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) await paymentLimiter.consume(ip)
} else if (UPLOAD_PATHS.has(path)) { } else if (UPLOAD_PATHS.has(path)) {
await uploadLimiter.consume(ip) await uploadLimiter.consume(ip)

View file

@ -4,11 +4,6 @@
const buckets = new Map() const buckets = new Map()
export function rateLimit(key, { max, windowMs }) { 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() const now = Date.now()
// Probabilistic sweep: ~1% of calls evict keys whose newest entry has fully // Probabilistic sweep: ~1% of calls evict keys whose newest entry has fully

View file

@ -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)', () => { describe('payment endpoint limiting (10 per min)', () => {
it('allows 10 requests then blocks the 11th', async () => { it('allows 10 requests then blocks the 11th', async () => {
const ip = '10.0.2.1' const ip = '10.0.2.1'
@ -39,19 +68,16 @@ describe('rate-limit middleware', () => {
await expect(rateLimitMiddleware(event)).rejects.toMatchObject({ await expect(rateLimitMiddleware(event)).rejects.toMatchObject({
statusCode: 429 statusCode: 429
}) })
// Check Retry-After header was set
expect(event._testSetHeaders['retry-after']).toBeDefined()
}) })
}) })
describe('IP isolation', () => { describe('IP isolation', () => {
it('different IPs have separate rate limit counters', async () => { it('different IPs have separate rate limit counters', async () => {
// Exhaust payment limit for IP A // Exhaust limit for IP A
for (let i = 0; i < 10; i++) { for (let i = 0; i < 5; i++) {
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/helcim/initialize-payment', path: '/api/auth/login',
remoteAddress: '10.0.3.1' remoteAddress: '10.0.3.1'
}) })
await rateLimitMiddleware(event) await rateLimitMiddleware(event)
@ -60,7 +86,7 @@ describe('rate-limit middleware', () => {
// IP B should still be able to make requests // IP B should still be able to make requests
const event = createMockEvent({ const event = createMockEvent({
method: 'POST', method: 'POST',
path: '/api/helcim/initialize-payment', path: '/api/auth/login',
remoteAddress: '10.0.3.2' remoteAddress: '10.0.3.2'
}) })
await expect(rateLimitMiddleware(event)).resolves.toBeUndefined() await expect(rateLimitMiddleware(event)).resolves.toBeUndefined()