mirror your GitHub repos to tangled.org automatically
1

Configure Feed

Select the types of activity you want to include in your feed.

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, rotateKey } 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 } }>>() 13const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise<unknown>>() 14 15vi.mock('@atproto/api', () => ({ 16 Agent: class { 17 com = { 18 atproto: { 19 repo: { 20 createRecord: createRecordMock, 21 deleteRecord: deleteRecordMock, 22 }, 23 }, 24 } 25 }, 26})) 27 28function fakeOauthSession(did: string) { 29 // The Agent mock above ignores its constructor argument, so we only need 30 // a `.did` field for the helper itself. 31 // eslint-disable-next-line ts/no-unsafe-type-assertion 32 return { did } as unknown as Parameters<typeof generateAndPublishKey>[0]['oauthSession'] 33} 34 35describe('generateAndPublishKey', () => { 36 beforeEach(async () => { 37 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 38 clearEncryptionKeyCache() 39 40 setDb(await createTestDb()) 41 const db = useDb() 42 await db.insert(installation).values({ 43 id: 1, 44 accountLogin: 'alice', 45 accountId: 100, 46 accountType: 'User', 47 }) 48 49 createRecordMock.mockReset() 50 deleteRecordMock.mockReset() 51 createRecordMock.mockResolvedValue({ 52 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, 53 }) 54 deleteRecordMock.mockResolvedValue({}) 55 }) 56 57 afterEach(() => { 58 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 59 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 60 clearEncryptionKeyCache() 61 clearDb() 62 }) 63 64 it('generates a key, publishes to PDS, and stores the encrypted private half', async () => { 65 const result = await generateAndPublishKey({ 66 oauthSession: fakeOauthSession('did:plc:abc'), 67 installationId: 1, 68 }) 69 70 expect(result.created).toBe(true) 71 expect(createRecordMock).toHaveBeenCalledTimes(1) 72 const call = createRecordMock.mock.calls[0][0] 73 expect(call.repo).toBe('did:plc:abc') 74 expect(call.collection).toBe('sh.tangled.publicKey') 75 expect(call.record.$type).toBe('sh.tangled.publicKey') 76 expect(call.record.key).toMatch(/^ssh-ed25519 /) 77 expect(call.record.name).toBe('synchub.to/1') 78 79 const db = useDb() 80 const rows = await db.select().from(sshKey) 81 .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`) 82 expect(rows).toHaveLength(1) 83 const row = rows[0] 84 expect(row.publicKey).toMatch(/^ssh-ed25519 /) 85 expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v') 86 87 const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce) 88 expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/) 89 expect(decrypted).toContain('-----END PRIVATE KEY-----') 90 }) 91 92 it('no-ops if a key already exists for (installation, did)', async () => { 93 await generateAndPublishKey({ 94 oauthSession: fakeOauthSession('did:plc:abc'), 95 installationId: 1, 96 }) 97 expect(createRecordMock).toHaveBeenCalledTimes(1) 98 99 const result = await generateAndPublishKey({ 100 oauthSession: fakeOauthSession('did:plc:abc'), 101 installationId: 1, 102 }) 103 expect(result.created).toBe(false) 104 expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again 105 106 const db = useDb() 107 const rows = await db.select().from(sshKey) 108 expect(rows).toHaveLength(1) 109 }) 110 111 it('does not write a row if the PDS publish fails', async () => { 112 createRecordMock.mockRejectedValueOnce(new Error('pds is sad')) 113 114 await expect(generateAndPublishKey({ 115 oauthSession: fakeOauthSession('did:plc:abc'), 116 installationId: 1, 117 })).rejects.toThrow(/pds is sad/) 118 119 const db = useDb() 120 const rows = await db.select().from(sshKey) 121 expect(rows).toHaveLength(0) 122 }) 123}) 124 125describe('rotateKey', () => { 126 beforeEach(async () => { 127 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 128 clearEncryptionKeyCache() 129 130 setDb(await createTestDb()) 131 await useDb().insert(installation).values({ 132 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 133 }) 134 135 createRecordMock.mockReset() 136 deleteRecordMock.mockReset() 137 let counter = 0 138 createRecordMock.mockImplementation(async () => { 139 counter += 1 140 return { data: { uri: `at://did:plc:abc/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } 141 }) 142 deleteRecordMock.mockResolvedValue({}) 143 }) 144 145 afterEach(() => { 146 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 147 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 148 clearEncryptionKeyCache() 149 clearDb() 150 }) 151 152 it('deletes the old PDS record and publishes a fresh key', async () => { 153 await generateAndPublishKey({ 154 oauthSession: fakeOauthSession('did:plc:abc'), 155 installationId: 1, 156 }) 157 const db = useDb() 158 const before = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) 159 expect(before).toHaveLength(1) 160 const oldPubKey = before[0].publicKey 161 const oldRkey = before[0].tangledKeyRkey 162 163 const result = await rotateKey({ 164 oauthSession: fakeOauthSession('did:plc:abc'), 165 installationId: 1, 166 }) 167 expect(result.created).toBe(true) 168 169 expect(deleteRecordMock).toHaveBeenCalledTimes(1) 170 const del = deleteRecordMock.mock.calls[0][0] 171 expect(del.repo).toBe('did:plc:abc') 172 expect(del.collection).toBe('sh.tangled.publicKey') 173 expect(del.rkey).toBe(oldRkey) 174 175 const after = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) 176 expect(after).toHaveLength(1) 177 expect(after[0].publicKey).not.toBe(oldPubKey) 178 expect(after[0].tangledKeyRkey).toBe('rkey-2') 179 }) 180 181 it('proceeds when the PDS reports the record is already gone (404)', async () => { 182 await generateAndPublishKey({ 183 oauthSession: fakeOauthSession('did:plc:abc'), 184 installationId: 1, 185 }) 186 187 const notFound: Error & { status: number } = Object.assign(new Error('not found'), { status: 404 }) 188 deleteRecordMock.mockRejectedValueOnce(notFound) 189 190 const result = await rotateKey({ 191 oauthSession: fakeOauthSession('did:plc:abc'), 192 installationId: 1, 193 }) 194 expect(result.created).toBe(true) 195 196 const db = useDb() 197 const rows = await db.select().from(sshKey) 198 expect(rows).toHaveLength(1) 199 expect(rows[0].tangledKeyRkey).toBe('rkey-2') 200 }) 201 202 it('aborts the rotation if the PDS delete fails for a non-404 reason', async () => { 203 await generateAndPublishKey({ 204 oauthSession: fakeOauthSession('did:plc:abc'), 205 installationId: 1, 206 }) 207 208 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) 209 210 await expect(rotateKey({ 211 oauthSession: fakeOauthSession('did:plc:abc'), 212 installationId: 1, 213 })).rejects.toThrow(/boom/) 214 215 // Old row still present, no second createRecord call. 216 const db = useDb() 217 const rows = await db.select().from(sshKey) 218 expect(rows).toHaveLength(1) 219 expect(rows[0].tangledKeyRkey).toBe('rkey-1') 220 expect(createRecordMock).toHaveBeenCalledTimes(1) 221 }) 222 223 it('mints a fresh key even if there is no existing row', async () => { 224 const result = await rotateKey({ 225 oauthSession: fakeOauthSession('did:plc:abc'), 226 installationId: 1, 227 }) 228 expect(result.created).toBe(true) 229 expect(deleteRecordMock).not.toHaveBeenCalled() 230 const db = useDb() 231 const rows = await db.select().from(sshKey) 232 expect(rows).toHaveLength(1) 233 }) 234})