Skip to content

Commit 277bd76

Browse files
fix linked unverfied nodes to verified account
1 parent acae49e commit 277bd76

2 files changed

Lines changed: 192 additions & 2 deletions

File tree

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import { randomUUID } from "node:crypto";
2+
import { sql } from "drizzle-orm";
3+
import { describe, expect, it } from "vitest";
4+
import { linkDevices, deviceLinkStatus } from "@/lib/account-link";
5+
import { executeMagicVerify } from "@/lib/magic-verify";
6+
import { deviceAccountHash, emailHash } from "@/lib/crypto";
7+
import { db } from "@/db";
8+
9+
async function createNode(localId: string, code: string) {
10+
await db.execute(sql`
11+
INSERT INTO nodes (code, local_id, class, depth, path)
12+
VALUES (${code}, ${localId}, 'human', 0, '1')
13+
`);
14+
}
15+
16+
async function accountExistsForHash(hash: string): Promise<boolean> {
17+
const r = (await db.execute(sql`
18+
SELECT 1 FROM accounts WHERE email_hash = ${hash} LIMIT 1;
19+
`)) as unknown as { rows: unknown[] };
20+
return !!r.rows?.[0];
21+
}
22+
23+
async function cleanup(localIds: string[], nonce?: string) {
24+
if (nonce) {
25+
await db.execute(sql`DELETE FROM magic_tokens WHERE nonce = ${nonce}`);
26+
}
27+
const accountIds = new Set<number>();
28+
for (const localId of localIds) {
29+
const r = (await db.execute(sql`
30+
SELECT account_id AS "accountId" FROM nodes WHERE local_id = ${localId};
31+
`)) as unknown as { rows: { accountId: number | null }[] };
32+
const accountId = r.rows?.[0]?.accountId;
33+
if (accountId != null) accountIds.add(accountId);
34+
await db.execute(sql`DELETE FROM nodes WHERE local_id = ${localId}`);
35+
}
36+
for (const accountId of accountIds) {
37+
await db.execute(sql`
38+
DELETE FROM accounts a
39+
WHERE a.id = ${accountId}
40+
AND NOT EXISTS (SELECT 1 FROM nodes n WHERE n.account_id = a.id);
41+
`);
42+
}
43+
}
44+
45+
describe("executeMagicVerify with linked devices", () => {
46+
it("keeps device links when verifying email on desktop after link", async () => {
47+
const desktop = randomUUID();
48+
const mobile = randomUUID();
49+
const nonce = `test-${randomUUID()}`;
50+
const email = `test-${randomUUID()}@example.com`;
51+
52+
await createNode(desktop, `d${randomUUID().slice(0, 6)}`);
53+
await createNode(mobile, `m${randomUUID().slice(0, 6)}`);
54+
55+
try {
56+
const linkResult = await linkDevices(desktop, mobile);
57+
expect(linkResult.ok).toBe(true);
58+
59+
const afterLinkDesktop = await deviceLinkStatus(desktop);
60+
const afterLinkMobile = await deviceLinkStatus(mobile);
61+
expect(afterLinkDesktop?.devicesLinked).toBe(true);
62+
expect(afterLinkMobile?.devicesLinked).toBe(true);
63+
64+
// linkDevices(desktop, …) minted a synthetic account keyed on the source.
65+
const syntheticHash = deviceAccountHash(desktop);
66+
expect(await accountExistsForHash(syntheticHash)).toBe(true);
67+
68+
await db.execute(sql`
69+
INSERT INTO magic_tokens (nonce, email_hash, local_id, expires_at)
70+
VALUES (${nonce}, ${emailHash(email)}, ${desktop}, now() + interval '30 minutes')
71+
`);
72+
73+
const verifyResult = await executeMagicVerify(nonce, desktop);
74+
expect(verifyResult).toBe("ok");
75+
76+
const afterVerifyDesktop = await deviceLinkStatus(desktop);
77+
const afterVerifyMobile = await deviceLinkStatus(mobile);
78+
expect(afterVerifyDesktop?.emailVerified).toBe(true);
79+
expect(afterVerifyDesktop?.devicesLinked).toBe(true);
80+
expect(afterVerifyMobile?.devicesLinked).toBe(true);
81+
expect(afterVerifyDesktop?.siblingCount).toBe(1);
82+
expect(afterVerifyMobile?.siblingCount).toBe(1);
83+
84+
// The now-empty synthetic account must be cleaned up (no orphan rows).
85+
expect(await accountExistsForHash(syntheticHash)).toBe(false);
86+
expect(await accountExistsForHash(emailHash(email))).toBe(true);
87+
} finally {
88+
await cleanup([desktop, mobile], nonce);
89+
}
90+
});
91+
92+
it("keeps device links when magic token was issued before link", async () => {
93+
const desktop = randomUUID();
94+
const mobile = randomUUID();
95+
const nonce = `test-${randomUUID()}`;
96+
const email = `test-${randomUUID()}@example.com`;
97+
98+
await createNode(desktop, `d${randomUUID().slice(0, 6)}`);
99+
await createNode(mobile, `m${randomUUID().slice(0, 6)}`);
100+
101+
try {
102+
await db.execute(sql`
103+
INSERT INTO magic_tokens (nonce, email_hash, local_id, expires_at)
104+
VALUES (${nonce}, ${emailHash(email)}, ${desktop}, now() + interval '30 minutes')
105+
`);
106+
107+
expect((await linkDevices(desktop, mobile)).ok).toBe(true);
108+
expect(await executeMagicVerify(nonce, desktop)).toBe("ok");
109+
110+
expect((await deviceLinkStatus(desktop))?.devicesLinked).toBe(true);
111+
expect((await deviceLinkStatus(mobile))?.devicesLinked).toBe(true);
112+
} finally {
113+
await cleanup([desktop, mobile], nonce);
114+
}
115+
});
116+
117+
it("keeps device links when mobile is link source and desktop verifies", async () => {
118+
const desktop = randomUUID();
119+
const mobile = randomUUID();
120+
const nonce = `test-${randomUUID()}`;
121+
const email = `test-${randomUUID()}@example.com`;
122+
123+
await createNode(desktop, `d${randomUUID().slice(0, 6)}`);
124+
await createNode(mobile, `m${randomUUID().slice(0, 6)}`);
125+
126+
try {
127+
expect((await linkDevices(mobile, desktop)).ok).toBe(true);
128+
129+
await db.execute(sql`
130+
INSERT INTO magic_tokens (nonce, email_hash, local_id, expires_at)
131+
VALUES (${nonce}, ${emailHash(email)}, ${desktop}, now() + interval '30 minutes')
132+
`);
133+
134+
expect(await executeMagicVerify(nonce, desktop)).toBe("ok");
135+
136+
expect((await deviceLinkStatus(desktop))?.devicesLinked).toBe(true);
137+
expect((await deviceLinkStatus(mobile))?.devicesLinked).toBe(true);
138+
} finally {
139+
await cleanup([desktop, mobile], nonce);
140+
}
141+
});
142+
143+
it("keeps device links when target device verifies after link", async () => {
144+
const desktop = randomUUID();
145+
const mobile = randomUUID();
146+
const nonce = `test-${randomUUID()}`;
147+
const email = `test-${randomUUID()}@example.com`;
148+
149+
await createNode(desktop, `d${randomUUID().slice(0, 6)}`);
150+
await createNode(mobile, `m${randomUUID().slice(0, 6)}`);
151+
152+
try {
153+
expect((await linkDevices(desktop, mobile)).ok).toBe(true);
154+
155+
await db.execute(sql`
156+
INSERT INTO magic_tokens (nonce, email_hash, local_id, expires_at)
157+
VALUES (${nonce}, ${emailHash(email)}, ${mobile}, now() + interval '30 minutes')
158+
`);
159+
160+
expect(await executeMagicVerify(nonce, mobile)).toBe("ok");
161+
162+
expect((await deviceLinkStatus(desktop))?.devicesLinked).toBe(true);
163+
expect((await deviceLinkStatus(mobile))?.devicesLinked).toBe(true);
164+
} finally {
165+
await cleanup([desktop, mobile], nonce);
166+
}
167+
});
168+
});

