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