Skip to content

Commit 8b1ddbb

Browse files
guo-yuclaude
andcommitted
Add E2E test: remote provider ↔ self-hosted Worker with AUTH_TOKEN
13 tests covering: - Worker AUTH_TOKEN enforcement (no token, wrong token, correct token) - /health always public - Remote provider: getEmails, getEmail, getCode, searchEmails - Mailbox isolation, direction filter - Read-only saveEmail protection Starts worker subprocess with .dev.vars, seeds D1, runs assertions, cleans up. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6c91abe commit 8b1ddbb

1 file changed

Lines changed: 204 additions & 0 deletions

File tree

test/e2e/remote-worker.test.ts

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
/**
2+
* E2E test: remote provider ↔ self-hosted Worker (open-source).
3+
*
4+
* Tests the full CLI light client flow:
5+
* CLI (remote provider) → Worker HTTP API (/api/*) → D1
6+
*
7+
* Also tests Worker AUTH_TOKEN enforcement.
8+
*
9+
* Prerequisites:
10+
* cd worker && bun install && npx wrangler d1 execute mails --local --file=schema.sql
11+
*
12+
* Run: bun test test/e2e/remote-worker.test.ts
13+
*/
14+
import { describe, expect, test, beforeAll, afterAll } from 'bun:test'
15+
import { spawn, type Subprocess } from 'bun'
16+
import { join } from 'path'
17+
import { createRemoteProvider } from '../../src/providers/storage/remote'
18+
import type { Email } from '../../src/core/types'
19+
import { execSync } from 'child_process'
20+
21+
const WORKER_DIR = join(import.meta.dir, '../../worker')
22+
const PORT = 3170 // Use a different port to avoid conflicts
23+
const API = `http://localhost:${PORT}`
24+
const AUTH_TOKEN = 'e2e_test_token_' + Date.now()
25+
const MAILBOX = `e2e-remote-${Date.now()}@test.com`
26+
27+
let workerProc: Subprocess | null = null
28+
29+
function d1(sql: string) {
30+
execSync(
31+
`cd ${WORKER_DIR} && npx wrangler d1 execute mails --local --command "${sql.replace(/"/g, '\\"')}"`,
32+
{ stdio: 'pipe' },
33+
)
34+
}
35+
36+
async function waitForServer(url: string, timeout = 15000) {
37+
const deadline = Date.now() + timeout
38+
while (Date.now() < deadline) {
39+
try {
40+
const res = await fetch(url)
41+
if (res.ok) return
42+
} catch {}
43+
await new Promise(r => setTimeout(r, 300))
44+
}
45+
throw new Error(`Server at ${url} did not start`)
46+
}
47+
48+
describe('E2E: remote provider ↔ self-hosted Worker', () => {
49+
beforeAll(async () => {
50+
// Ensure schema — use only CREATE statements (ALTER IF NOT EXISTS is not valid SQLite)
51+
const createSchema = `
52+
CREATE TABLE IF NOT EXISTS emails (
53+
id TEXT PRIMARY KEY, mailbox TEXT NOT NULL, from_address TEXT NOT NULL,
54+
from_name TEXT DEFAULT '', to_address TEXT NOT NULL, subject TEXT DEFAULT '',
55+
body_text TEXT DEFAULT '', body_html TEXT DEFAULT '', code TEXT,
56+
headers TEXT DEFAULT '{}', metadata TEXT DEFAULT '{}',
57+
message_id TEXT, has_attachments INTEGER NOT NULL DEFAULT 0,
58+
attachment_count INTEGER NOT NULL DEFAULT 0, attachment_names TEXT DEFAULT '',
59+
attachment_search_text TEXT DEFAULT '', raw_storage_key TEXT,
60+
direction TEXT NOT NULL CHECK (direction IN ('inbound', 'outbound')),
61+
status TEXT DEFAULT 'received' CHECK (status IN ('received', 'sent', 'failed', 'queued')),
62+
received_at TEXT NOT NULL, created_at TEXT NOT NULL
63+
);
64+
CREATE INDEX IF NOT EXISTS idx_emails_mailbox ON emails(mailbox, received_at DESC);
65+
CREATE TABLE IF NOT EXISTS attachments (
66+
id TEXT PRIMARY KEY, email_id TEXT NOT NULL, filename TEXT NOT NULL,
67+
content_type TEXT NOT NULL, size_bytes INTEGER, content_disposition TEXT,
68+
content_id TEXT, mime_part_index INTEGER NOT NULL, text_content TEXT DEFAULT '',
69+
text_extraction_status TEXT NOT NULL DEFAULT 'pending', storage_key TEXT, created_at TEXT NOT NULL
70+
);
71+
CREATE INDEX IF NOT EXISTS idx_attachments_email_id ON attachments(email_id);
72+
`
73+
try {
74+
execSync(`cd ${WORKER_DIR} && npx wrangler d1 execute mails --local --command "${createSchema.replace(/\n/g, ' ').replace(/"/g, '\\"')}"`, { stdio: 'pipe' })
75+
} catch {}
76+
77+
// Write .dev.vars with AUTH_TOKEN so wrangler picks it up
78+
const { writeFileSync: writeFile } = await import('fs')
79+
writeFile(join(WORKER_DIR, '.dev.vars'), `AUTH_TOKEN=${AUTH_TOKEN}\n`)
80+
81+
// Start worker
82+
workerProc = spawn({
83+
cmd: ['npx', 'wrangler', 'dev', '--port', String(PORT)],
84+
cwd: WORKER_DIR,
85+
stdout: 'ignore',
86+
stderr: 'ignore',
87+
})
88+
89+
await waitForServer(`${API}/health`)
90+
91+
// Seed test emails
92+
const now = new Date().toISOString()
93+
d1(`INSERT INTO emails (id, mailbox, from_address, from_name, to_address, subject, body_text, body_html, code, direction, status, received_at, created_at) VALUES ('remote-e1', '${MAILBOX}', 'sender@test.com', 'Sender', '${MAILBOX}', 'Password reset', 'Your code is 112233', '', '112233', 'inbound', 'received', '${now}', '${now}')`)
94+
d1(`INSERT INTO emails (id, mailbox, from_address, from_name, to_address, subject, body_text, body_html, code, direction, status, received_at, created_at) VALUES ('remote-e2', '${MAILBOX}', 'billing@test.com', 'Billing', '${MAILBOX}', 'Invoice ready', 'Your invoice is attached', '', NULL, 'inbound', 'received', '${now}', '${now}')`)
95+
d1(`INSERT INTO emails (id, mailbox, from_address, from_name, to_address, subject, body_text, body_html, code, direction, status, received_at, created_at) VALUES ('remote-e3', 'other@test.com', 'x@y.com', '', 'other@test.com', 'Other mailbox', 'Should not appear', '', NULL, 'inbound', 'received', '${now}', '${now}')`)
96+
}, 20000)
97+
98+
afterAll(() => {
99+
if (workerProc) {
100+
workerProc.kill()
101+
workerProc = null
102+
}
103+
try {
104+
d1(`DELETE FROM emails WHERE id IN ('remote-e1','remote-e2','remote-e3')`)
105+
} catch {}
106+
// Clean up .dev.vars
107+
try {
108+
const { rmSync: rm } = require('fs')
109+
rm(join(WORKER_DIR, '.dev.vars'), { force: true })
110+
} catch {}
111+
})
112+
113+
// --- AUTH_TOKEN enforcement ---
114+
115+
test('rejects requests without token when AUTH_TOKEN is set', async () => {
116+
const res = await fetch(`${API}/api/inbox?to=${MAILBOX}`)
117+
expect(res.status).toBe(401)
118+
})
119+
120+
test('accepts requests with correct token', async () => {
121+
const res = await fetch(`${API}/api/inbox?to=${MAILBOX}`, {
122+
headers: { Authorization: `Bearer ${AUTH_TOKEN}` },
123+
})
124+
expect(res.status).toBe(200)
125+
const data = await res.json() as { emails: any[] }
126+
expect(data.emails.length).toBeGreaterThanOrEqual(2)
127+
})
128+
129+
test('rejects requests with wrong token', async () => {
130+
const res = await fetch(`${API}/api/inbox?to=${MAILBOX}`, {
131+
headers: { Authorization: 'Bearer wrong_token' },
132+
})
133+
expect(res.status).toBe(401)
134+
})
135+
136+
test('/health is always public', async () => {
137+
const res = await fetch(`${API}/health`)
138+
expect(res.status).toBe(200)
139+
})
140+
141+
// --- Remote provider (self-hosted mode) ---
142+
143+
test('getEmails via remote provider', async () => {
144+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
145+
await provider.init()
146+
147+
const emails = await provider.getEmails(MAILBOX, { limit: 10 })
148+
expect(emails.length).toBeGreaterThanOrEqual(2)
149+
expect(emails.every((e: Email) => e.mailbox === MAILBOX)).toBe(true)
150+
})
151+
152+
test('getEmail detail via remote provider', async () => {
153+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
154+
const email = await provider.getEmail('remote-e1')
155+
expect(email).not.toBeNull()
156+
expect(email!.subject).toBe('Password reset')
157+
expect(email!.code).toBe('112233')
158+
})
159+
160+
test('getEmail returns null for nonexistent', async () => {
161+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
162+
expect(await provider.getEmail('nonexistent')).toBeNull()
163+
})
164+
165+
test('getCode via remote provider', async () => {
166+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
167+
const result = await provider.getCode(MAILBOX, { timeout: 3 })
168+
expect(result).not.toBeNull()
169+
expect(result!.code).toBe('112233')
170+
})
171+
172+
test('searchEmails via remote provider', async () => {
173+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
174+
const results = await provider.searchEmails(MAILBOX, { query: 'invoice' })
175+
expect(results).toHaveLength(1)
176+
expect(results[0]!.subject).toBe('Invoice ready')
177+
})
178+
179+
test('searchEmails returns empty for no match', async () => {
180+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
181+
const results = await provider.searchEmails(MAILBOX, { query: 'nonexistent_xyz' })
182+
expect(results).toHaveLength(0)
183+
})
184+
185+
test('mailbox isolation — cannot see other mailbox emails', async () => {
186+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
187+
const emails = await provider.getEmails(MAILBOX)
188+
expect(emails.every((e: Email) => e.mailbox === MAILBOX)).toBe(true)
189+
expect(emails.some((e: Email) => e.id === 'remote-e3')).toBe(false)
190+
})
191+
192+
test('direction filter works', async () => {
193+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
194+
const outbound = await provider.getEmails(MAILBOX, { direction: 'outbound' })
195+
expect(outbound).toHaveLength(0) // all seeded emails are inbound
196+
})
197+
198+
// --- saveEmail is read-only ---
199+
200+
test('saveEmail throws', async () => {
201+
const provider = createRemoteProvider({ url: API, mailbox: MAILBOX, token: AUTH_TOKEN })
202+
expect(provider.saveEmail({} as Email)).rejects.toThrow('read-only')
203+
})
204+
})

0 commit comments

Comments
 (0)