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