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 { createTestDb } from '../utils/db'
7
8const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
9
10// Mock git + ssh + octokit so we can exercise mapping-lookup + envelope
11// branches without invoking real binaries.
12const gitMock = vi.fn<(args: string[], opts?: unknown) => Promise<{ stdout: string, stderr: string }>>()
13const sshLoadMock = vi.fn<(installationId: number) => Promise<{ gitSshCommand: string, cleanup: () => void }>>()
14const octokitAuthMock = vi.fn<(input: { type: 'installation' }) => Promise<{ token: string }>>()
15
16vi.mock('../../server/utils/git', async () => {
17 const actual = await vi.importActual<typeof import('../../server/utils/git')>('../../server/utils/git')
18 return {
19 ...actual,
20 git: (args: string[], opts: unknown) => gitMock(args, opts),
21 }
22})
23
24vi.mock('../../server/utils/ssh-cmd', () => ({
25 loadSshCommandForInstall: (id: number) => sshLoadMock(id),
26}))
27
28vi.mock('../../server/utils/github-app', () => ({
29 installationOctokit: async () => ({
30 auth: octokitAuthMock,
31 }),
32}))
33
34const { syncCreateRef, syncDeleteRef } = await import('../../server/utils/sync-ref')
35
36describe('sync-ref', () => {
37 beforeEach(async () => {
38 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
39 clearEncryptionKeyCache()
40
41 setDb(await createTestDb())
42 const db = useDb()
43 await db.insert(installation).values({
44 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
45 })
46
47 gitMock.mockReset()
48 sshLoadMock.mockReset()
49 octokitAuthMock.mockReset()
50
51 sshLoadMock.mockResolvedValue({ gitSshCommand: 'ssh -i /tmp/key', cleanup: () => {} })
52 octokitAuthMock.mockResolvedValue({ token: 'install-token' })
53 gitMock.mockResolvedValue({ stdout: 'abc1234567890abc1234567890abc1234567890a', stderr: '' })
54 })
55
56 afterEach(() => {
57 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
58 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
59 clearEncryptionKeyCache()
60 clearDb()
61 })
62
63 async function seedMapping(over: Partial<typeof repoMapping.$inferInsert> = {}) {
64 const db = useDb()
65 await db.insert(repoMapping).values({
66 installationId: 1,
67 githubRepoId: 9001,
68 githubFullName: 'alice/my-project',
69 tangledRepoDid: 'did:plc:repo-xyz',
70 tangledFullName: 'did:plc:abc/my-project',
71 knot: 'knot1.tangled.sh',
72 status: 'active',
73 ...over,
74 })
75 }
76
77 describe('syncCreateRef', () => {
78 it('skips non-branch/tag ref types', async () => {
79 await seedMapping()
80 const result = await syncCreateRef({
81 installationId: 1,
82 githubRepoId: 9001,
83 refType: 'repository' as never,
84 ref: 'whatever',
85 })
86 expect(result).toEqual({ status: 'skipped', reason: 'not-branch-or-tag' })
87 expect(gitMock).not.toHaveBeenCalled()
88 })
89
90 it('skips when no mapping exists', async () => {
91 const result = await syncCreateRef({
92 installationId: 1,
93 githubRepoId: 9001,
94 refType: 'branch',
95 ref: 'main',
96 })
97 expect(result).toEqual({ status: 'skipped', reason: 'no-mapping' })
98 })
99
100 it('skips when mapping is disabled', async () => {
101 await seedMapping({ disabledAt: new Date() })
102 const result = await syncCreateRef({
103 installationId: 1,
104 githubRepoId: 9001,
105 refType: 'branch',
106 ref: 'main',
107 })
108 expect(result).toEqual({ status: 'skipped', reason: 'disabled' })
109 })
110
111 it('qualifies branch refs as refs/heads/<name>', async () => {
112 await seedMapping()
113 await syncCreateRef({
114 installationId: 1,
115 githubRepoId: 9001,
116 refType: 'branch',
117 ref: 'feature-x',
118 })
119
120 // git init, git fetch, git push, git rev-parse
121 const calls = gitMock.mock.calls.map(c => c[0])
122 const fetch = calls.find(args => args[0] === 'fetch')
123 const push = calls.find(args => args[0] === 'push')
124 expect(fetch).toBeDefined()
125 expect(fetch).toContain('+refs/heads/feature-x:refs/heads/feature-x')
126 expect(push).toContain('+refs/heads/feature-x:refs/heads/feature-x')
127 })
128
129 it('qualifies tag refs as refs/tags/<name>', async () => {
130 await seedMapping()
131 await syncCreateRef({
132 installationId: 1,
133 githubRepoId: 9001,
134 refType: 'tag',
135 ref: 'v1.0.0',
136 })
137 const calls = gitMock.mock.calls.map(c => c[0])
138 const fetch = calls.find(args => args[0] === 'fetch')
139 expect(fetch).toContain('+refs/tags/v1.0.0:refs/tags/v1.0.0')
140 })
141
142 it('routes push for knot1.tangled.sh via tangled.org', async () => {
143 await seedMapping()
144 await syncCreateRef({
145 installationId: 1,
146 githubRepoId: 9001,
147 refType: 'branch',
148 ref: 'main',
149 })
150 const push = gitMock.mock.calls.map(c => c[0]).find(a => a[0] === 'push')!
151 const url = push.find(a => a.startsWith('ssh://'))!
152 expect(url).toContain('@tangled.org/')
153 })
154
155 it('updates lastSyncedRefs with the fetched SHA', async () => {
156 await seedMapping()
157 gitMock.mockImplementation(async args => {
158 if (args[0] === 'rev-parse') {
159 return { stdout: 'deadbeef1234567890deadbeef1234567890dead', stderr: '' }
160 }
161 return { stdout: '', stderr: '' }
162 })
163
164 await syncCreateRef({
165 installationId: 1,
166 githubRepoId: 9001,
167 refType: 'branch',
168 ref: 'main',
169 })
170
171 const db = useDb()
172 const rows = await db.select().from(repoMapping)
173 const refs = rows[0].lastSyncedRefs as Record<string, string>
174 expect(refs['refs/heads/main']).toBe('deadbeef1234567890deadbeef1234567890dead')
175 })
176 })
177
178 describe('syncDeleteRef', () => {
179 it('uses the empty-source refspec to delete on the remote', async () => {
180 await seedMapping()
181 await syncDeleteRef({
182 installationId: 1,
183 githubRepoId: 9001,
184 refType: 'branch',
185 ref: 'old-branch',
186 })
187 const push = gitMock.mock.calls.map(c => c[0]).find(a => a[0] === 'push')!
188 expect(push).toContain(':refs/heads/old-branch')
189 expect(push.some(s => s.startsWith(':'))).toBe(true)
190 })
191
192 it('treats "remote ref does not exist" as success', async () => {
193 await seedMapping()
194 gitMock.mockImplementation(async args => {
195 if (args[0] === 'push') {
196 throw Object.assign(new Error('exit 1'), {
197 stderr: 'error: unable to delete \'refs/tags/v9\': remote ref does not exist\n',
198 })
199 }
200 return { stdout: '', stderr: '' }
201 })
202
203 const result = await syncDeleteRef({
204 installationId: 1,
205 githubRepoId: 9001,
206 refType: 'tag',
207 ref: 'v9',
208 })
209 expect(result).toEqual({ status: 'synced' })
210 })
211
212 it('removes the ref from lastSyncedRefs', async () => {
213 const db = useDb()
214 await seedMapping({
215 lastSyncedRefs: { 'refs/heads/main': 'abc', 'refs/heads/old': 'def' },
216 })
217
218 await syncDeleteRef({
219 installationId: 1,
220 githubRepoId: 9001,
221 refType: 'branch',
222 ref: 'old',
223 })
224
225 const rows = await db.select().from(repoMapping)
226 const refs = rows[0].lastSyncedRefs as Record<string, string>
227 expect(refs).toEqual({ 'refs/heads/main': 'abc' })
228 })
229
230 it('marks mapping as error if knot reports repo gone', async () => {
231 await seedMapping()
232 gitMock.mockImplementation(async args => {
233 if (args[0] === 'push') {
234 throw Object.assign(new Error('exit 1'), {
235 stderr: 'fatal: repository not found\n',
236 })
237 }
238 return { stdout: '', stderr: '' }
239 })
240
241 const result = await syncDeleteRef({
242 installationId: 1,
243 githubRepoId: 9001,
244 refType: 'branch',
245 ref: 'main',
246 })
247 expect(result).toEqual({ status: 'skipped', reason: 'repo-gone' })
248
249 const db = useDb()
250 const rows = await db.select().from(repoMapping)
251 expect(rows[0].status).toBe('error')
252 })
253 })
254})