mirror your GitHub repos to tangled.org automatically
1import crypto from 'node:crypto'
2import process from 'node:process'
3import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4import { installation, repoMapping } from '../../server/db/schema'
5import { clearDb, setDb, useDb } from '../../server/utils/db'
6import { clearEncryptionKeyCache } from '../../server/utils/encryption'
7import { RemoteRejectedError } from '../../server/utils/git-wire/errors'
8import { createTestDb } from '../utils/db'
9
10const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
11
12const fetchAdvertisementMock = vi.fn<(repo: string, token: string) => Promise<{ refs: Map<string, string> }>>()
13const splicePushMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string, sha: string }>>()
14const spliceDeleteMock = vi.fn<(params: Record<string, unknown>) => Promise<{ status: string }>>()
15const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>()
16
17vi.mock('../../server/utils/git-wire/upload-pack', () => ({
18 fetchAdvertisement: (repo: string, token: string) => fetchAdvertisementMock(repo, token),
19}))
20
21vi.mock('../../server/utils/splice', () => ({
22 splicePush: (params: Record<string, unknown>) => splicePushMock(params),
23 spliceDelete: (params: Record<string, unknown>) => spliceDeleteMock(params),
24}))
25
26vi.mock('../../server/utils/github-app', () => ({
27 installationOctokit: async () => ({ auth: octokitAuthMock }),
28}))
29
30const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref')
31
32describe('sync-ref', () => {
33 beforeEach(async () => {
34 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
35 clearEncryptionKeyCache()
36
37 setDb(await createTestDb())
38 const db = useDb()
39 await db.insert(installation).values({
40 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
41 })
42
43 fetchAdvertisementMock.mockReset()
44 splicePushMock.mockReset()
45 spliceDeleteMock.mockReset()
46 octokitAuthMock.mockReset()
47
48 octokitAuthMock.mockResolvedValue({ token: 'install-token' })
49 fetchAdvertisementMock.mockResolvedValue({
50 refs: new Map([
51 ['refs/heads/main', 'a'.repeat(40)],
52 ['refs/heads/feature-x', 'b'.repeat(40)],
53 ['refs/tags/v1.0.0', 'c'.repeat(40)],
54 ]),
55 })
56 splicePushMock.mockResolvedValue({ status: 'synced', sha: 'a'.repeat(40) })
57 spliceDeleteMock.mockResolvedValue({ status: 'synced' })
58 })
59
60 afterEach(() => {
61 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
62 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
63 clearEncryptionKeyCache()
64 clearDb()
65 })
66
67 async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) {
68 const db = useDb()
69 await db.insert(repoMapping).values({
70 installationId: 1,
71 githubRepoId: 9001,
72 githubFullName: 'alice/my-project',
73 tangledRepoDid: 'did:plc:repo-xyz',
74 tangledFullName: 'did:plc:abc/my-project',
75 knot: 'knot1.tangled.sh',
76 status: 'active',
77 ...over,
78 })
79 }
80
81 describe('syncCreateRef', () => {
82 it('skips non-branch/tag ref types', async () => {
83 await seedMapping()
84 const result = await syncCreateRef({
85 installationId: 1, githubRepoId: 9001, refType: 'repository' as never, ref: 'whatever',
86 })
87 expect(result).toEqual({ status: 'skipped', reason: 'not-branch-or-tag' })
88 expect(splicePushMock).not.toHaveBeenCalled()
89 })
90
91 it('skips when no mapping exists', async () => {
92 const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' })
93 expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' })
94 })
95
96 it('skips when mapping is disabled', async () => {
97 await seedMapping({ disabledAt: new Date() })
98 const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' })
99 expect(result).toEqual({ status: 'skipped', reason: 'disabled' })
100 })
101
102 it('resolves a branch ref to its SHA and splices it', async () => {
103 await seedMapping()
104 await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'feature-x' })
105 expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({
106 ref: 'refs/heads/feature-x',
107 want: 'b'.repeat(40),
108 repoDid: 'did:plc:repo-xyz',
109 }))
110 })
111
112 it('resolves a tag ref to its SHA', async () => {
113 await seedMapping()
114 await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'tag', ref: 'v1.0.0' })
115 expect(splicePushMock).toHaveBeenCalledWith(expect.objectContaining({
116 ref: 'refs/tags/v1.0.0',
117 want: 'c'.repeat(40),
118 }))
119 })
120
121 it('retries (throws) when github does not yet advertise the ref', async () => {
122 await seedMapping()
123 fetchAdvertisementMock.mockResolvedValue({ refs: new Map() })
124 await expect(
125 syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }),
126 ).rejects.toThrow(/does not yet advertise/)
127 expect(splicePushMock).not.toHaveBeenCalled()
128 })
129
130 it('updates lastSyncedRefs with the synced SHA', async () => {
131 await seedMapping()
132 splicePushMock.mockResolvedValue({ status: 'synced', sha: 'd'.repeat(40) })
133 await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' })
134
135 const rows = await useDb().select().from(repoMapping)
136 expect((rows[0]!.lastSyncedRefs as Record<string, string>)['refs/heads/main']).toBe('d'.repeat(40))
137 })
138
139 it('marks mapping error when the knot reports repo gone', async () => {
140 await seedMapping()
141 splicePushMock.mockRejectedValue(new RemoteRejectedError('gone', 'repo-gone'))
142 const result = await syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' })
143 expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' })
144 const rows = await useDb().select().from(repoMapping)
145 expect(rows[0]!.status).toBe('error')
146 })
147
148 it('rethrows a transient stale-old-sha for queue retry', async () => {
149 await seedMapping()
150 splicePushMock.mockRejectedValue(new RemoteRejectedError('stale', 'stale-old-sha'))
151 await expect(
152 syncCreateRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' }),
153 ).rejects.toMatchObject({ reason: 'stale-old-sha' })
154 })
155 })
156
157 describe('syncDeleteRef', () => {
158 it('splices a delete for the qualified ref', async () => {
159 await seedMapping()
160 await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'old-branch' })
161 expect(spliceDeleteMock).toHaveBeenCalledWith(expect.objectContaining({ ref: 'refs/heads/old-branch' }))
162 })
163
164 it('treats an already-absent ref as success', async () => {
165 await seedMapping()
166 spliceDeleteMock.mockResolvedValue({ status: 'already-absent' })
167 const result = await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'tag', ref: 'v9' })
168 expect(result).toEqual({ status: 'synced' })
169 })
170
171 it('removes the ref from lastSyncedRefs', async () => {
172 await seedMapping({ lastSyncedRefs: { 'refs/heads/main': 'abc', 'refs/heads/old': 'def' } })
173 await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'old' })
174 const rows = await useDb().select().from(repoMapping)
175 expect(rows[0]!.lastSyncedRefs).toEqual({ 'refs/heads/main': 'abc' })
176 })
177
178 it('marks mapping as error if knot reports repo gone', async () => {
179 await seedMapping()
180 spliceDeleteMock.mockRejectedValue(new RemoteRejectedError('gone', 'repo-gone'))
181 const result = await syncDeleteRef({ installationId: 1, githubRepoId: 9001, refType: 'branch', ref: 'main' })
182 expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' })
183 const rows = await useDb().select().from(repoMapping)
184 expect(rows[0]!.status).toBe('error')
185 })
186 })
187})