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