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
26describe('generateAndPublishKey', () => {
27 beforeEach(async () => {
28 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
29 clearEncryptionKeyCache()
30
31 setDb(await createTestDb())
32 const db = useDb()
33 await db.insert(installation).values({
34 id: 1,
35 accountLogin: 'alice',
36 accountId: 100,
37 accountType: 'User',
38 })
39
40 createRecordMock.mockReset()
41 createRecordMock.mockResolvedValue({
42 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' },
43 })
44 })
45
46 afterEach(() => {
47 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
48 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
49 clearEncryptionKeyCache()
50 clearDb()
51 })
52
53 function fakeOauthSession(did: string) {
54 // The Agent mock above ignores its constructor argument, so we only need
55 // a `.did` field for the helper itself.
56 return { did } as never
57 }
58
59 it('generates a key, publishes to PDS, and stores the encrypted private half', async () => {
60 const result = await generateAndPublishKey({
61 oauthSession: fakeOauthSession('did:plc:abc'),
62 installationId: 1,
63 })
64
65 expect(result.created).toBe(true)
66 expect(createRecordMock).toHaveBeenCalledTimes(1)
67 const call = createRecordMock.mock.calls[0]![0]
68 expect(call.repo).toBe('did:plc:abc')
69 expect(call.collection).toBe('sh.tangled.publicKey')
70 expect(call.record.$type).toBe('sh.tangled.publicKey')
71 expect(call.record.key).toMatch(/^ssh-ed25519 /)
72 expect(call.record.name).toBe('synchub.to/1')
73
74 const db = useDb()
75 const rows = await db.select().from(sshKey)
76 .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`)
77 expect(rows).toHaveLength(1)
78 const row = rows[0]!
79 expect(row.publicKey).toMatch(/^ssh-ed25519 /)
80 expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v')
81
82 const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce)
83 expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/)
84 expect(decrypted).toContain('-----END PRIVATE KEY-----')
85 })
86
87 it('no-ops if a key already exists for (installation, did)', async () => {
88 await generateAndPublishKey({
89 oauthSession: fakeOauthSession('did:plc:abc'),
90 installationId: 1,
91 })
92 expect(createRecordMock).toHaveBeenCalledTimes(1)
93
94 const result = await generateAndPublishKey({
95 oauthSession: fakeOauthSession('did:plc:abc'),
96 installationId: 1,
97 })
98 expect(result.created).toBe(false)
99 expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again
100
101 const db = useDb()
102 const rows = await db.select().from(sshKey)
103 expect(rows).toHaveLength(1)
104 })
105
106 it('does not write a row if the PDS publish fails', async () => {
107 createRecordMock.mockRejectedValueOnce(new Error('pds is sad'))
108
109 await expect(generateAndPublishKey({
110 oauthSession: fakeOauthSession('did:plc:abc'),
111 installationId: 1,
112 })).rejects.toThrow(/pds is sad/)
113
114 const db = useDb()
115 const rows = await db.select().from(sshKey)
116 expect(rows).toHaveLength(0)
117 })
118})