mirror your GitHub repos to tangled.org automatically
1

Configure Feed

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

at main 5.2 kB View raw
1import crypto from 'node:crypto' 2import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' 3import { installation, repoMapping } from '../../server/db/schema' 4import { clearDb, setDb, useDb } from '../../server/utils/db' 5import { clearEncryptionKeyCache } from '../../server/utils/encryption' 6import { RemoteRejectedError } from '../../server/utils/git-wire/errors' 7import { createTestDb } from '../utils/db' 8 9const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY 10const ZERO = '0'.repeat(40) 11 12const splicePushMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string, sha: string }>>() 13const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>() 14 15vi.mock('../../server/utils/splice', () => ({ 16 splicePush: (params: Record<string, unknown>) => splicePushMock(params), 17})) 18 19vi.mock('../../server/utils/github-app', () => ({ 20 installationOctokit: async () => ({ auth: octokitAuthMock }), 21 installationToken: async () => (await octokitAuthMock({ type: 'installation' })).token, 22})) 23 24const { syncPush } = await import('../../server/utils/sync-push') 25 26describe('sync-push', () => { 27 beforeEach(async () => { 28 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64') 29 clearEncryptionKeyCache() 30 31 setDb(await createTestDb()) 32 await useDb().insert(installation).values({ 33 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User', 34 }) 35 36 splicePushMock.mockReset() 37 octokitAuthMock.mockReset() 38 octokitAuthMock.mockResolvedValue({ token: 'install-token' }) 39 splicePushMock.mockResolvedValue({ status: 'synced', sha: 'a'.repeat(40) }) 40 }) 41 42 afterEach(() => { 43 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY 44 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY 45 clearEncryptionKeyCache() 46 clearDb() 47 }) 48 49 async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) { 50 await useDb().insert(repoMapping).values({ 51 installationId: 1, 52 githubRepoId: 9001, 53 githubFullName: 'alice/my-project', 54 tangledRepoDid: 'did:plc:repo-xyz', 55 tangledFullName: 'did:plc:abc/my-project', 56 knot: 'knot1.tangled.sh', 57 status: 'active', 58 ...over, 59 }) 60 } 61 62 const payload = (over: Record<string, unknown> = {}) => ({ 63 installationId: 1, 64 githubRepoId: 9001, 65 ref: 'refs/heads/main', 66 before: ZERO, 67 after: 'a'.repeat(40), 68 ...over, 69 }) 70 71 it('skips when no mapping exists', async () => { 72 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'no-mapping' }) 73 expect(splicePushMock).not.toHaveBeenCalled() 74 }) 75 76 it('skips when disabled', async () => { 77 await seedMapping({ disabledAt: new Date() }) 78 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'disabled' }) 79 }) 80 81 it('skips ref deletions (after = zero sha)', async () => { 82 await seedMapping() 83 expect(await syncPush(payload({ after: ZERO }))).toEqual({ status: 'skipped', reason: 'deletion' }) 84 expect(splicePushMock).not.toHaveBeenCalled() 85 }) 86 87 it('dedupes a redelivery via lastSyncedRefs', async () => { 88 await seedMapping({ lastSyncedRefs: { 'refs/heads/main': 'a'.repeat(40) } }) 89 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'already-synced' }) 90 expect(splicePushMock).not.toHaveBeenCalled() 91 }) 92 93 it('splices the push and records the new tip', async () => { 94 await seedMapping() 95 const result = await syncPush(payload()) 96 expect(result).toEqual({ status: 'synced' }) 97 expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({ 98 ref: 'refs/heads/main', 99 want: 'a'.repeat(40), 100 repoDid: 'did:plc:repo-xyz', 101 knot: 'knot1.tangled.sh', 102 token: 'install-token', 103 })) 104 const rows = await useDb().select().from(repoMapping) 105 expect((rows[0]!.lastSyncedRefs as Record<string, string>)['refs/heads/main']).toBe('a'.repeat(40)) 106 }) 107 108 it('marks mapping error and stops on a terminal too-big failure', async () => { 109 await seedMapping() 110 splicePushMock.mockRejectedValue(new RemoteRejectedError('pack exceeded', 'too-big')) 111 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'repo-gone' }) 112 const rows = await useDb().select().from(repoMapping) 113 expect(rows[0]!.status).toBe('error') 114 expect(rows[0]!.lastError).toMatch(/size limit/) 115 }) 116 117 it('marks mapping error when the knot rejects our key', async () => { 118 await seedMapping() 119 splicePushMock.mockRejectedValue(new RemoteRejectedError('denied', 'auth-rejected')) 120 expect(await syncPush(payload())).toEqual({ status: 'skipped', reason: 'repo-gone' }) 121 const rows = await useDb().select().from(repoMapping) 122 expect(rows[0]!.status).toBe('error') 123 }) 124 125 it('rethrows a transient stale-old-sha for queue retry', async () => { 126 await seedMapping() 127 splicePushMock.mockRejectedValue(new RemoteRejectedError('stale', 'stale-old-sha')) 128 await expect(syncPush(payload())).rejects.toMatchObject({ reason: 'stale-old-sha' }) 129 const rows = await useDb().select().from(repoMapping) 130 expect(rows[0]!.status).toBe('active') 131 }) 132})