src/lib/magic-verify.ts

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,36 @@ export async function executeMagicVerify(
7878
SELECT
7979
(SELECT COUNT(*)::int FROM consumed) AS consumed,
8080
(SELECT COUNT(*)::int FROM linked) AS linked,
81-
(SELECT id FROM acc LIMIT 1) AS "accountId";
82-
`)) as unknown as { rows: { consumed: number; linked: number; accountId: number | null }[] };
81+
(SELECT id FROM acc LIMIT 1) AS "accountId",
82+
(SELECT old_account_id FROM pre LIMIT 1) AS "oldAccountId";
83+
`)) as unknown as {
84+
rows: {
85+
consumed: number;
86+
linked: number;
87+
accountId: number | null;
88+
oldAccountId: number | null;
89+
}[];
90+
};
8391

8492
const row = result.rows?.[0];
8593
if (!row || Number(row.consumed) === 0) return "token_invalid";
8694
if (row.accountId == null || Number(row.linked) === 0) {
8795
throw new Error("MAGIC_VERIFY_INCOMPLETE");
8896
}
97+
98+
// The device's prior account (e.g. a synthetic device-link account keyed by
99+
// deviceAccountHash) just had all its nodes migrated onto the email account.
100+
// Drop it if it's now empty so we don't leak orphaned account rows. Separate
101+
// statement: a data-modifying CTE can't see the `linked` UPDATE's effect on the
102+
// same snapshot, and the FK (nodes.account_id → accounts.id) is RESTRICT.
103+
if (row.oldAccountId != null && row.oldAccountId !== row.accountId) {
104+
await db.execute(sql`
105+
DELETE FROM accounts a
106+
WHERE a.id = ${row.oldAccountId}
107+
AND NOT EXISTS (SELECT 1 FROM nodes n WHERE n.account_id = a.id);
108+
`);
109+
}
110+
89111
return "ok";
90112
}
91113

0 commit comments

Comments
 (0)