From ec48836dc966022c65e704eb382d200ec24f647c Mon Sep 17 00:00:00 2001 From: tangsongdayo Date: Sun, 19 Jul 2026 13:04:49 +0800 Subject: [PATCH] test: cover atomic source invites --- CHANGELOG.md | 1 + app/api/invites/accept/route.ts | 5 ++--- lib/invites.ts | 14 ++++++++++++++ tests/invites.test.ts | 23 +++++++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 lib/invites.ts create mode 100644 tests/invites.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 69b4b47..1fff681 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ ### Fixed - Tightened standard webhook signature validation to reject invalid timestamps and non-v1 signature schemes. +- Made source invitation consumption atomic so a token cannot be accepted twice during concurrent requests. ## [0.7.0] - 2026-07-19 diff --git a/app/api/invites/accept/route.ts b/app/api/invites/accept/route.ts index c7c0bc1..9b997f4 100644 --- a/app/api/invites/accept/route.ts +++ b/app/api/invites/accept/route.ts @@ -1,8 +1,7 @@ -import { createHash } from "node:crypto"; import { NextResponse } from "next/server"; import { requireUser } from "@/lib/auth"; -import { db } from "@/lib/db"; import { audit } from "@/lib/audit"; +import { acceptSourceInvite } from "@/lib/invites"; import { externalUrl } from "@/lib/http"; import { requireSameOrigin } from "@/lib/security"; -export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const token = String(form.get("token") || ""); const hash = createHash("sha256").update(token).digest("hex"); const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined; if (!invite) throw new Error("邀請不存在、已使用或已過期"); db.transaction(() => { db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, user.id, invite.role); db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=?").run(invite.id); })(); audit(user.id, "source.invite.accept", "source", invite.source_id, { role: invite.role }); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } } +export async function POST(request: Request) { try { requireSameOrigin(request); const user = await requireUser(); const form = await request.formData(); const invite = acceptSourceInvite(user.id, String(form.get("token") || "")); audit(user.id, "source.invite.accept", "source", invite.source_id, { role: invite.role }); return NextResponse.redirect(externalUrl(request, "/dashboard?source=joined")); } catch (error) { return NextResponse.redirect(externalUrl(request, "/dashboard?error=" + encodeURIComponent(error instanceof Error ? error.message : "invite"))); } } diff --git a/lib/invites.ts b/lib/invites.ts new file mode 100644 index 0000000..bfe8948 --- /dev/null +++ b/lib/invites.ts @@ -0,0 +1,14 @@ +import { createHash } from "node:crypto"; +import { db } from "@/lib/db"; + +export function acceptSourceInvite(userId: number, token: string) { + const hash = createHash("sha256").update(token).digest("hex"); + return db.transaction(() => { + const invite = db.prepare("SELECT id,source_id,role FROM source_invites WHERE token_hash=? AND used_at IS NULL AND expires_at>CURRENT_TIMESTAMP").get(hash) as { id: number; source_id: number; role: string } | undefined; + if (!invite) throw new Error("邀請不存在、已使用或已過期"); + const consumed = db.prepare("UPDATE source_invites SET used_at=CURRENT_TIMESTAMP WHERE id=? AND used_at IS NULL").run(invite.id); + if (!consumed.changes) throw new Error("邀請不存在、已使用或已過期"); + db.prepare("INSERT INTO source_members(source_id,user_id,role) VALUES(?,?,?) ON CONFLICT(source_id,user_id) DO UPDATE SET role=excluded.role").run(invite.source_id, userId, invite.role); + return invite; + })(); +} diff --git a/tests/invites.test.ts b/tests/invites.test.ts new file mode 100644 index 0000000..9bbd8d1 --- /dev/null +++ b/tests/invites.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { after, test } from "node:test"; +import { createHash, randomUUID } from "node:crypto"; +import { rmSync } from "node:fs"; + +const databasePath = `/tmp/mebbling-invites-${randomUUID()}.db`; +process.env.DATABASE_PATH = databasePath; +delete process.env.HUB_BUILD; +let database: typeof import("../lib/db").db | undefined; +after(() => { database?.close(); rmSync(databasePath, { force: true }); rmSync(`${databasePath}-wal`, { force: true }); rmSync(`${databasePath}-shm`, { force: true }); }); + +test("consumes a source invitation exactly once and grants its role", async () => { + const { db } = await import("../lib/db"); database = db; + const { acceptSourceInvite } = await import("../lib/invites"); + const owner = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('owner','hash')").run().lastInsertRowid); + const member = Number(db.prepare("INSERT INTO users(username,password_hash) VALUES('member','hash')").run().lastInsertRowid); + const source = Number(db.prepare("INSERT INTO sources(user_id,name,base_url,token_encrypted) VALUES(?,?,?,?)").run(owner, "Source", "https://example.test", "encrypted").lastInsertRowid); + const token = "single-use-token"; + db.prepare("INSERT INTO source_invites(source_id,token_hash,role,expires_at,created_by) VALUES(?,?,?,datetime('now','+1 day'),?)").run(source, createHash("sha256").update(token).digest("hex"), "editor", owner); + assert.deepEqual(acceptSourceInvite(member, token), { id: 1, source_id: source, role: "editor" }); + assert.equal((db.prepare("SELECT role FROM source_members WHERE source_id=? AND user_id=?").get(source, member) as { role: string }).role, "editor"); + assert.throws(() => acceptSourceInvite(member, token), /已使用/); +});