mirror your GitHub repos to tangled.org automatically
1import crypto from 'node:crypto'
2import { sql } from 'drizzle-orm'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4import { installation, sshKey } from '../../server/db/schema'
5import { clearDb, setDb, useDb } from '../../server/utils/db'
6import { clearEncryptionKeyCache, decrypt } from '../../server/utils/encryption'
7import { generateAndPublishKey } from '../../server/utils/tangled-pubkey'
8import { createTestDb } from '../utils/db'
9
10const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
11
12const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>()
13
14vi.mock('@atproto/api', () => ({
15 Agent: class {
16 com = {
17 atproto: {
18 repo: {
19 createRecord: createRecordMock,
20 },
21 },
22 }
23 },
24}))
25
26function fakeOauthSession(did: string) {
27 // The Agent mock above ignores its constructor argument, so we only need
28 // a `.did` field for the helper itself.
29 // eslint-disable-next-line ts/no-unsafe-type-assertion
30 return { did } as unknown as Parameters<typeof generateAndPublishKey>[0]['oauthSession']
31}
32
33describe('generateAndPublishKey', () => {
34 beforeEach(async () => {
35 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
36 clearEncryptionKeyCache()
37
38 setDb(await createTestDb())
39 const db = useDb()
40 await db.insert(installation).values({
41 id: 1,
42 accountLogin: 'alice',
43 accountId: 100,
44 accountType: 'User',
45 })
46
47 createRecordMock.mockReset()
48 createRecordMock.mockResolvedValue({
49 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' },
50 })
51 })
52
53 afterEach(() => {
54 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
55 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
56 clearEncryptionKeyCache()
57 clearDb()
58 })
59
60 it('generates a key, publishes to PDS, and stores the encrypted private half', async () => {
61 const result = await generateAndPublishKey({
62 oauthSession: fakeOauthSession('did:plc:abc'),
63 installationId: 1,
64 })
65
66 expect(result.created).toBe(true)
67 expect(createRecordMock).toHaveBeenCalledTimes(1)
68 const call = createRecordMock.mock.calls[0][0]
69 expect(call.repo).toBe('did:plc:abc')
70 expect(call.collection).toBe('sh.tangled.publicKey')
71 expect(call.record.$type).toBe('sh.tangled.publicKey')
72 expect(call.record.key).toMatch(/^ssh-ed25519 /)
73 expect(call.record.name).toBe('synchub.to/1')
74
75 const db = useDb()
76 const rows = await db.select().from(sshKey)
77 .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`)
78 expect(rows).toHaveLength(1)
79 const row = rows[0]
80 expect(row.publicKey).toMatch(/^ssh-ed25519 /)
81 expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v')
82
83 const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
84 expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/)
85 expect(decrypted).toContain('-----END PRIVATE KEY-----')
86 })
87
88 it('no-ops if a key already exists for (installation, did)', async () => {
89 await generateAndPublishKey({
90 oauthSession: fakeOauthSession('did:plc:abc'),
91 installationId: 1,
92 })
93 expect(createRecordMock).toHaveBeenCalledTimes(1)
94
95 const result = await generateAndPublishKey({
96 oauthSession: fakeOauthSession('did:plc:abc'),
97 installationId: 1,
98 })
99 expect(result.created).toBe(false)
100 expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again
101
102 const db = useDb()
103 const rows = await db.select().from(sshKey)
104 expect(rows).toHaveLength(1)
105 })
106
107 it('does not write a row if the PDS publish fails', async () => {
108 createRecordMock.mockRejectedValueOnce(new Error('pds is sad'))
109
110 await expect(generateAndPublishKey({
111 oauthSession: fakeOauthSession('did:plc:abc'),
112 installationId: 1,
113 })).rejects.toThrow(/pds is sad/)
114
115 const db = useDb()
116 const rows = await db.select().from(sshKey)
117 expect(rows).toHaveLength(0)
118 })
119})