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