Skip to content

Commit 67d09d5

Browse files
authored
UseFetcher action issue (#13)
1 parent 304f819 commit 67d09d5

9 files changed

Lines changed: 372 additions & 176 deletions

File tree

e2e/account.spec.ts

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,26 @@
1-
import { expect, test } from '@playwright/test'
1+
import { expect, test, type APIRequestContext } from '@playwright/test'
2+
3+
const testUser = { email: 'user@example.com', password: 'password123' }
4+
5+
async function ensureUserExists(request: APIRequestContext) {
6+
const response = await request.post('/auth', {
7+
data: { ...testUser, mode: 'signup' },
8+
headers: { 'Content-Type': 'application/json' },
9+
})
10+
if (response.ok() || response.status() === 409) {
11+
return
12+
}
13+
throw new Error(`Failed to seed user (${response.status()}).`)
14+
}
15+
16+
test.beforeEach(async ({ page }) => {
17+
await ensureUserExists(page.request)
18+
await page.context().clearCookies()
19+
})
220

321
test('redirects unauthenticated account to login with redirectTo', async ({
422
page,
523
}) => {
6-
await page.context().clearCookies()
724
await page.goto('/account')
825
await expect(page).toHaveURL(/\/login\?redirectTo=%2Faccount$/)
926
await expect(
@@ -13,8 +30,8 @@ test('redirects unauthenticated account to login with redirectTo', async ({
1330

1431
test('redirects authenticated user from login to account', async ({ page }) => {
1532
await page.goto('/login')
16-
await page.getByLabel('Email').fill('user@example.com')
17-
await page.getByLabel('Password').fill('password123')
33+
await page.getByLabel('Email').fill(testUser.email)
34+
await page.getByLabel('Password').fill(testUser.password)
1835
await page.getByRole('button', { name: 'Sign in' }).click()
1936

2037
await expect(page).toHaveURL(/\/account$/)
@@ -25,8 +42,8 @@ test('redirects authenticated user from login to account', async ({ page }) => {
2542

2643
test('logs out from the account page', async ({ page }) => {
2744
await page.goto('/login')
28-
await page.getByLabel('Email').fill('user@example.com')
29-
await page.getByLabel('Password').fill('password123')
45+
await page.getByLabel('Email').fill(testUser.email)
46+
await page.getByLabel('Password').fill(testUser.password)
3047
await page.getByRole('button', { name: 'Sign in' }).click()
3148

3249
await expect(page).toHaveURL(/\/account$/)

e2e/login.spec.ts

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,46 @@
1-
import { expect, test } from '@playwright/test'
1+
import { expect, test, type APIRequestContext } from '@playwright/test'
2+
3+
const testUser = { email: 'user@example.com', password: 'password123' }
4+
5+
async function ensureUserExists(request: APIRequestContext) {
6+
const response = await request.post('/auth', {
7+
data: { ...testUser, mode: 'signup' },
8+
headers: { 'Content-Type': 'application/json' },
9+
})
10+
if (response.ok() || response.status() === 409) {
11+
return
12+
}
13+
throw new Error(`Failed to seed user (${response.status()}).`)
14+
}
215

316
test('logs in with email and password', async ({ page }) => {
17+
await ensureUserExists(page.request)
18+
await page.context().clearCookies()
419
await page.goto('/login')
520

6-
await page.getByLabel('Email').fill('user@example.com')
7-
await page.getByLabel('Password').fill('password123')
21+
await page.getByLabel('Email').fill(testUser.email)
22+
await page.getByLabel('Password').fill(testUser.password)
823
await page.getByRole('button', { name: 'Sign in' }).click()
924

1025
await expect(page).toHaveURL(/\/account$/)
1126
await expect(
12-
page.getByRole('heading', { name: 'Welcome, user@example.com' }),
27+
page.getByRole('heading', { name: `Welcome, ${testUser.email}` }),
1328
).toBeVisible()
1429
})
1530

1631
test('signs up with email and password', async ({ page }) => {
32+
const signupUser = {
33+
email: `new-user-${crypto.randomUUID()}@example.com`,
34+
password: 'password123',
35+
}
1736
await page.goto('/signup')
1837

19-
await page.getByLabel('Email').fill('new-user@example.com')
20-
await page.getByLabel('Password').fill('password123')
38+
await page.getByLabel('Email').fill(signupUser.email)
39+
await page.getByLabel('Password').fill(signupUser.password)
2140
await page.getByRole('button', { name: 'Create account' }).click()
2241

2342
await expect(page).toHaveURL(/\/account$/)
2443
await expect(
25-
page.getByRole('heading', { name: 'Welcome, new-user@example.com' }),
44+
page.getByRole('heading', { name: `Welcome, ${signupUser.email}` }),
2645
).toBeVisible()
2746
})

server/app-env.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
let appDb: D1Database | null = null
2+
3+
export function setAppDb(db: D1Database) {
4+
appDb = db
5+
}
6+
7+
export function getAppDb() {
8+
if (!appDb) {
9+
throw new Error('APP_DB binding is not configured.')
10+
}
11+
return appDb
12+
}

server/handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
import { setAuthSessionSecret } from './auth-session.ts'
2+
import { setAppDb } from './app-env.ts'
23
import { getEnv } from './env.ts'
34
import router from './router.ts'
45

56
export async function handleRequest(request: Request, env: Env) {
67
try {
78
const appEnv = getEnv(env)
89
setAuthSessionSecret(appEnv.COOKIE_SECRET)
10+
setAppDb(env.APP_DB)
911
return await router.fetch(request)
1012
} catch (error) {
1113
console.error('Remix server handler failed:', error)

server/handlers/auth-handler.test.ts

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
/// <reference types="bun" />
2-
import { beforeAll, expect, test } from 'bun:test'
2+
import { beforeAll, beforeEach, expect, test } from 'bun:test'
33
import { RequestContext } from 'remix/fetch-router'
4+
import { setAppDb } from '../app-env.ts'
45
import { setAuthSessionSecret } from '../auth-session.ts'
6+
import { createPasswordHash } from '../password-hash.ts'
57
import auth from './auth.ts'
68

79
function createAuthRequest(body: unknown, url: string) {
@@ -17,10 +19,106 @@ function createAuthRequest(body: unknown, url: string) {
1719
}
1820
}
1921

22+
type TestUser = {
23+
id: number
24+
email: string
25+
username: string
26+
password_hash: string
27+
}
28+
29+
function createTestDb() {
30+
let nextId = 1
31+
const users = new Map<string, TestUser>()
32+
const db = {
33+
prepare(query: string) {
34+
return {
35+
bind(...params: Array<unknown>) {
36+
const normalizedQuery = query
37+
.replace(/\s+/g, ' ')
38+
.trim()
39+
.toLowerCase()
40+
return {
41+
async first() {
42+
if (normalizedQuery.startsWith('select id from users')) {
43+
const email = String(params[0] ?? '').toLowerCase()
44+
const user = users.get(email)
45+
return user ? { id: user.id } : null
46+
}
47+
if (
48+
normalizedQuery.startsWith(
49+
'select password_hash from users where email = ?',
50+
)
51+
) {
52+
const email = String(params[0] ?? '').toLowerCase()
53+
const user = users.get(email)
54+
return user ? { password_hash: user.password_hash } : null
55+
}
56+
return null
57+
},
58+
async run() {
59+
if (normalizedQuery.startsWith('insert into users')) {
60+
const [username, email, passwordHash] = params as Array<string>
61+
const normalizedEmail = String(email).toLowerCase()
62+
if (users.has(normalizedEmail)) {
63+
throw new Error('UNIQUE constraint failed: users.email')
64+
}
65+
const user: TestUser = {
66+
id: nextId,
67+
email: String(email),
68+
username: String(username),
69+
password_hash: String(passwordHash),
70+
}
71+
nextId += 1
72+
users.set(normalizedEmail, user)
73+
return { success: true }
74+
}
75+
if (
76+
normalizedQuery.startsWith(
77+
'update users set password_hash = ? where email = ?',
78+
)
79+
) {
80+
const [passwordHash, email] = params as Array<string>
81+
const user = users.get(String(email).toLowerCase())
82+
if (user) {
83+
user.password_hash = String(passwordHash)
84+
}
85+
return { success: true }
86+
}
87+
return { success: true }
88+
},
89+
}
90+
},
91+
}
92+
},
93+
} as unknown as D1Database
94+
95+
async function addUser(email: string, password: string) {
96+
const passwordHash = await createPasswordHash(password)
97+
const user: TestUser = {
98+
id: nextId,
99+
email,
100+
username: email,
101+
password_hash: passwordHash,
102+
}
103+
nextId += 1
104+
users.set(email.toLowerCase(), user)
105+
return user
106+
}
107+
108+
return { db, users, addUser }
109+
}
110+
111+
let testDb: ReturnType<typeof createTestDb>
112+
20113
beforeAll(() => {
21114
setAuthSessionSecret('test-cookie-secret')
22115
})
23116

117+
beforeEach(() => {
118+
testDb = createTestDb()
119+
setAppDb(testDb.db)
120+
})
121+
24122
test('auth handler returns 400 for invalid JSON', async () => {
25123
const authRequest = createAuthRequest('{', 'http://example.com/auth')
26124
const response = await authRequest.run()
@@ -42,7 +140,33 @@ test('auth handler returns 400 for missing fields', async () => {
42140
})
43141
})
44142

143+
test('auth handler rejects login with unknown user', async () => {
144+
const authRequest = createAuthRequest(
145+
{ email: 'a@b.com', password: 'secret', mode: 'login' },
146+
'http://example.com/auth',
147+
)
148+
const response = await authRequest.run()
149+
expect(response.status).toBe(401)
150+
const payload = await response.json()
151+
expect(payload).toEqual({ error: 'Invalid email or password.' })
152+
})
153+
154+
test('auth handler creates a user and cookie for signup', async () => {
155+
const authRequest = createAuthRequest(
156+
{ email: 'new@b.com', password: 'secret', mode: 'signup' },
157+
'http://example.com/auth',
158+
)
159+
const response = await authRequest.run()
160+
expect(response.status).toBe(200)
161+
const payload = await response.json()
162+
expect(payload).toEqual({ ok: true, mode: 'signup' })
163+
expect(testDb.users.has('new@b.com')).toBe(true)
164+
const setCookie = response.headers.get('Set-Cookie') ?? ''
165+
expect(setCookie).toContain('epicflare_session=')
166+
})
167+
45168
test('auth handler returns ok with a session cookie for login', async () => {
169+
await testDb.addUser('a@b.com', 'secret')
46170
const authRequest = createAuthRequest(
47171
{ email: 'a@b.com', password: 'secret', mode: 'login' },
48172
'http://example.com/auth',

server/handlers/auth.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { type BuildAction } from 'remix/fetch-router'
2+
import { z } from 'zod'
3+
import { createDb, sql } from '../../worker/db.ts'
24
import { createAuthCookie } from '../auth-session.ts'
5+
import { getAppDb } from '../app-env.ts'
6+
import { createPasswordHash, verifyPassword } from '../password-hash.ts'
37
import type routes from '../routes.ts'
48

59
type AuthMode = 'login' | 'signup'
@@ -18,6 +22,9 @@ function jsonResponse(data: unknown, init?: ResponseInit) {
1822
})
1923
}
2024

25+
const userIdSchema = z.object({ id: z.number() })
26+
const userPasswordSchema = z.object({ password_hash: z.string() })
27+
2128
export default {
2229
middleware: [],
2330
async action({ request, url }) {
@@ -34,7 +41,8 @@ export default {
3441
}
3542

3643
const { email, password, mode } = body as Record<string, unknown>
37-
const normalizedEmail = typeof email === 'string' ? email.trim() : ''
44+
const normalizedEmail =
45+
typeof email === 'string' ? email.trim().toLowerCase() : ''
3846
const normalizedPassword = typeof password === 'string' ? password : ''
3947
const normalizedMode =
4048
typeof mode === 'string' && isAuthMode(mode) ? mode : null
@@ -46,6 +54,54 @@ export default {
4654
)
4755
}
4856

57+
const db = createDb(getAppDb())
58+
59+
if (normalizedMode === 'signup') {
60+
const existingUser = await db.queryFirst(
61+
sql`SELECT id FROM users WHERE email = ${normalizedEmail}`,
62+
userIdSchema,
63+
)
64+
if (existingUser) {
65+
return jsonResponse({ error: 'Email already in use.' }, { status: 409 })
66+
}
67+
const passwordHash = await createPasswordHash(normalizedPassword)
68+
try {
69+
await db.exec(
70+
sql`INSERT INTO users (username, email, password_hash) VALUES (${normalizedEmail}, ${normalizedEmail}, ${passwordHash})`,
71+
)
72+
} catch {
73+
return jsonResponse(
74+
{ error: 'Unable to create account.' },
75+
{ status: 500 },
76+
)
77+
}
78+
}
79+
80+
if (normalizedMode === 'login') {
81+
const userRecord = await db.queryFirst(
82+
sql`SELECT password_hash FROM users WHERE email = ${normalizedEmail}`,
83+
userPasswordSchema,
84+
)
85+
const passwordCheck = userRecord
86+
? await verifyPassword(normalizedPassword, userRecord.password_hash)
87+
: null
88+
if (!userRecord || !passwordCheck?.valid) {
89+
return jsonResponse(
90+
{ error: 'Invalid email or password.' },
91+
{ status: 401 },
92+
)
93+
}
94+
if (passwordCheck.upgradedHash) {
95+
try {
96+
await db.exec(
97+
sql`UPDATE users SET password_hash = ${passwordCheck.upgradedHash} WHERE email = ${normalizedEmail}`,
98+
)
99+
} catch {
100+
// Ignore upgrade failures so valid logins still succeed.
101+
}
102+
}
103+
}
104+
49105
const cookie = await createAuthCookie(
50106
{
51107
id: crypto.randomUUID(),

0 commit comments

Comments
 (0)