diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..6be11a73 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,221 @@ +name: E2E Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: read + +jobs: + e2e: + name: Run E2E Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: nekobox + POSTGRES_PASSWORD: nekobox + POSTGRES_DB: nekobox_e2e + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7 + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + mailhog: + image: mailhog/mailhog:latest + ports: + - 1025:1025 + - 8025:8025 + options: >- + --health-cmd "wget -qO- http://localhost:8025/api/v2/messages || exit 1" + --health-interval 5s + --health-timeout 3s + --health-retries 10 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version-file: go.mod + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: latest + + - name: Start MinIO (pgsty/minio) + run: | + docker run -d \ + --name minio \ + --network host \ + -e MINIO_ROOT_USER=minioadmin \ + -e MINIO_ROOT_PASSWORD=minioadmin \ + pgsty/minio \ + server /data --console-address ":9001" + + - name: Wait for MinIO + run: | + echo "Waiting for MinIO S3 API to be ready..." + for i in $(seq 1 60); do + if curl -sf http://localhost:9000/minio/health/live > /dev/null 2>&1; then + echo "MinIO is ready (health check passed)" + sleep 2 + exit 0 + fi + echo "Waiting for MinIO ($i/60)..." + sleep 2 + done + echo "MinIO did not become ready in time" + docker logs minio || true + exit 1 + + - name: Create MinIO bucket + run: | + curl -fsSL https://dl.min.io/client/mc/release/linux-amd64/mc -o ./mc + chmod +x ./mc + ./mc alias set local http://localhost:9000 minioadmin minioadmin + ./mc mb --ignore-existing local/nekobox-e2e + ./mc ls local/nekobox-e2e + echo "minio-smoke" > /tmp/minio-smoke.txt + ./mc cp /tmp/minio-smoke.txt local/nekobox-e2e/health/smoke.txt + ./mc stat local/nekobox-e2e/health/smoke.txt + + # ── Backend ──────────────────────────────────────────────────────────── + + - name: Build backend + run: | + COVERPKG=$(go list ./... | paste -sd "," -) + go build -cover -covermode=atomic -coverpkg="$COVERPKG" -o ./nekobox-server ./cmd + + - name: Start backend (port 8080) + run: | + mkdir -p coverage/backend + GOCOVERDIR=$PWD/coverage/backend NEKOBOX_CONFIG_PATH=conf/app.e2e.ini ./nekobox-server web & + echo $! > /tmp/nekobox-server.pid + + # ── Frontend ─────────────────────────────────────────────────────────── + + - name: Install frontend dependencies + working-directory: web + run: pnpm install + + - name: Start frontend dev server (port 3000) + working-directory: web + run: pnpm dev:e2e & + env: + VITE_EXTERNAL_URL: http://localhost:3000 + + - name: Wait for frontend to be ready + run: | + for i in $(seq 1 30); do + if curl -sf http://localhost:3000 > /dev/null 2>&1; then + echo "Frontend is up!" + break + fi + echo "Waiting for frontend ($i/30)..." + sleep 2 + done + + # ── Playwright ───────────────────────────────────────────────────────── + + - name: Install Playwright dependencies + working-directory: e2e + run: npm install + + - name: Install Playwright browsers + working-directory: e2e + run: npx playwright install --with-deps chromium + + - name: Run Playwright E2E tests + working-directory: e2e + run: npx playwright test + env: + E2E_BASE_URL: http://localhost:3000 + E2E_MAILHOG_URL: http://localhost:8025 + + - name: Stop backend and generate coverage.txt + if: always() + run: | + if [ -f /tmp/nekobox-server.pid ]; then + BACKEND_PID=$(cat /tmp/nekobox-server.pid) + if kill -0 "$BACKEND_PID" 2>/dev/null; then + kill "$BACKEND_PID" + for i in $(seq 1 20); do + if ! kill -0 "$BACKEND_PID" 2>/dev/null; then + break + fi + sleep 1 + done + if kill -0 "$BACKEND_PID" 2>/dev/null; then + kill -9 "$BACKEND_PID" || true + fi + fi + fi + + mkdir -p coverage + if [ -d coverage/backend ] && [ -n "$(ls -A coverage/backend)" ]; then + go tool covdata textfmt -i=coverage/backend -o=coverage/coverage.txt + echo "Route coverage entries:" + grep 'internal/route/' coverage/coverage.txt | head -n 20 || true + + if ! grep -q 'internal/route/' coverage/coverage.txt; then + echo "::error::No internal/route coverage found in coverage/coverage.txt" + exit 1 + fi + else + echo "No backend coverage data found" > coverage/coverage.txt + echo "::error::No backend coverage data found in coverage/backend" + exit 1 + fi + + - name: Upload Playwright report + uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: e2e/playwright-report/ + retention-days: 30 + + - name: Upload backend coverage artifact + uses: actions/upload-artifact@v4 + if: always() + with: + name: coverage + path: coverage/coverage.txt + retention-days: 30 + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v4 + if: always() + with: + token: ${{ secrets.CODECOV_TOKEN }} + files: ./coverage/coverage.txt + flags: e2e + name: e2e-tests + diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 943e3318..10dd0454 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -37,6 +37,10 @@ jobs: DB_USER: root DB_PASSWORD: root DB_DATABASE: nekobox + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} test-postgres: name: Test Postgres @@ -68,6 +72,10 @@ jobs: DB_USER: postgres DB_PASSWORD: postgres DB_DATABASE: nekobox + - name: Upload coverage reports to Codecov + uses: codecov/codecov-action@v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} lint: name: Lint diff --git a/.gitignore b/.gitignore index 9327f7c4..63f472e5 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,12 @@ app.ini .vscode nekobox share.yaml + +node_modules + +# E2E test artifacts +e2e/node_modules +e2e/.auth +e2e/test-results/ +e2e/playwright-report/ +e2e/.env.local diff --git a/conf/app.e2e.ini b/conf/app.e2e.ini new file mode 100644 index 00000000..2fa302ec --- /dev/null +++ b/conf/app.e2e.ini @@ -0,0 +1,50 @@ +; E2E test configuration. +; Used by: NEKOBOX_CONFIG_PATH=conf/app.e2e.ini +; The Go backend listens on :8080; the Vite dev-server (port 3000) +; proxies /api → http://127.0.0.1:8080. + +[app] +production = false +title = NekoBox E2E Test +external_url = http://localhost:3000 + +[security] +enable_text_censor = false + +[server] +port = 8080 +salt = e2e-test-salt-value-placeholder +xsrf_key = e2e-test-xsrf-key-placeholder + +[database] +type = postgres +host = localhost +port = 5432 +user = nekobox +password = nekobox +name = nekobox_e2e + +[redis] +addr = localhost:6379 +password = + +[recaptcha] +; Universal test keys – accepted by Google's verify API for any token. +site_key = 6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +server_key = 6LeIxAcTAAAAAGG-vFI1TnRWxMZNFuojJ4WifJWe + +[upload] +default_avatar = https://example.com/default-avatar.png +default_background = https://example.com/default-bg.png +image_endpoint = http://localhost:9000 +image_access_id = minioadmin +image_access_secret = minioadmin +image_bucket = nekobox-e2e +image_bucket_cdn_host = localhost:9000 + +[mail] +account = noreply@nekobox.local +password = +port = 1025 +smtp = localhost + diff --git a/e2e/package-lock.json b/e2e/package-lock.json new file mode 100644 index 00000000..8c764299 --- /dev/null +++ b/e2e/package-lock.json @@ -0,0 +1,96 @@ +{ + "name": "nekobox-e2e", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "nekobox-e2e", + "version": "1.0.0", + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.2" + } + }, + "node_modules/@playwright/test": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.59.1.tgz", + "integrity": "sha512-PG6q63nQg5c9rIi4/Z5lR5IVF7yU5MqmKaPOe0HSc0O2cX1fPi96sUQu5j7eo4gKCkB2AnNGoWt7y4/Xx3Kcqg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@types/node": { + "version": "22.19.17", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.17.tgz", + "integrity": "sha512-wGdMcf+vPYM6jikpS/qhg6WiqSV/OhG+jeeHT/KlVqxYfD40iYJf9/AE1uQxVWFvU7MipKRkRv8NSHiCGgPr8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/playwright": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz", + "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.59.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.59.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz", + "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/e2e/package.json b/e2e/package.json new file mode 100644 index 00000000..c41302b7 --- /dev/null +++ b/e2e/package.json @@ -0,0 +1,17 @@ +{ + "name": "nekobox-e2e", + "version": "1.0.0", + "private": true, + "scripts": { + "test": "playwright test", + "test:ui": "playwright test --ui", + "test:headed": "playwright test --headed", + "test:debug": "playwright test --debug", + "report": "playwright show-report" + }, + "devDependencies": { + "@playwright/test": "^1.49.1", + "@types/node": "^22.10.2" + } +} + diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts new file mode 100644 index 00000000..a5c3146e --- /dev/null +++ b/e2e/playwright.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './tests', + + // Run tests in parallel, but keep worker count conservative for CI stability. + fullyParallel: true, + workers: process.env.CI ? 2 : undefined, + timeout: process.env.CI ? 90_000 : 60_000, + + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + + reporter: [ + ['html'], + ...(process.env.CI ? ([['github']] as any) : []), + ], + + // Real reCAPTCHA + remote verify can take longer than default 5s in CI. + // Image upload (MinIO) can be slow during initialization. + expect: { + timeout: process.env.CI ? 30_000 : 10_000, + }, + + use: { + baseURL: process.env.E2E_BASE_URL || 'http://localhost:3000', + trace: 'on-first-retry', + screenshot: 'only-on-failure', + // Give Vue time to hydrate and animations to settle. + actionTimeout: 10_000, + navigationTimeout: 30_000, + }, + + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], +}); + diff --git a/e2e/tests/auth.spec.ts b/e2e/tests/auth.spec.ts new file mode 100644 index 00000000..6f22431c --- /dev/null +++ b/e2e/tests/auth.spec.ts @@ -0,0 +1,110 @@ +import { test, expect } from '@playwright/test'; +import { clickSubmitWhenReady, uniqueUser } from './helpers'; + +test.describe('Authentication', () => { + + test('user can sign up and is redirected to sign-in', async ({ page }) => { + const user = uniqueUser('signup'); + + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await clickSubmitWhenReady(page); + + // Successful registration redirects to /sign-in. + await expect(page).toHaveURL(/\/sign-in$/); + }); + + test('user can sign in and land on their profile page', async ({ page }) => { + const user = uniqueUser('signin'); + + // Register first. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await clickSubmitWhenReady(page); + await expect(page).toHaveURL(/\/sign-in$/); + + // Sign in. + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill(user.password); + await clickSubmitWhenReady(page); + + // Should land on the user's own profile page. + await expect(page).toHaveURL(new RegExp(`/_/${user.domain}$`)); + await expect(page).toHaveTitle(new RegExp(user.name)); + }); + + test('sign-up with duplicate email shows an error', async ({ page }) => { + const user = uniqueUser('dupmail'); + const duplicateDomain = `${user.domain.slice(0, 19)}2`; + + // First registration. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await clickSubmitWhenReady(page); + await expect(page).toHaveURL(/\/sign-in$/); + + // Try to register again with the same email but a different domain. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); // duplicate + await page.locator('input[name="domain"]').fill(duplicateDomain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + + const duplicateSignUpResponsePromise = page.waitForResponse(resp => + resp.url().includes('/auth/sign-up') && resp.request().method() === 'POST' + ); + await clickSubmitWhenReady(page); + + const duplicateSignUpResponse = await duplicateSignUpResponsePromise; + expect(duplicateSignUpResponse.status()).toBe(400); + const duplicateSignUpPayload = await duplicateSignUpResponse.json(); + + // Error toast must appear. + await expect(duplicateSignUpPayload.msg).toContain('邮箱'); + }); + + test('sign-in with wrong password shows an error', async ({ page }) => { + const user = uniqueUser('wrongpw'); + + // Register. + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await clickSubmitWhenReady(page); + await expect(page).toHaveURL(/\/sign-in$/); + + // Sign in with wrong password. + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill('totally-wrong-password'); + + const wrongPasswordSignInResponsePromise = page.waitForResponse(resp => + resp.url().includes('/auth/sign-in') && resp.request().method() === 'POST' + ); + await clickSubmitWhenReady(page); + + const wrongPasswordSignInResponse = await wrongPasswordSignInResponsePromise; + expect(wrongPasswordSignInResponse.status()).toBe(400); + const wrongPasswordSignInPayload = await wrongPasswordSignInResponse.json(); + + // Error toast must appear; URL must not change. + await expect(wrongPasswordSignInPayload.msg).toContain('密码'); + await expect(page).toHaveURL('/sign-in'); + }); +}); + diff --git a/e2e/tests/helpers.ts b/e2e/tests/helpers.ts new file mode 100644 index 00000000..627b6b33 --- /dev/null +++ b/e2e/tests/helpers.ts @@ -0,0 +1,103 @@ +import { expect, type Page } from '@playwright/test'; +import { randomBytes } from 'node:crypto'; + +export async function clickSubmitWhenReady(page: Page): Promise { + const submitButton = page.locator('button[type="submit"]'); + await expect(submitButton).toBeVisible(); + await expect(submitButton).toBeEnabled(); + await submitButton.click(); +} + +// ─── Unique test data ───────────────────────────────────────────────────────── + +/** + * Returns a unique user object based on the given prefix + current timestamp. + * Each test run gets a different domain/email so repeated local runs do not clash. + */ +export function uniqueUser(prefix: string) { + const ts = Date.now().toString(36); + const nonce = randomBytes(3).toString('hex'); + // Keep domain <= 20 chars to satisfy form/backend constraints. + const domainPrefix = prefix.toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 8) || 'user'; + const domain = `${domainPrefix}_${ts}${nonce}`.slice(0, 20); + + return { + email: `${domain}@example.com`, + domain, + name: `${prefix} ${nonce}`, + password: 'Password123!', + }; +} + +// ─── MailHog ────────────────────────────────────────────────────────────────── + +const MAILHOG_BASE = process.env.E2E_MAILHOG_URL ?? 'http://localhost:8025'; + +interface MailhogResponse { + items: Array<{ + Content: { + Headers: Record; + Body: string; + }; + }>; +} + +/** + * Polls the MailHog HTTP API until an email addressed to `to` appears, + * or the timeout is reached. + * + * @returns true if the email was found within the timeout. + */ +export async function waitForMailhogEmail(to: string, timeoutMs = 15_000): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + try { + const res = await fetch(`${MAILHOG_BASE}/api/v2/messages`); + if (res.ok) { + const data = (await res.json()) as MailhogResponse; + for (const item of data.items) { + const toHeaders = item.Content?.Headers?.['To'] ?? []; + if (toHeaders.some(addr => addr.includes(to))) { + return true; + } + } + } + } catch { + // MailHog not yet ready – keep polling. + } + await new Promise(r => setTimeout(r, 500)); + } + return false; +} + +// ─── Auth flow helper ───────────────────────────────────────────────────────── + +type User = ReturnType; + +/** + * Registers and signs in a test user, leaving the browser on the user's + * profile page. Returns the user object for later use. + */ +export async function registerAndLogin(page: Page, prefix: string): Promise { + const user = uniqueUser(prefix); + + // ── Sign up + await page.goto('/sign-up'); + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="domain"]').fill(user.domain); + await page.locator('input[name="name"]').fill(user.name); + await page.locator('input[name="password"]').fill(user.password); + await page.locator('input[name="repeatPassword"]').fill(user.password); + await clickSubmitWhenReady(page); + await expect(page).toHaveURL(/\/sign-in$/); + + // ── Sign in + await page.locator('input[name="email"]').fill(user.email); + await page.locator('input[name="password"]').fill(user.password); + await clickSubmitWhenReady(page); + await expect(page).toHaveURL(new RegExp(`/_/${user.domain}$`)); + + return user; +} + diff --git a/e2e/tests/questions.spec.ts b/e2e/tests/questions.spec.ts new file mode 100644 index 00000000..55bde926 --- /dev/null +++ b/e2e/tests/questions.spec.ts @@ -0,0 +1,157 @@ +import { test, expect, type Page } from '@playwright/test'; +import { clickSubmitWhenReady, registerAndLogin, waitForMailhogEmail } from './helpers'; + +function waitForAnswerResponse(page: Page) { + return page.waitForResponse(resp => + /\/api\/mine\/questions\/\d+\/answer$/.test(resp.url()) && resp.request().method() === 'PUT' + ); +} + +function waitForPostQuestionResponse(page: Page, domain: string) { + return page.waitForResponse(resp => + resp.request().method() === 'POST' && + new RegExp(`/api/users/${domain}/questions$`).test(new URL(resp.url()).pathname) + ); +} + +// ─── Post a question ────────────────────────────────────────────────────────── + +test('can post a question to a profile box', async ({ page }) => { + const user = await registerAndLogin(page, 'qpost'); + + await page.goto(`/_/${user.domain}`); + await page.locator('textarea[name="content"]').fill('What is your favorite color?'); + await clickSubmitWhenReady(page); + + // A success banner with the private link appears. + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + await expect(page.locator('.uk-alert-success')).toContainText('发送问题成功'); +}); + +// ─── Full question lifecycle ─────────────────────────────────────────────────── + +test('owner can answer a question and the answer is shown publicly', async ({ page }) => { + const user = await registerAndLogin(page, 'qanswer'); + + // 1. Post a question (as the signed-in owner – valid for testing). + await page.goto(`/_/${user.domain}`); + await page.locator('textarea[name="content"]').fill('What is your favorite programming language?'); + await clickSubmitWhenReady(page); + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + + // 2. Navigate to "my questions" and open the first one. + await page.goto('/mine/questions'); + await expect(page.locator('p.uk-text-small').first()).toContainText( + 'What is your favorite programming language?', + { timeout: 10_000 }, + ); + await page.locator('p.uk-text-small').first().click(); + + // 3. On the question-detail page, the owner sees the answer form. + await expect(page).toHaveURL(new RegExp(`/_/${user.domain}/\\d+`)); + await expect(page.locator('textarea[name="answer"]')).toBeVisible({ timeout: 10_000 }); + + // 4. Submit the answer. + await page.locator('textarea[name="answer"]').fill('Go is my favorite!'); + const answerResponsePromise = waitForAnswerResponse(page); + await clickSubmitWhenReady(page); + const answerResponse = await answerResponsePromise; + expect(answerResponse.status()).toBe(200); + const answerPayload = await answerResponse.json(); + expect(answerPayload.data).toContain('提问回复成功'); + + // 5. The answer text is now rendered in the card body. + await expect(page.locator('.uk-card-body p.uk-text-small').first()).toContainText('Go is my favorite!'); +}); + +// ─── Email notifications ────────────────────────────────────────────────────── + +test('owner receives a "new question" email when someone posts a question', async ({ page }) => { + const owner = await registerAndLogin(page, 'mailowner'); + + // Post a question to the owner's box (as the signed-in owner for simplicity). + await page.goto(`/_/${owner.domain}`); + await page.locator('textarea[name="content"]').fill('Will you get an email about this?'); + await clickSubmitWhenReady(page); + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + + // MailHog should receive the "new question" notification. + const received = await waitForMailhogEmail(owner.email, 30_000); + expect(received).toBe(true); +}); + +test('questioner receives a reply email when their question is answered', async ({ page }) => { + const owner = await registerAndLogin(page, 'replyowner'); + const replyEmail = `reply-${owner.domain}-${Date.now()}@example.com`; + + // 1. Post a question with an email-reply address. + await page.goto(`/_/${owner.domain}`); + await page.locator('textarea[name="content"]').fill('Will I get a reply notification?'); + + // Check the "接收回复通知" checkbox (the second checkbox in the form). + await page.locator('label:has-text("我想接收回复通知") input[type="checkbox"]').check(); + await page.locator('input[name="receiveReplyEmail"]').fill(replyEmail); + + await clickSubmitWhenReady(page); + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 10_000 }); + + // 2. Open the question from the mine list. + await page.goto('/mine/questions'); + await page.locator('p.uk-text-small').first().click(); + await expect(page).toHaveURL(new RegExp(`/_/${owner.domain}/\\d+`)); + + // 3. Answer the question – this triggers the reply email. + await expect(page.locator('textarea[name="answer"]')).toBeVisible({ timeout: 10_000 }); + await page.locator('textarea[name="answer"]').fill('Yes, you will receive a reply!'); + const replyAnswerResponsePromise = waitForAnswerResponse(page); + await clickSubmitWhenReady(page); + const replyAnswerResponse = await replyAnswerResponsePromise; + expect(replyAnswerResponse.status()).toBe(200); + const replyAnswerPayload = await replyAnswerResponse.json(); + expect(replyAnswerPayload.data).toContain('提问回复成功'); + + // 4. MailHog must have delivered the reply notification to replyEmail. + const received = await waitForMailhogEmail(replyEmail, 30_000); + expect(received).toBe(true); +}); + +// ─── Image upload (MinIO / S3) ──────────────────────────────────────────────── + +test('can post a question with an image (MinIO upload)', async ({ page }) => { + const user = await registerAndLogin(page, 'imgupload'); + + await page.goto(`/_/${user.domain}`); + + // Create a minimal 1×1 PNG as a Buffer and attach it via the file chooser. + const minimalPNG = Buffer.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, + 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, + 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, + 0x0c, 0x49, 0x44, 0x41, 0x54, 0x08, 0xd7, 0x63, 0xf8, 0xcf, 0xc0, 0x00, + 0x00, 0x00, 0x02, 0x00, 0x01, 0xe2, 0x21, 0xbc, 0x33, 0x00, 0x00, 0x00, + 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, + ]); + + await page.locator('textarea[name="content"]').fill('Check out this image!'); + + // Attach via the hidden file input. + const fileInput = page.locator('input[name="images"][type="file"]'); + await fileInput.setInputFiles({ + name: 'test.png', + mimeType: 'image/png', + buffer: minimalPNG, + }); + + const postQuestionResponsePromise = waitForPostQuestionResponse(page, user.domain); + await clickSubmitWhenReady(page); + const postQuestionResponse = await postQuestionResponsePromise; + + const postQuestionBody = await postQuestionResponse.json(); + expect(postQuestionResponse.status(), `Post question failed: ${JSON.stringify(postQuestionBody)}`).toBe(200); + expect(postQuestionBody?.data ?? '').toContain('发送问题成功'); + + // Success message must appear (image upload in CI can take longer due to MinIO initialization). + await expect(page.locator('.uk-alert-success')).toBeVisible({ timeout: 30_000 }); + await expect(page.locator('.uk-alert-success')).toContainText('发送问题成功'); +}); + diff --git a/go.mod b/go.mod index 8574b966..7c7d726a 100644 --- a/go.mod +++ b/go.mod @@ -5,10 +5,11 @@ go 1.24 require ( github.com/aliyun/alibaba-cloud-sdk-go v1.62.26 github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible - github.com/aws/aws-sdk-go-v2 v1.24.0 - github.com/aws/aws-sdk-go-v2/config v1.26.1 - github.com/aws/aws-sdk-go-v2/credentials v1.16.12 - github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 + github.com/aws/aws-sdk-go-v2 v1.24.1 + github.com/aws/aws-sdk-go-v2/config v1.26.6 + github.com/aws/aws-sdk-go-v2/credentials v1.16.16 + github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15 + github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 github.com/flamego/cache v1.1.0 github.com/flamego/cors v1.2.1 github.com/flamego/flamego v1.9.4 @@ -45,18 +46,18 @@ require ( require ( github.com/alecthomas/participle/v2 v2.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 // indirect - github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 // indirect - github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 // indirect - github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 // indirect - github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 // indirect + github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 // indirect github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 // indirect - github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 // indirect - github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 // indirect - github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 // indirect - github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 // indirect github.com/aws/smithy-go v1.19.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/baiyubin/aliyun-sts-go-sdk v0.0.0-20180326062324-cfa1a18b161f // indirect diff --git a/go.sum b/go.sum index 6ad7efce..8899406b 100644 --- a/go.sum +++ b/go.sum @@ -52,40 +52,42 @@ github.com/aliyun/alibaba-cloud-sdk-go v1.62.26/go.mod h1:Api2AkmMgGaSUAhmk76oaF github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible h1:cD1bK/FmYTpL+r5i9lQ9EU6ScAjA173EVsii7gAc6SQ= github.com/aliyun/aliyun-oss-go-sdk v2.2.4+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8= github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= -github.com/aws/aws-sdk-go-v2 v1.24.0 h1:890+mqQ+hTpNuw0gGP6/4akolQkSToDJgHfQE7AwGuk= -github.com/aws/aws-sdk-go-v2 v1.24.0/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= +github.com/aws/aws-sdk-go-v2 v1.24.1 h1:xAojnj+ktS95YZlDf0zxWBkbFtymPeDP+rvUQIH3uAU= +github.com/aws/aws-sdk-go-v2 v1.24.1/go.mod h1:LNh45Br1YAkEKaAqvmE1m8FUx6a5b/V0oAKV7of29b4= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4 h1:OCs21ST2LrepDfD3lwlQiOqIGp6JiEUqG84GzTDoyJs= github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.5.4/go.mod h1:usURWEKSNNAcAZuzRn/9ZYPT8aZQkR7xcCtunK/LkJo= -github.com/aws/aws-sdk-go-v2/config v1.26.1 h1:z6DqMxclFGL3Zfo+4Q0rLnAZ6yVkzCRxhRMsiRQnD1o= -github.com/aws/aws-sdk-go-v2/config v1.26.1/go.mod h1:ZB+CuKHRbb5v5F0oJtGdhFTelmrxd4iWO1lf0rQwSAg= -github.com/aws/aws-sdk-go-v2/credentials v1.16.12 h1:v/WgB8NxprNvr5inKIiVVrXPuuTegM+K8nncFkr1usU= -github.com/aws/aws-sdk-go-v2/credentials v1.16.12/go.mod h1:X21k0FjEJe+/pauud82HYiQbEr9jRKY3kXEIQ4hXeTQ= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10 h1:w98BT5w+ao1/r5sUuiH6JkVzjowOKeOJRHERyy1vh58= -github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.10/go.mod h1:K2WGI7vUvkIv1HoNbfBA1bvIZ+9kL3YVmWxeKuLQsiw= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9 h1:v+HbZaCGmOwnTTVS86Fleq0vPzOd7tnJGbFhP0stNLs= -github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.9/go.mod h1:Xjqy+Nyj7VDLBtCMkQYOw1QYfAEZCVLrfI0ezve8wd4= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9 h1:N94sVhRACtXyVcjXxrwK1SKFIJrA9pOJ5yu2eSHnmls= -github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.9/go.mod h1:hqamLz7g1/4EJP+GH5NBhcUMLjW+gKLQabgyz6/7WAU= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2 h1:GrSw8s0Gs/5zZ0SX+gX4zQjRnRsMJDJ2sLur1gRBhEM= -github.com/aws/aws-sdk-go-v2/internal/ini v1.7.2/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9 h1:ugD6qzjYtB7zM5PN/ZIeaAIyefPaD82G8+SJopgvUpw= -github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.9/go.mod h1:YD0aYBWCrPENpHolhKw2XDlTIWae2GKXT1T4o6N6hiM= +github.com/aws/aws-sdk-go-v2/config v1.26.6 h1:Z/7w9bUqlRI0FFQpetVuFYEsjzE3h7fpU6HuGmfPL/o= +github.com/aws/aws-sdk-go-v2/config v1.26.6/go.mod h1:uKU6cnDmYCvJ+pxO9S4cWDb2yWWIH5hra+32hVh1MI4= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16 h1:8q6Rliyv0aUFAVtzaldUEcS+T5gbadPbWdV1WcAddK8= +github.com/aws/aws-sdk-go-v2/credentials v1.16.16/go.mod h1:UHVZrdUsv63hPXFo1H7c5fEneoVo9UXiz36QG1GEPi0= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11 h1:c5I5iH+DZcH3xOIMlz3/tCKJDaHFwYEmxvlh2fAcFo8= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.14.11/go.mod h1:cRrYDYAMUohBJUtUnOhydaMHtiK/1NZ0Otc9lIb6O0Y= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15 h1:2MUXyGW6dVaQz6aqycpbdLIH1NMcUI6kW6vQ0RabGYg= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.15.15/go.mod h1:aHbhbR6WEQgHAiRj41EQ2W47yOYwNtIkWTXmcAtYqj8= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10 h1:vF+Zgd9s+H4vOXd5BMaPWykta2a6Ih0AKLq/X6NYKn4= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.2.10/go.mod h1:6BkRjejp/GR4411UGqkX8+wFMbFbqsUIimfK4XjOKR4= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10 h1:nYPe006ktcqUji8S2mqXf9c/7NdiKriOwMvWQHgYztw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.5.10/go.mod h1:6UV4SZkVvmODfXKql4LCbaZUpF7HO2BX38FgBf9ZOLw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3 h1:n3GDfwqF2tzEkXlv5cuy4iy7LpKDtqDMcNLfZDu9rls= +github.com/aws/aws-sdk-go-v2/internal/ini v1.7.3/go.mod h1:6fQQgfuGmw8Al/3M2IgIllycxV7ZW7WCdVSqfBeUiCY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10 h1:5oE2WzJE56/mVveuDZPJESKlg/00AaS2pY2QZcnxg4M= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.2.10/go.mod h1:FHbKWQtRBYUz4vO5WBWjzMD2by126ny5y/1EoaWoLfI= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4 h1:/b31bi3YVNlkzkBrm9LfpaKoaYZUxIAj4sHfOTmLfqw= github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.10.4/go.mod h1:2aGXHFmbInwgP9ZfpmdIfOELL79zhdNYNmReK8qDfdQ= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9 h1:/90OR2XbSYfXucBMJ4U14wrjlfleq/0SB6dZDPncgmo= -github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.9/go.mod h1:dN/Of9/fNZet7UrQQ6kTDo/VSwKPIq94vjlU16bRARc= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9 h1:Nf2sHxjMJR8CSImIVCONRi4g0Su3J+TSTbS7G0pUeMU= -github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.9/go.mod h1:idky4TER38YIjr2cADF1/ugFMKvZV7p//pVeV5LZbF0= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9 h1:iEAeF6YC3l4FzlJPP9H3Ko1TXpdjdqWffxXjp8SY6uk= -github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.9/go.mod h1:kjsXoK23q9Z/tLBrckZLLyvjhZoS+AGrzqzUfEClvMM= -github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5 h1:Keso8lIOS+IzI2MkPZyK6G0LYcK3My2LQ+T5bxghEAY= -github.com/aws/aws-sdk-go-v2/service/s3 v1.47.5/go.mod h1:vADO6Jn+Rq4nDtfwNjhgR84qkZwiC6FqCaXdw/kYwjA= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.5 h1:ldSFWz9tEHAwHNmjx2Cvy1MjP5/L9kNoR0skc6wyOOM= -github.com/aws/aws-sdk-go-v2/service/sso v1.18.5/go.mod h1:CaFfXLYL376jgbP7VKC96uFcU8Rlavak0UlAwk1Dlhc= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5 h1:2k9KmFawS63euAkY4/ixVNsYYwrwnd5fIvgEKkfZFNM= -github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.5/go.mod h1:W+nd4wWDVkSUIox9bacmkBP5NMFQeTJ/xqNabpzSR38= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.5 h1:5UYvv8JUvllZsRnfrcMQ+hJ9jNICmcgKPAO1CER25Wg= -github.com/aws/aws-sdk-go-v2/service/sts v1.26.5/go.mod h1:XX5gh4CB7wAs4KhcF46G6C8a2i7eupU19dcAAE+EydU= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10 h1:L0ai8WICYHozIKK+OtPzVJBugL7culcuM4E4JOpIEm8= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.2.10/go.mod h1:byqfyxJBshFk0fF9YmK0M0ugIO8OWjzH2T3bPG4eGuA= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10 h1:DBYTXwIGQSGs9w4jKm60F5dmCQ3EEruxdc0MFh+3EY4= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.10.10/go.mod h1:wohMUQiFdzo0NtxbBg0mSRGZ4vL3n0dKjLTINdcIino= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10 h1:KOxnQeWy5sXyS37fdKEvAsGHOr9fa/qvwxfJurR/BzE= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.16.10/go.mod h1:jMx5INQFYFYB3lQD9W0D8Ohgq6Wnl7NYOJ2TQndbulI= +github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1 h1:5XNlsBsEvBZBMO6p82y+sqpWg8j5aBCe+5C2GBFgqBQ= +github.com/aws/aws-sdk-go-v2/service/s3 v1.48.1/go.mod h1:4qXHrG1Ne3VGIMZPCB8OjH/pLFO94sKABIusjh0KWPU= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7 h1:eajuO3nykDPdYicLlP3AGgOyVN3MOlFmZv7WGTuJPow= +github.com/aws/aws-sdk-go-v2/service/sso v1.18.7/go.mod h1:+mJNDdF+qiUlNKNC3fxn74WWNN+sOiGOEImje+3ScPM= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7 h1:QPMJf+Jw8E1l7zqhZmMlFw6w1NmfkfiSK8mS4zOx3BA= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.21.7/go.mod h1:ykf3COxYI0UJmxcfcxcVuz7b6uADi1FkiUz6Eb7AgM8= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7 h1:NzO4Vrau795RkUdSHKEwiR01FaGzGOH1EETJ+5QHnm0= +github.com/aws/aws-sdk-go-v2/service/sts v1.26.7/go.mod h1:6h2YuIoxaMSCFf5fi1EgZAwdfkGMgDY+DVfa61uLe4U= github.com/aws/smithy-go v1.19.0 h1:KWFKQV80DpP3vJrrA9sVAHQ5gc2z8i4EzrLhLlWXcBM= github.com/aws/smithy-go v1.19.0/go.mod h1:NukqUGpCZIILqqiV0NIjeFh24kd/FAa4beRb6nbIUPE= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= diff --git a/internal/conf/static.go b/internal/conf/static.go index 87f70e88..fe6b515f 100644 --- a/internal/conf/static.go +++ b/internal/conf/static.go @@ -57,6 +57,7 @@ var ( SiteKey string `ini:"site_key"` ServerKey string `ini:"server_key"` TurnstileStyle bool `ini:"turnstile_style"` + VerifyURL string `ini:"verify_url"` // Optional: override verify URL (e.g. for testing) } Pixel struct { diff --git a/internal/db/questions_test.go b/internal/db/questions_test.go index 9312cab5..272fb31d 100644 --- a/internal/db/questions_test.go +++ b/internal/db/questions_test.go @@ -357,11 +357,50 @@ func testQuestionsUpdateCensor(t *testing.T, ctx context.Context, db *questions) }) require.Nil(t, err) - err = db.DeleteByID(ctx, 1) + contentMetadata := []byte(`{"source_name":"aliyun","result":"pass"}`) + answerMetadata := []byte(`{"source_name":"qiniu","result":"review"}`) + + err = db.UpdateCensor(ctx, 1, UpdateQuestionCensorOptions{ + ContentCensorMetadata: contentMetadata, + AnswerCensorMetadata: answerMetadata, + }) require.Nil(t, err) - _, err = db.GetByID(ctx, 1) - require.Equal(t, ErrQuestionNotExist, err) + got, err := db.GetByID(ctx, 1) + require.Nil(t, err) + require.JSONEq(t, string(contentMetadata), string(got.ContentCensorMetadata)) + require.JSONEq(t, string(answerMetadata), string(got.AnswerCensorMetadata)) + }) + + t.Run("invalid metadata should keep original value", func(t *testing.T) { + _, err := db.Create(ctx, CreateQuestionOptions{ + FromIP: "114.5.1.4", + UserID: 1, + Content: "Content - 2", + ReceiveReplyEmail: "i@github.red", + AskerUserID: 1, + }) + require.Nil(t, err) + + originContentMetadata := []byte(`{"source_name":"aliyun","result":"pass"}`) + originAnswerMetadata := []byte(`{"source_name":"qiniu","result":"pass"}`) + + err = db.UpdateCensor(ctx, 2, UpdateQuestionCensorOptions{ + ContentCensorMetadata: originContentMetadata, + AnswerCensorMetadata: originAnswerMetadata, + }) + require.Nil(t, err) + + err = db.UpdateCensor(ctx, 2, UpdateQuestionCensorOptions{ + ContentCensorMetadata: []byte(`null`), + AnswerCensorMetadata: []byte(`{"source_name":}`), + }) + require.Nil(t, err) + + got, err := db.GetByID(ctx, 2) + require.Nil(t, err) + require.JSONEq(t, string(originContentMetadata), string(got.ContentCensorMetadata)) + require.JSONEq(t, string(originAnswerMetadata), string(got.AnswerCensorMetadata)) }) t.Run("not found", func(t *testing.T) { @@ -370,6 +409,26 @@ func testQuestionsUpdateCensor(t *testing.T, ctx context.Context, db *questions) }) } +func TestCheckTextCensorResponseValid(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + raw []byte + want bool + }{ + {name: "empty", raw: nil, want: false}, + {name: "null", raw: []byte(`null`), want: false}, + {name: "missing source", raw: []byte(`{"result":"pass"}`), want: false}, + {name: "malformed", raw: []byte(`{"source_name":}`), want: false}, + {name: "valid", raw: []byte(`{"source_name":"aliyun"}`), want: true}, + } { + t.Run(tc.name, func(t *testing.T) { + require.Equal(t, tc.want, checkTextCensorResponseValid(tc.raw)) + }) + } +} + func testQuestionsCount(t *testing.T, ctx context.Context, db *questions) { _, err := db.Create(ctx, CreateQuestionOptions{ FromIP: "114.5.1.4", diff --git a/internal/db/users_test.go b/internal/db/users_test.go index 63fcfeeb..b1947938 100644 --- a/internal/db/users_test.go +++ b/internal/db/users_test.go @@ -30,6 +30,7 @@ func TestUsers(t *testing.T) { {"GetByEmail", testUsersGetByEmail}, {"GetByDomain", testUsersGetByDomain}, {"Update", testUsersUpdate}, + {"SetName", testUsersSetName}, {"UpdateHarassmentSetting", testUsersUpdateHarassmentSetting}, {"Authenticate", testUsersAuthenticate}, {"ChangePassword", testUsersChangePassword}, @@ -264,6 +265,33 @@ func testUsersUpdate(t *testing.T, ctx context.Context, db *users) { }) } +func testUsersSetName(t *testing.T, ctx context.Context, db *users) { + err := db.Create(ctx, CreateUserOptions{ + Name: "E99p1ant", + Password: "super_secret", + Email: "i@github.red", + Avatar: "avater.png", + Domain: "e99", + Background: "background.png", + Intro: "Be cool, but also be warm.", + }) + require.Nil(t, err) + + t.Run("normal", func(t *testing.T) { + err := db.SetName(ctx, 1, "new-name") + require.Nil(t, err) + + got, err := db.GetByID(ctx, 1) + require.Nil(t, err) + require.Equal(t, "new-name", got.Name) + }) + + t.Run("empty name", func(t *testing.T) { + err := db.SetName(ctx, 1, " ") + require.EqualError(t, err, "name cannot be empty") + }) +} + func testUsersUpdateHarassmentSetting(t *testing.T, ctx context.Context, db *users) { err := db.Create(ctx, CreateUserOptions{ Name: "E99p1ant", diff --git a/internal/route/route.go b/internal/route/route.go index 816cd42a..be336e0c 100644 --- a/internal/route/route.go +++ b/internal/route/route.go @@ -98,6 +98,9 @@ func New(db *gorm.DB) *flamego.Flame { recaptcha.Options{ Secret: conf.Recaptcha.ServerKey, VerifyURL: func() recaptcha.VerifyURL { + if conf.Recaptcha.VerifyURL != "" { + return recaptcha.VerifyURL(conf.Recaptcha.VerifyURL) + } if conf.Recaptcha.TurnstileStyle { // FYI: https://developers.cloudflare.com/turnstile/migration/migrating-from-recaptcha/ return "https://challenges.cloudflare.com/turnstile/v0/siteverify" diff --git a/internal/route/user.go b/internal/route/user.go index a740eaf9..88a1dad2 100644 --- a/internal/route/user.go +++ b/internal/route/user.go @@ -14,6 +14,7 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/config" "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/feature/s3/manager" "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/flamego/recaptcha" "github.com/pkg/errors" @@ -300,7 +301,8 @@ func uploadImageFile(ctx context.Context, options uploadImageFileOptions) (*db.U } client := s3.NewFromConfig(cfg) - if _, err := client.PutObject(ctx.Request().Context(), &s3.PutObjectInput{ + uploader := manager.NewUploader(client) + if _, err := uploader.Upload(ctx.Request().Context(), &s3.PutObjectInput{ Bucket: aws.String(conf.Upload.ImageBucket), Key: aws.String(fileKey), Body: reader, diff --git a/web/.env.e2e b/web/.env.e2e new file mode 100644 index 00000000..ac3fbced --- /dev/null +++ b/web/.env.e2e @@ -0,0 +1,6 @@ +VITE_EXTERNAL_URL=http://localhost:3000 +VITE_BASE_URL=/api +VITE_RECAPTCHA_SITE_KEY=6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI +VITE_ICP= +VITE_COMMIT_SHA=e2e + diff --git a/web/package.json b/web/package.json index 4121a4f5..e826c8ad 100644 --- a/web/package.json +++ b/web/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "dev": "vite", + "dev:e2e": "vite --mode e2e", "build": "vue-tsc -b && vite build", "preview": "vite preview" }, diff --git a/web/src/components/NewQuestion.vue b/web/src/components/NewQuestion.vue index 02919a19..13624b3d 100644 --- a/web/src/components/NewQuestion.vue +++ b/web/src/components/NewQuestion.vue @@ -69,6 +69,7 @@ import {Form} from "vee-validate"; import {ToastError} from "@/utils/notify.ts"; import {type IReCaptchaComposition, useReCaptcha} from "vue-recaptcha-v3"; import {useRoute} from "vue-router"; +import {ensureRecaptchaReady, getRecaptchaToken} from "@/utils/recaptcha.ts"; const route = useRoute(); @@ -106,7 +107,7 @@ const successMessage = ref('') onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -114,8 +115,7 @@ onMounted(async () => { }) const handleSubmit = async () => { try { - await recaptchaLoaded() - postQuestionForm.value.recaptcha = await executeRecaptcha('submit') + postQuestionForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/pages/auth/ForgotPassword.vue b/web/src/pages/auth/ForgotPassword.vue index 170091eb..839973e4 100644 --- a/web/src/pages/auth/ForgotPassword.vue +++ b/web/src/pages/auth/ForgotPassword.vue @@ -24,6 +24,7 @@ import {type ForgotPasswordRequest, forgotPassword} from "@/api/auth.ts"; import {useRouter} from "vue-router"; import {type IReCaptchaComposition, useReCaptcha} from "vue-recaptcha-v3"; import {ToastError, ToastSuccess} from "@/utils/notify.ts"; +import {ensureRecaptchaReady, getRecaptchaToken} from "@/utils/recaptcha.ts"; const router = useRouter() const {executeRecaptcha, recaptchaLoaded} = useReCaptcha() as IReCaptchaComposition @@ -37,7 +38,7 @@ const forgotPasswordForm = ref({ onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -45,8 +46,7 @@ onMounted(async () => { }) const handleForgotPassword = async () => { try { - await recaptchaLoaded() - forgotPasswordForm.value.recaptcha = await executeRecaptcha('submit') + forgotPasswordForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/pages/auth/SignIn.vue b/web/src/pages/auth/SignIn.vue index 4b339305..10ffa40c 100644 --- a/web/src/pages/auth/SignIn.vue +++ b/web/src/pages/auth/SignIn.vue @@ -35,6 +35,7 @@ import {useRoute, useRouter} from "vue-router"; import {ToastError, ToastSuccess} from "@/utils/notify.ts"; import {useAuthStore} from "@/store"; import {type IReCaptchaComposition, useReCaptcha} from 'vue-recaptcha-v3' +import {ensureRecaptchaReady, getRecaptchaToken} from '@/utils/recaptcha.ts' const route = useRoute() const router = useRouter() @@ -51,7 +52,7 @@ const signInForm = ref({ onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -60,8 +61,7 @@ onMounted(async () => { const handleSignIn = async () => { try { - await recaptchaLoaded() - signInForm.value.recaptcha = await executeRecaptcha('submit') + signInForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/pages/auth/SignUp.vue b/web/src/pages/auth/SignUp.vue index 29f3210a..59026757 100644 --- a/web/src/pages/auth/SignUp.vue +++ b/web/src/pages/auth/SignUp.vue @@ -51,6 +51,7 @@ import {ToastError, ToastSuccess} from "@/utils/notify.ts"; import {type IReCaptchaComposition, useReCaptcha} from "vue-recaptcha-v3"; import {useRouter} from "vue-router"; import {ExternalURL} from "@/utils/consts.ts"; +import {ensureRecaptchaReady, getRecaptchaToken} from '@/utils/recaptcha.ts' const router = useRouter() const {executeRecaptcha, recaptchaLoaded} = useReCaptcha() as IReCaptchaComposition @@ -68,7 +69,7 @@ const signUpForm = ref({ onMounted(async () => { try { - await recaptchaLoaded() + await ensureRecaptchaReady({executeRecaptcha, recaptchaLoaded}) recaptchaReady.value = true } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') @@ -77,8 +78,7 @@ onMounted(async () => { const handleSignUp = async () => { try { - await recaptchaLoaded() - signUpForm.value.recaptcha = await executeRecaptcha('submit') + signUpForm.value.recaptcha = await getRecaptchaToken({executeRecaptcha, recaptchaLoaded}) } catch (error) { ToastError('无感验证码加载失败,请刷新页面重试') return diff --git a/web/src/utils/recaptcha.ts b/web/src/utils/recaptcha.ts new file mode 100644 index 00000000..b020ef69 --- /dev/null +++ b/web/src/utils/recaptcha.ts @@ -0,0 +1,27 @@ +import type {IReCaptchaComposition} from 'vue-recaptcha-v3' + +const E2E_RECAPTCHA_TOKEN = 'e2e-test-token' +const RECAPTCHA_TEST_SITE_KEY = '6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI' + +type RecaptchaClient = Pick + +export const isE2EMode = import.meta.env.MODE === 'e2e' +export const isUsingRecaptchaTestKey = import.meta.env.VITE_RECAPTCHA_SITE_KEY === RECAPTCHA_TEST_SITE_KEY +const shouldBypassRecaptcha = isE2EMode + +export async function ensureRecaptchaReady(recaptcha: RecaptchaClient) { + if (shouldBypassRecaptcha) { + return + } + + await recaptcha.recaptchaLoaded() +} + +export async function getRecaptchaToken(recaptcha: RecaptchaClient, action = 'submit') { + if (shouldBypassRecaptcha) { + return E2E_RECAPTCHA_TOKEN + } + + await recaptcha.recaptchaLoaded() + return await recaptcha.executeRecaptcha(action) +}