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 { createTestDb } from '../utils/db' 8 9const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 10 11const createRecordMock = vi.fn<(input: { repo: string, collection: string, record: Record<string, unknown> }) => Promise<{ data: { uri: string, cid: string } }>>() 12const deleteRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string }) => Promise<unknown>>() 13const restoreMock = vi.fn<(did: string) => Promise<{ did: string }>>() 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 28vi.mock('../../server/utils/atproto-oauth', () => ({ 29 useOAuthClient: async () => ({ restore: restoreMock }), 30})) 31 32const { generateAndPublishKey, revokeKeyForInstallationDid, revokeKeysForInstallation, rotateKey } = await import('../../server/utils/tangled-pubkey') 33 34function fakeOauthSession(did: string) { 35 // The Agent mock above ignores its constructor argument, so we only need 36 // a `.did` field for the helper itself. 37 // eslint-disable-next-line ts/no-unsafe-type-assertion 38 return { did } as unknown as Parameters<typeof generateAndPublishKey>[0]['oauthSession'] 39} 40 41describe('generateAndPublishKey', () => { 42 beforeEach(async () => { 43 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 44 clearEncryptionKeyCache() 45 46 setDb(await createTestDb()) 47 const db = useDb() 48 await db.insert(installation).values({ 49 id: 1, 50 accountLogin: 'alice', 51 accountId: 100, 52 accountType: 'User', 53 }) 54 55 createRecordMock.mockReset() 56 deleteRecordMock.mockReset() 57 createRecordMock.mockResolvedValue({ 58 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, 59 }) 60 deleteRecordMock.mockResolvedValue({}) 61 }) 62 63 afterEach(() => { 64 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 65 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 66 clearEncryptionKeyCache() 67 clearDb() 68 }) 69 70 it('generates a key, publishes to PDS, and stores the encrypted private half', async () => { 71 const result = await generateAndPublishKey({ 72 oauthSession: fakeOauthSession('did:plc:abc'), 73 installationId: 1, 74 }) 75 76 expect(result.created).toBe(true) 77 expect(createRecordMock).toHaveBeenCalledTimes(1) 78 const call = createRecordMock.mock.calls[0][0] 79 expect(call.repo).toBe('did:plc:abc') 80 expect(call.collection).toBe('sh.tangled.publicKey') 81 expect(call.record.$type).toBe('sh.tangled.publicKey') 82 expect(call.record.key).toMatch(/^ssh-ed25519 /) 83 expect(call.record.name).toBe('synchub.to/1') 84 85 const db = useDb() 86 const rows = await db.select().from(sshKey) 87 .where(sql`${sshKey.installationId} = 1 AND ${sshKey.did} = 'did:plc:abc'`) 88 expect(rows).toHaveLength(1) 89 const row = rows[0] 90 expect(row.publicKey).toMatch(/^ssh-ed25519 /) 91 expect(row.tangledKeyRkey).toBe('3kh2y4xq2lk2v') 92 93 const decrypted = decrypt(row.privateKeyCiphertext, row.privateKeyNonce) 94 expect(decrypted).toMatch(/^-----BEGIN PRIVATE KEY-----/) 95 expect(decrypted).toContain('-----END PRIVATE KEY-----') 96 }) 97 98 it('no-ops if a key already exists for (installation, did)', async () => { 99 await generateAndPublishKey({ 100 oauthSession: fakeOauthSession('did:plc:abc'), 101 installationId: 1, 102 }) 103 expect(createRecordMock).toHaveBeenCalledTimes(1) 104 105 const result = await generateAndPublishKey({ 106 oauthSession: fakeOauthSession('did:plc:abc'), 107 installationId: 1, 108 }) 109 expect(result.created).toBe(false) 110 expect(createRecordMock).toHaveBeenCalledTimes(1) // not called again 111 112 const db = useDb() 113 const rows = await db.select().from(sshKey) 114 expect(rows).toHaveLength(1) 115 }) 116 117 it('does not write a row if the PDS publish fails', async () => { 118 createRecordMock.mockRejectedValueOnce(new Error('pds is sad')) 119 120 await expect(generateAndPublishKey({ 121 oauthSession: fakeOauthSession('did:plc:abc'), 122 installationId: 1, 123 })).rejects.toThrow(/pds is sad/) 124 125 const db = useDb() 126 const rows = await db.select().from(sshKey) 127 expect(rows).toHaveLength(0) 128 }) 129}) 130 131describe('rotateKey', () => { 132 beforeEach(async () => { 133 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 134 clearEncryptionKeyCache() 135 136 setDb(await createTestDb()) 137 await useDb().insert(installation).values({ 138 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 139 }) 140 141 createRecordMock.mockReset() 142 deleteRecordMock.mockReset() 143 let counter = 0 144 createRecordMock.mockImplementation(async () => { 145 counter += 1 146 return { data: { uri: `at://did:plc:abc/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } 147 }) 148 deleteRecordMock.mockResolvedValue({}) 149 }) 150 151 afterEach(() => { 152 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 153 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 154 clearEncryptionKeyCache() 155 clearDb() 156 }) 157 158 it('deletes the old PDS record and publishes a fresh key', async () => { 159 await generateAndPublishKey({ 160 oauthSession: fakeOauthSession('did:plc:abc'), 161 installationId: 1, 162 }) 163 const db = useDb() 164 const before = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) 165 expect(before).toHaveLength(1) 166 const oldPubKey = before[0].publicKey 167 const oldRkey = before[0].tangledKeyRkey 168 169 const result = await rotateKey({ 170 oauthSession: fakeOauthSession('did:plc:abc'), 171 installationId: 1, 172 }) 173 expect(result.created).toBe(true) 174 175 expect(deleteRecordMock).toHaveBeenCalledTimes(1) 176 const del = deleteRecordMock.mock.calls[0][0] 177 expect(del.repo).toBe('did:plc:abc') 178 expect(del.collection).toBe('sh.tangled.publicKey') 179 expect(del.rkey).toBe(oldRkey) 180 181 const after = await db.select().from(sshKey).where(sql`${sshKey.installationId} = 1`) 182 expect(after).toHaveLength(1) 183 expect(after[0].publicKey).not.toBe(oldPubKey) 184 expect(after[0].tangledKeyRkey).toBe('rkey-2') 185 }) 186 187 it('proceeds when the PDS reports the record is already gone (404)', async () => { 188 await generateAndPublishKey({ 189 oauthSession: fakeOauthSession('did:plc:abc'), 190 installationId: 1, 191 }) 192 193 const notFound: Error & { status: number } = Object.assign(new Error('not found'), { status: 404 }) 194 deleteRecordMock.mockRejectedValueOnce(notFound) 195 196 const result = await rotateKey({ 197 oauthSession: fakeOauthSession('did:plc:abc'), 198 installationId: 1, 199 }) 200 expect(result.created).toBe(true) 201 202 const db = useDb() 203 const rows = await db.select().from(sshKey) 204 expect(rows).toHaveLength(1) 205 expect(rows[0].tangledKeyRkey).toBe('rkey-2') 206 }) 207 208 it('aborts the rotation if the PDS delete fails for a non-404 reason', async () => { 209 await generateAndPublishKey({ 210 oauthSession: fakeOauthSession('did:plc:abc'), 211 installationId: 1, 212 }) 213 214 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) 215 216 await expect(rotateKey({ 217 oauthSession: fakeOauthSession('did:plc:abc'), 218 installationId: 1, 219 })).rejects.toThrow(/boom/) 220 221 // Old row still present, no second createRecord call. 222 const db = useDb() 223 const rows = await db.select().from(sshKey) 224 expect(rows).toHaveLength(1) 225 expect(rows[0].tangledKeyRkey).toBe('rkey-1') 226 expect(createRecordMock).toHaveBeenCalledTimes(1) 227 }) 228 229 it('mints a fresh key even if there is no existing row', async () => { 230 const result = await rotateKey({ 231 oauthSession: fakeOauthSession('did:plc:abc'), 232 installationId: 1, 233 }) 234 expect(result.created).toBe(true) 235 expect(deleteRecordMock).not.toHaveBeenCalled() 236 const db = useDb() 237 const rows = await db.select().from(sshKey) 238 expect(rows).toHaveLength(1) 239 }) 240}) 241 242describe('revokeKeysForInstallation', () => { 243 beforeEach(async () => { 244 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 245 clearEncryptionKeyCache() 246 247 setDb(await createTestDb()) 248 await useDb().insert(installation).values({ 249 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 250 }) 251 252 createRecordMock.mockReset() 253 deleteRecordMock.mockReset() 254 restoreMock.mockReset() 255 createRecordMock.mockResolvedValue({ 256 data: { uri: 'at://did:plc:abc/sh.tangled.publicKey/3kh2y4xq2lk2v', cid: 'bafy' }, 257 }) 258 deleteRecordMock.mockResolvedValue({}) 259 restoreMock.mockImplementation(async (did: string) => ({ did })) 260 }) 261 262 afterEach(() => { 263 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 264 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 265 clearEncryptionKeyCache() 266 clearDb() 267 }) 268 269 it('deletes the publicKey PDS record for the installation', async () => { 270 await generateAndPublishKey({ 271 oauthSession: fakeOauthSession('did:plc:abc'), 272 installationId: 1, 273 }) 274 275 await revokeKeysForInstallation(1) 276 277 expect(restoreMock).toHaveBeenCalledWith('did:plc:abc') 278 expect(deleteRecordMock).toHaveBeenCalledTimes(1) 279 const del = deleteRecordMock.mock.calls[0][0] 280 expect(del.repo).toBe('did:plc:abc') 281 expect(del.collection).toBe('sh.tangled.publicKey') 282 expect(del.rkey).toBe('3kh2y4xq2lk2v') 283 }) 284 285 it('no-ops when the installation has no keys', async () => { 286 await revokeKeysForInstallation(1) 287 expect(restoreMock).not.toHaveBeenCalled() 288 expect(deleteRecordMock).not.toHaveBeenCalled() 289 }) 290 291 it('swallows a 404 from the PDS delete', async () => { 292 await generateAndPublishKey({ 293 oauthSession: fakeOauthSession('did:plc:abc'), 294 installationId: 1, 295 }) 296 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('not found'), { status: 404 })) 297 298 await expect(revokeKeysForInstallation(1)).resolves.toBeUndefined() 299 }) 300 301 it('continues when OAuth session restoration fails', async () => { 302 await generateAndPublishKey({ 303 oauthSession: fakeOauthSession('did:plc:abc'), 304 installationId: 1, 305 }) 306 restoreMock.mockRejectedValueOnce(new Error('session gone')) 307 308 await expect(revokeKeysForInstallation(1)).resolves.toBeUndefined() 309 expect(deleteRecordMock).not.toHaveBeenCalled() 310 }) 311}) 312 313describe('revokeKeyForInstallationDid', () => { 314 beforeEach(async () => { 315 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 316 clearEncryptionKeyCache() 317 318 setDb(await createTestDb()) 319 await useDb().insert(installation).values({ 320 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 321 }) 322 323 createRecordMock.mockReset() 324 deleteRecordMock.mockReset() 325 restoreMock.mockReset() 326 let counter = 0 327 createRecordMock.mockImplementation(async () => { 328 counter += 1 329 return { data: { uri: `at://did:plc:${counter}/sh.tangled.publicKey/rkey-${counter}`, cid: 'bafy' } } 330 }) 331 deleteRecordMock.mockResolvedValue({}) 332 restoreMock.mockImplementation(async (did: string) => ({ did })) 333 }) 334 335 afterEach(() => { 336 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 337 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 338 clearEncryptionKeyCache() 339 clearDb() 340 }) 341 342 it('revokes the PDS record and drops the local row for one (install, did)', async () => { 343 await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 344 const db = useDb() 345 expect(await db.select().from(sshKey)).toHaveLength(1) 346 347 await revokeKeyForInstallationDid(1, 'did:plc:1') 348 349 expect(restoreMock).toHaveBeenCalledWith('did:plc:1') 350 const del = deleteRecordMock.mock.calls[0][0] 351 expect(del.repo).toBe('did:plc:1') 352 expect(del.collection).toBe('sh.tangled.publicKey') 353 expect(del.rkey).toBe('rkey-1') 354 expect(await db.select().from(sshKey)).toHaveLength(0) 355 }) 356 357 it('leaves other dids on the same installation untouched', async () => { 358 await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 359 await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:2'), installationId: 1 }) 360 361 await revokeKeyForInstallationDid(1, 'did:plc:1') 362 363 const db = useDb() 364 const rows = await db.select().from(sshKey) 365 expect(rows).toHaveLength(1) 366 expect(rows[0].did).toBe('did:plc:2') 367 }) 368 369 it('no-ops when no key exists for the pair', async () => { 370 await revokeKeyForInstallationDid(1, 'did:plc:none') 371 expect(restoreMock).not.toHaveBeenCalled() 372 expect(deleteRecordMock).not.toHaveBeenCalled() 373 }) 374 375 it('drops the local row even when the PDS delete fails', async () => { 376 await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 377 deleteRecordMock.mockRejectedValueOnce(Object.assign(new Error('boom'), { status: 500 })) 378 379 await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined() 380 381 const db = useDb() 382 expect(await db.select().from(sshKey)).toHaveLength(0) 383 }) 384 385 it('drops the local row even when session restoration fails', async () => { 386 await generateAndPublishKey({ oauthSession: fakeOauthSession('did:plc:1'), installationId: 1 }) 387 restoreMock.mockRejectedValueOnce(new Error('session gone')) 388 389 await expect(revokeKeyForInstallationDid(1, 'did:plc:1')).resolves.toBeUndefined() 390 391 const db = useDb() 392 expect(await db.select().from(sshKey)).toHaveLength(0) 393 }) 394})