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 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})