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