mirror your GitHub repos to tangled.org automatically
1import crypto from 'node:crypto'
2import { sql } from 'drizzle-orm'
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 { enrollRepo } from '../../server/utils/tangled-repo'
8import { createTestDb } from '../utils/db'
9
10const ORIGINAL_ENC_KEY = process.env.NUXT_ENCRYPTION_KEY
11
12interface GithubRepoLike {
13 id: number
14 full_name: string
15 private: boolean
16 fork: boolean
17 default_branch: string
18}
19
20const githubGet = vi.fn<(input: { repository_id: number }) => Promise<{ data: GithubRepoLike }>>()
21const getServiceAuthMock = vi.fn<(input: { aud: string, lxm: string, exp: number }) => Promise<{ data: { token: string } }>>()
22const putRecordMock = vi.fn<(input: { repo: string, collection: string, rkey: string, record: Record<string, unknown> }) => Promise<unknown>>()
23
24vi.mock('@atproto/api', () => ({
25 Agent: class {
26 com = {
27 atproto: {
28 server: { getServiceAuth: getServiceAuthMock },
29 repo: { putRecord: putRecordMock },
30 },
31 }
32 },
33}))
34
35vi.mock('../../server/utils/github-app', () => ({
36 installationOctokit: async () => ({
37 request: githubGet,
38 }),
39 clearGitHubAppCache: () => {},
40}))
41
42interface CapturedInit {
43 method?: string
44 headers?: Record<string, string>
45 body?: string
46}
47const fakeFetch = vi.fn<(url: string, init: CapturedInit) => Promise<Response>>()
48const ORIGINAL_FETCH = globalThis.fetch
49
50function fakeOauthSession(did: string) {
51 // eslint-disable-next-line ts/no-unsafe-type-assertion
52 return { did } as unknown as Parameters<typeof enrollRepo>[0]['oauthSession']
53}
54
55function ghRepo(over: Partial<GithubRepoLike> = {}): GithubRepoLike {
56 return {
57 id: 9001,
58 full_name: 'alice/my-project',
59 private: false,
60 fork: false,
61 default_branch: 'main',
62 ...over,
63 }
64}
65
66describe('enrollRepo', () => {
67 beforeEach(async () => {
68 process.env.NUXT_ENCRYPTION_KEY = crypto.randomBytes(32).toString('base64')
69 clearEncryptionKeyCache()
70
71 setDb(await createTestDb())
72 await useDb().insert(installation).values({
73 id: 1, accountLogin: 'alice', accountId: 100, accountType: 'User',
74 })
75
76 githubGet.mockReset()
77 getServiceAuthMock.mockReset()
78 putRecordMock.mockReset()
79 fakeFetch.mockReset()
80 // eslint-disable-next-line ts/no-unsafe-type-assertion
81 globalThis.fetch = fakeFetch as unknown as typeof globalThis.fetch
82
83 getServiceAuthMock.mockResolvedValue({ data: { token: 'service-auth-jwt' } })
84 putRecordMock.mockResolvedValue({ data: { uri: 'at://did:plc:abc/sh.tangled.repo/whatever', cid: 'bafy' } })
85 })
86
87 afterEach(() => {
88 globalThis.fetch = ORIGINAL_FETCH
89 if (ORIGINAL_ENC_KEY === undefined) delete process.env.NUXT_ENCRYPTION_KEY
90 else process.env.NUXT_ENCRYPTION_KEY = ORIGINAL_ENC_KEY
91 clearEncryptionKeyCache()
92 clearDb()
93 })
94
95 it('enrolls a public, non-fork repo end to end', async () => {
96 githubGet.mockResolvedValue({ data: ghRepo() })
97 fakeFetch.mockResolvedValue(new Response(
98 JSON.stringify({ repoDid: 'did:plc:repo-xyz' }),
99 { status: 200 },
100 ))
101
102 const result = await enrollRepo({
103 oauthSession: fakeOauthSession('did:plc:abc'),
104 installationId: 1,
105 githubRepoId: 9001,
106 })
107 expect(result.status).toBe('enrolled')
108
109 // Service auth requested with the right shape.
110 expect(getServiceAuthMock).toHaveBeenCalledTimes(1)
111 const sa = getServiceAuthMock.mock.calls[0]?.[0]
112 expect(sa?.aud).toBe('did:web:knot1.tangled.sh')
113 expect(sa?.lxm).toBe('sh.tangled.repo.create')
114
115 // Knot procedure invoked with source URL and rkey.
116 expect(fakeFetch).toHaveBeenCalledTimes(1)
117 const fetchCall = fakeFetch.mock.calls[0]
118 const url = fetchCall?.[0]
119 const init = fetchCall?.[1]
120 expect(url).toBe('https://knot1.tangled.sh/xrpc/sh.tangled.repo.create')
121 expect(init?.headers?.authorization).toBe('Bearer service-auth-jwt')
122 if (typeof init?.body !== 'string') throw new TypeError('expected string body')
123 const body: Record<string, unknown> = JSON.parse(init.body)
124 expect(body.name).toBe('my-project')
125 expect(body.source).toBe('https://github.com/alice/my-project')
126 expect(body.defaultBranch).toBe('main')
127 expect(typeof body.rkey).toBe('string')
128
129 // PDS record written with the same rkey.
130 expect(putRecordMock).toHaveBeenCalledTimes(1)
131 const put = putRecordMock.mock.calls[0]?.[0]
132 expect(put?.rkey).toBe(body.rkey)
133 expect(put?.record.repoDid).toBe('did:plc:repo-xyz')
134 expect(put?.record.knot).toBe('knot1.tangled.sh')
135
136 // Mapping persisted.
137 const rows = await useDb().select().from(repoMapping)
138 .where(sql`${repoMapping.installationId} = 1`)
139 expect(rows).toHaveLength(1)
140 expect(rows[0].tangledRepoDid).toBe('did:plc:repo-xyz')
141 expect(rows[0].knot).toBe('knot1.tangled.sh')
142 expect(rows[0].status).toBe('active')
143 })
144
145 it('skips private repos', async () => {
146 githubGet.mockResolvedValue({ data: ghRepo({ private: true }) })
147
148 const result = await enrollRepo({
149 oauthSession: fakeOauthSession('did:plc:abc'),
150 installationId: 1,
151 githubRepoId: 9001,
152 })
153 expect(result).toEqual({ status: 'skipped', reason: 'private' })
154 expect(fakeFetch).not.toHaveBeenCalled()
155 expect(putRecordMock).not.toHaveBeenCalled()
156 expect(await useDb().select().from(repoMapping)).toHaveLength(0)
157 })
158
159 it('skips forks', async () => {
160 githubGet.mockResolvedValue({ data: ghRepo({ fork: true }) })
161
162 const result = await enrollRepo({
163 oauthSession: fakeOauthSession('did:plc:abc'),
164 installationId: 1,
165 githubRepoId: 9001,
166 })
167 expect(result).toEqual({ status: 'skipped', reason: 'fork' })
168 expect(fakeFetch).not.toHaveBeenCalled()
169 })
170
171 it('no-ops if a mapping already exists', async () => {
172 await useDb().insert(repoMapping).values({
173 installationId: 1,
174 githubRepoId: 9001,
175 githubFullName: 'alice/my-project',
176 status: 'active',
177 })
178
179 const result = await enrollRepo({
180 oauthSession: fakeOauthSession('did:plc:abc'),
181 installationId: 1,
182 githubRepoId: 9001,
183 })
184 expect(result).toEqual({ status: 'already' })
185 expect(githubGet).not.toHaveBeenCalled()
186 })
187
188 it('throws and writes nothing if the knot rejects the procedure', async () => {
189 githubGet.mockResolvedValue({ data: ghRepo() })
190 fakeFetch.mockResolvedValue(new Response('nope', { status: 500 }))
191
192 await expect(enrollRepo({
193 oauthSession: fakeOauthSession('did:plc:abc'),
194 installationId: 1,
195 githubRepoId: 9001,
196 })).rejects.toThrow(/knot1\.tangled\.sh returned 500/)
197
198 expect(putRecordMock).not.toHaveBeenCalled()
199 expect(await useDb().select().from(repoMapping)).toHaveLength(0)
200 })
201})