Add advanced monorepo path-scope management and enforcement
swadhinbiswas committed 8 months ago
f321d69
1 parent 75ea0df
commit f321d69
5 files changed
+461 -33
Showing 5 changed files+461-33
▼doc/feature_audit.md
+1-1
| @@ -30,7 +30,7 @@ | ||
30 | 30 | | Repository hosting | ✅ | Multiple storage adapters (S3, R2, local, GDrive) | |
31 | 31 | | Repository mirroring | ⚠️ | Background sync cron plus repository mirror config/manual-sync APIs are implemented (`settings/mirror`, `settings/mirror/sync`) with derived health telemetry (`isHealthy`, `isStale`, `lastSyncAgeMinutes`); scheduled sync automation now supports stale-only windows, sync-interval/max-repo controls, and batch monitoring metrics (`eligible`, `skipped`, `stale`, `failedRepoIds`, `durationMs`) via `cron/mirror-sync`, while advanced alerting/runbook workflows remain partial | |
32 | 32 | | Forks & pull-based workflows | ✅ | `fork.ts` API, UI implemented | |
33 | -| Monorepo support | ⚠️ | Basic support, no advanced path scoping | | |
33 | +| Monorepo support | ✅ | Advanced path scoping is implemented: repository settings CRUD APIs for path rules (`settings/path-permissions`), read/write enforcement across PR/review/comment/impact/template flows, and pre-receive push checks now use centralized permission-level evaluation (`checkPathPermissions`) for protected monorepo paths | | |
34 | 34 | | Git LFS | ✅ | `lfs.ts` with batch API (upload/download) | |
35 | 35 | | Submodules | ✅ | Native Git support, no special handling needed | |
36 | 36 | | Branch protection rules | ✅ | Schema + UI + API in place | |
37 | 37 | |
▼src/pages/api/internal/hooks/pre-receive.ts
+4-32
| @@ -3,8 +3,8 @@ import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | ||
3 | 3 | import { logger } from "@/lib/logger"; |
4 | 4 | import type { APIRoute } from "astro"; |
5 | 5 | import { eq, and } from "drizzle-orm"; |
6 | -import micromatch from "micromatch"; | |
7 | 6 | import { resolveRepoPath } from "@/lib/git-storage"; |
7 | +import { checkPathPermissions } from "@/lib/path-scoping"; | |
8 | 8 | |
9 | 9 | export const POST: APIRoute = async ({ request, url }) => { |
10 | 10 | const hookSecret = process.env.INTERNAL_HOOK_SECRET; |
| @@ -227,37 +227,9 @@ export const POST: APIRoute = async ({ request, url }) => { | ||
227 | 227 | } |
228 | 228 | |
229 | 229 | if (changedFiles.length > 0) { |
230 | - // Check permissions | |
231 | - // 1. Get user's teams | |
232 | - const userTeams = await db.query.teamMembers.findMany({ | |
233 | - where: eq(schema.teamMembers.userId, userId), | |
234 | - with: { team: true } | |
235 | - }); | |
236 | - const userTeamIds = userTeams.map(tm => tm.teamId); | |
237 | - | |
238 | - // 2. Identify rules that grant access to this user | |
239 | - const userAllowedRules = strictPathPermissions.filter(rule => | |
240 | - (rule.userId === userId) || (rule.teamId && userTeamIds.includes(rule.teamId)) | |
241 | - ); | |
242 | - | |
243 | - // 3. Check each file | |
244 | - for (const file of changedFiles) { | |
245 | - // Find all rules that 'protect' this file (match the path) | |
246 | - const protectingRules = strictPathPermissions.filter(rule => | |
247 | - micromatch.isMatch(file, rule.pathPattern) | |
248 | - ); | |
249 | - | |
250 | - if (protectingRules.length > 0) { | |
251 | - // File is protected. User must match at least one ALLOWED rule that covers this file. | |
252 | - // (i.e. one of userAllowedRules must match the file) | |
253 | - const hasAccess = userAllowedRules.some(allowed => | |
254 | - micromatch.isMatch(file, allowed.pathPattern) | |
255 | - ); | |
256 | - | |
257 | - if (!hasAccess) { | |
258 | - return new Response(`Permission denied for ${file}. Protected path.`, { status: 403 }); | |
259 | - } | |
260 | - } | |
230 | + const permission = await checkPathPermissions(userId, repo.id, changedFiles, "write"); | |
231 | + if (!permission.allowed) { | |
232 | + return new Response(permission.reason || "Write access denied by path permissions.", { status: 403 }); | |
261 | 233 | } |
262 | 234 | } |
263 | 235 | } |
264 | 236 | |
▼src/pages/api/repos/[owner]/[repo]/settings/path-permissions.ts
+118-0
| @@ -0,0 +1,118 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, desc, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, created, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canAdminRepo, canReadRepo } from "@/lib/permissions"; | |
10 | +import { getPathPermissions } from "@/lib/path-scoping"; | |
11 | + | |
12 | +const createPathPermissionSchema = z.object({ | |
13 | + pathPattern: z.string().min(1).max(500), | |
14 | + userId: z.string().min(1).max(128).optional(), | |
15 | + teamId: z.string().min(1).max(128).optional(), | |
16 | + permission: z.enum(["read", "write", "admin"]), | |
17 | + requireApproval: z.boolean().optional(), | |
18 | +}).superRefine((value, ctx) => { | |
19 | + if (!value.userId && !value.teamId) { | |
20 | + ctx.addIssue({ | |
21 | + code: "custom", | |
22 | + path: ["userId"], | |
23 | + message: "Either userId or teamId is required", | |
24 | + }); | |
25 | + } | |
26 | + if (value.userId && value.teamId) { | |
27 | + ctx.addIssue({ | |
28 | + code: "custom", | |
29 | + path: ["teamId"], | |
30 | + message: "Provide only one of userId or teamId", | |
31 | + }); | |
32 | + } | |
33 | +}); | |
34 | + | |
35 | +async function resolveRepository(owner: string, repoName: string) { | |
36 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
37 | + const ownerUser = await db.query.users.findFirst({ | |
38 | + where: eq(schema.users.username, owner), | |
39 | + }); | |
40 | + if (!ownerUser) return null; | |
41 | + | |
42 | + return db.query.repositories.findFirst({ | |
43 | + where: and( | |
44 | + eq(schema.repositories.ownerId, ownerUser.id), | |
45 | + eq(schema.repositories.name, repoName) | |
46 | + ), | |
47 | + }); | |
48 | +} | |
49 | + | |
50 | +export const GET: APIRoute = withErrorHandler(async ({ params, request }) => { | |
51 | + const owner = params.owner; | |
52 | + const repoName = params.repo; | |
53 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
54 | + | |
55 | + const user = await getUserFromRequest(request); | |
56 | + const repository = await resolveRepository(owner, repoName); | |
57 | + if (!repository) return notFound("Repository not found"); | |
58 | + | |
59 | + if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) { | |
60 | + return notFound("Repository not found"); | |
61 | + } | |
62 | + | |
63 | + const rules = await getPathPermissions(repository.id); | |
64 | + | |
65 | + return success({ | |
66 | + rules, | |
67 | + summary: { | |
68 | + totalRules: rules.length, | |
69 | + requireApprovalRules: rules.filter((rule) => rule.requireApproval === "true").length, | |
70 | + }, | |
71 | + }); | |
72 | +}); | |
73 | + | |
74 | +export const POST: APIRoute = withErrorHandler(async ({ params, request }) => { | |
75 | + const owner = params.owner; | |
76 | + const repoName = params.repo; | |
77 | + if (!owner || !repoName) return badRequest("Missing route parameters"); | |
78 | + | |
79 | + const user = await getUserFromRequest(request); | |
80 | + if (!user) return unauthorized(); | |
81 | + | |
82 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
83 | + const repository = await resolveRepository(owner, repoName); | |
84 | + if (!repository) return notFound("Repository not found"); | |
85 | + | |
86 | + if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
87 | + return forbidden(); | |
88 | + } | |
89 | + | |
90 | + const parsed = await parseBody(request, createPathPermissionSchema); | |
91 | + if ("error" in parsed) return parsed.error; | |
92 | + | |
93 | + const payload = parsed.data; | |
94 | + const now = new Date(); | |
95 | + const id = crypto.randomUUID(); | |
96 | + | |
97 | + await db.insert(schema.repositoryPathPermissions).values({ | |
98 | + id, | |
99 | + repositoryId: repository.id, | |
100 | + pathPattern: payload.pathPattern, | |
101 | + userId: payload.userId ?? null, | |
102 | + teamId: payload.teamId ?? null, | |
103 | + permission: payload.permission, | |
104 | + requireApproval: payload.requireApproval ? "true" : "false", | |
105 | + createdAt: now, | |
106 | + updatedAt: now, | |
107 | + }); | |
108 | + | |
109 | + return created({ | |
110 | + id, | |
111 | + repositoryId: repository.id, | |
112 | + pathPattern: payload.pathPattern, | |
113 | + userId: payload.userId ?? null, | |
114 | + teamId: payload.teamId ?? null, | |
115 | + permission: payload.permission, | |
116 | + requireApproval: payload.requireApproval ? "true" : "false", | |
117 | + }); | |
118 | +}); | |
0 | 119 | |
▼src/pages/api/repos/[owner]/[repo]/settings/path-permissions/[id].ts
+130-0
| @@ -0,0 +1,130 @@ | ||
1 | +import type { APIRoute } from "astro"; | |
2 | +import type { NodePgDatabase } from "drizzle-orm/node-postgres"; | |
3 | +import { and, eq } from "drizzle-orm"; | |
4 | +import { z } from "zod"; | |
5 | +import { getDatabase, schema } from "@/db"; | |
6 | +import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api"; | |
7 | +import { withErrorHandler } from "@/lib/errors"; | |
8 | +import { getUserFromRequest } from "@/lib/auth"; | |
9 | +import { canAdminRepo } from "@/lib/permissions"; | |
10 | + | |
11 | +const updatePathPermissionSchema = z.object({ | |
12 | + pathPattern: z.string().min(1).max(500).optional(), | |
13 | + userId: z.string().min(1).max(128).nullable().optional(), | |
14 | + teamId: z.string().min(1).max(128).nullable().optional(), | |
15 | + permission: z.enum(["read", "write", "admin"]).optional(), | |
16 | + requireApproval: z.boolean().optional(), | |
17 | +}).superRefine((value, ctx) => { | |
18 | + if (value.userId !== undefined && value.teamId !== undefined) { | |
19 | + const hasUser = value.userId !== null && value.userId.length > 0; | |
20 | + const hasTeam = value.teamId !== null && value.teamId.length > 0; | |
21 | + if (hasUser === hasTeam) { | |
22 | + ctx.addIssue({ | |
23 | + code: "custom", | |
24 | + path: ["teamId"], | |
25 | + message: "When updating assignee target, provide exactly one of userId or teamId", | |
26 | + }); | |
27 | + } | |
28 | + } | |
29 | +}); | |
30 | + | |
31 | +async function resolveRepository(owner: string, repoName: string) { | |
32 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
33 | + const ownerUser = await db.query.users.findFirst({ | |
34 | + where: eq(schema.users.username, owner), | |
35 | + }); | |
36 | + if (!ownerUser) return null; | |
37 | + | |
38 | + return db.query.repositories.findFirst({ | |
39 | + where: and( | |
40 | + eq(schema.repositories.ownerId, ownerUser.id), | |
41 | + eq(schema.repositories.name, repoName) | |
42 | + ), | |
43 | + }); | |
44 | +} | |
45 | + | |
46 | +export const PUT: APIRoute = withErrorHandler(async ({ params, request }) => { | |
47 | + const owner = params.owner; | |
48 | + const repoName = params.repo; | |
49 | + const id = params.id; | |
50 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
51 | + | |
52 | + const user = await getUserFromRequest(request); | |
53 | + if (!user) return unauthorized(); | |
54 | + | |
55 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
56 | + const repository = await resolveRepository(owner, repoName); | |
57 | + if (!repository) return notFound("Repository not found"); | |
58 | + | |
59 | + if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
60 | + return forbidden(); | |
61 | + } | |
62 | + | |
63 | + const existing = await db.query.repositoryPathPermissions.findFirst({ | |
64 | + where: and( | |
65 | + eq(schema.repositoryPathPermissions.id, id), | |
66 | + eq(schema.repositoryPathPermissions.repositoryId, repository.id) | |
67 | + ), | |
68 | + }); | |
69 | + if (!existing) return notFound("Path permission not found"); | |
70 | + | |
71 | + const parsed = await parseBody(request, updatePathPermissionSchema); | |
72 | + if ("error" in parsed) return parsed.error; | |
73 | + | |
74 | + const payload = parsed.data; | |
75 | + const updateData: Record<string, unknown> = { | |
76 | + updatedAt: new Date(), | |
77 | + }; | |
78 | + | |
79 | + if (payload.pathPattern !== undefined) updateData.pathPattern = payload.pathPattern; | |
80 | + if (payload.permission !== undefined) updateData.permission = payload.permission; | |
81 | + if (payload.requireApproval !== undefined) updateData.requireApproval = payload.requireApproval ? "true" : "false"; | |
82 | + | |
83 | + if (payload.userId !== undefined || payload.teamId !== undefined) { | |
84 | + updateData.userId = payload.userId ?? null; | |
85 | + updateData.teamId = payload.teamId ?? null; | |
86 | + } | |
87 | + | |
88 | + await db.update(schema.repositoryPathPermissions) | |
89 | + .set(updateData) | |
90 | + .where(and( | |
91 | + eq(schema.repositoryPathPermissions.id, id), | |
92 | + eq(schema.repositoryPathPermissions.repositoryId, repository.id) | |
93 | + )); | |
94 | + | |
95 | + return success({ updated: true }); | |
96 | +}); | |
97 | + | |
98 | +export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => { | |
99 | + const owner = params.owner; | |
100 | + const repoName = params.repo; | |
101 | + const id = params.id; | |
102 | + if (!owner || !repoName || !id) return badRequest("Missing route parameters"); | |
103 | + | |
104 | + const user = await getUserFromRequest(request); | |
105 | + if (!user) return unauthorized(); | |
106 | + | |
107 | + const db = getDatabase() as NodePgDatabase<typeof schema>; | |
108 | + const repository = await resolveRepository(owner, repoName); | |
109 | + if (!repository) return notFound("Repository not found"); | |
110 | + | |
111 | + if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) { | |
112 | + return forbidden(); | |
113 | + } | |
114 | + | |
115 | + const existing = await db.query.repositoryPathPermissions.findFirst({ | |
116 | + where: and( | |
117 | + eq(schema.repositoryPathPermissions.id, id), | |
118 | + eq(schema.repositoryPathPermissions.repositoryId, repository.id) | |
119 | + ), | |
120 | + }); | |
121 | + if (!existing) return notFound("Path permission not found"); | |
122 | + | |
123 | + await db.delete(schema.repositoryPathPermissions) | |
124 | + .where(and( | |
125 | + eq(schema.repositoryPathPermissions.id, id), | |
126 | + eq(schema.repositoryPathPermissions.repositoryId, repository.id) | |
127 | + )); | |
128 | + | |
129 | + return success({ deleted: true }); | |
130 | +}); | |
0 | 131 | |
▼tests/integration/repo-path-permissions-settings-route.test.ts
+208-0
| @@ -0,0 +1,208 @@ | ||
1 | +import { beforeEach, describe, expect, it, vi } from "vitest"; | |
2 | + | |
3 | +const { | |
4 | + getUserFromRequestMock, | |
5 | + canReadRepoMock, | |
6 | + canAdminRepoMock, | |
7 | + getPathPermissionsMock, | |
8 | + fakeSchema, | |
9 | +} = vi.hoisted(() => ({ | |
10 | + getUserFromRequestMock: vi.fn(async () => ({ userId: "user-1", isAdmin: false })), | |
11 | + canReadRepoMock: vi.fn(async () => true), | |
12 | + canAdminRepoMock: vi.fn(async () => true), | |
13 | + getPathPermissionsMock: vi.fn(async () => ([ | |
14 | + { | |
15 | + id: "perm-1", | |
16 | + repositoryId: "repo-1", | |
17 | + pathPattern: "packages/web/**", | |
18 | + userId: "user-1", | |
19 | + teamId: null, | |
20 | + permission: "write", | |
21 | + requireApproval: "false", | |
22 | + createdAt: new Date("2026-02-19T00:00:00Z"), | |
23 | + updatedAt: new Date("2026-02-19T00:00:00Z"), | |
24 | + }, | |
25 | + ])), | |
26 | + fakeSchema: { | |
27 | + users: { username: {} }, | |
28 | + repositories: { ownerId: {}, name: {}, id: {} }, | |
29 | + repositoryPathPermissions: { id: {}, repositoryId: {} }, | |
30 | + } as any, | |
31 | +})); | |
32 | + | |
33 | +let mockDb: any; | |
34 | + | |
35 | +vi.mock("@/db", () => ({ | |
36 | + getDatabase: () => mockDb, | |
37 | + schema: fakeSchema, | |
38 | +})); | |
39 | + | |
40 | +vi.mock("@/lib/auth", () => ({ | |
41 | + getUserFromRequest: getUserFromRequestMock, | |
42 | +})); | |
43 | + | |
44 | +vi.mock("@/lib/permissions", () => ({ | |
45 | + canReadRepo: canReadRepoMock, | |
46 | + canAdminRepo: canAdminRepoMock, | |
47 | +})); | |
48 | + | |
49 | +vi.mock("@/lib/path-scoping", () => ({ | |
50 | + getPathPermissions: getPathPermissionsMock, | |
51 | +})); | |
52 | + | |
53 | +import { GET as pathPermissionsGet, POST as pathPermissionsPost } from "@/pages/api/repos/[owner]/[repo]/settings/path-permissions"; | |
54 | +import { PUT as pathPermissionsPut, DELETE as pathPermissionsDelete } from "@/pages/api/repos/[owner]/[repo]/settings/path-permissions/[id]"; | |
55 | + | |
56 | +function makeDb() { | |
57 | + const owner = { id: "owner-1", username: "acme" }; | |
58 | + const repo = { id: "repo-1", ownerId: "owner-1", name: "demo" }; | |
59 | + const pathPermission = { | |
60 | + id: "perm-1", | |
61 | + repositoryId: "repo-1", | |
62 | + pathPattern: "packages/web/**", | |
63 | + userId: "user-1", | |
64 | + teamId: null, | |
65 | + permission: "write", | |
66 | + requireApproval: "false", | |
67 | + }; | |
68 | + | |
69 | + const insertCalls: Array<unknown> = []; | |
70 | + const updateCalls: Array<unknown> = []; | |
71 | + const deleteCalls: Array<unknown> = []; | |
72 | + | |
73 | + return { | |
74 | + query: { | |
75 | + users: { | |
76 | + findFirst: vi.fn(async () => owner), | |
77 | + }, | |
78 | + repositories: { | |
79 | + findFirst: vi.fn(async () => repo), | |
80 | + }, | |
81 | + repositoryPathPermissions: { | |
82 | + findFirst: vi.fn(async () => pathPermission), | |
83 | + }, | |
84 | + }, | |
85 | + insert: vi.fn(() => ({ | |
86 | + values: vi.fn(async (value: unknown) => { | |
87 | + insertCalls.push(value); | |
88 | + }), | |
89 | + })), | |
90 | + update: vi.fn(() => ({ | |
91 | + set: vi.fn((value: unknown) => { | |
92 | + updateCalls.push(value); | |
93 | + return { | |
94 | + where: vi.fn(async () => undefined), | |
95 | + }; | |
96 | + }), | |
97 | + })), | |
98 | + delete: vi.fn(() => ({ | |
99 | + where: vi.fn(async (value: unknown) => { | |
100 | + deleteCalls.push(value); | |
101 | + }), | |
102 | + })), | |
103 | + __state: { | |
104 | + insertCalls, | |
105 | + updateCalls, | |
106 | + deleteCalls, | |
107 | + }, | |
108 | + }; | |
109 | +} | |
110 | + | |
111 | +async function readJson(response: Response): Promise<any> { | |
112 | + return response.json(); | |
113 | +} | |
114 | + | |
115 | +describe("repository path permissions settings routes", () => { | |
116 | + beforeEach(() => { | |
117 | + mockDb = makeDb(); | |
118 | + getUserFromRequestMock.mockResolvedValue({ userId: "user-1", isAdmin: false }); | |
119 | + canReadRepoMock.mockResolvedValue(true); | |
120 | + canAdminRepoMock.mockResolvedValue(true); | |
121 | + }); | |
122 | + | |
123 | + it("lists path permission rules for readers", async () => { | |
124 | + const response = await pathPermissionsGet({ | |
125 | + params: { owner: "acme", repo: "demo" }, | |
126 | + request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions"), | |
127 | + } as any); | |
128 | + | |
129 | + const body = await readJson(response); | |
130 | + expect(response.status).toBe(200); | |
131 | + expect(body?.data?.rules).toHaveLength(1); | |
132 | + expect(body?.data?.summary?.totalRules).toBe(1); | |
133 | + }); | |
134 | + | |
135 | + it("creates a path permission for repo admins", async () => { | |
136 | + const response = await pathPermissionsPost({ | |
137 | + params: { owner: "acme", repo: "demo" }, | |
138 | + request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions", { | |
139 | + method: "POST", | |
140 | + headers: { "content-type": "application/json" }, | |
141 | + body: JSON.stringify({ | |
142 | + pathPattern: "packages/api/**", | |
143 | + teamId: "team-1", | |
144 | + permission: "write", | |
145 | + requireApproval: true, | |
146 | + }), | |
147 | + }), | |
148 | + } as any); | |
149 | + | |
150 | + const body = await readJson(response); | |
151 | + expect(response.status).toBe(201); | |
152 | + expect(body?.data?.pathPattern).toBe("packages/api/**"); | |
153 | + expect(body?.data?.teamId).toBe("team-1"); | |
154 | + expect(body?.data?.requireApproval).toBe("true"); | |
155 | + expect(mockDb.__state.insertCalls).toHaveLength(1); | |
156 | + }); | |
157 | + | |
158 | + it("rejects creation when neither userId nor teamId is provided", async () => { | |
159 | + const response = await pathPermissionsPost({ | |
160 | + params: { owner: "acme", repo: "demo" }, | |
161 | + request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions", { | |
162 | + method: "POST", | |
163 | + headers: { "content-type": "application/json" }, | |
164 | + body: JSON.stringify({ | |
165 | + pathPattern: "packages/shared/**", | |
166 | + permission: "read", | |
167 | + }), | |
168 | + }), | |
169 | + } as any); | |
170 | + | |
171 | + expect(response.status).toBe(400); | |
172 | + expect(mockDb.__state.insertCalls).toHaveLength(0); | |
173 | + }); | |
174 | + | |
175 | + it("updates a rule for repo admins", async () => { | |
176 | + const response = await pathPermissionsPut({ | |
177 | + params: { owner: "acme", repo: "demo", id: "perm-1" }, | |
178 | + request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions/perm-1", { | |
179 | + method: "PUT", | |
180 | + headers: { "content-type": "application/json" }, | |
181 | + body: JSON.stringify({ | |
182 | + pathPattern: "packages/mobile/**", | |
183 | + permission: "admin", | |
184 | + requireApproval: true, | |
185 | + }), | |
186 | + }), | |
187 | + } as any); | |
188 | + | |
189 | + const body = await readJson(response); | |
190 | + expect(response.status).toBe(200); | |
191 | + expect(body?.data?.updated).toBe(true); | |
192 | + expect(mockDb.__state.updateCalls).toHaveLength(1); | |
193 | + }); | |
194 | + | |
195 | + it("deletes a rule for repo admins", async () => { | |
196 | + const response = await pathPermissionsDelete({ | |
197 | + params: { owner: "acme", repo: "demo", id: "perm-1" }, | |
198 | + request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions/perm-1", { | |
199 | + method: "DELETE", | |
200 | + }), | |
201 | + } as any); | |
202 | + | |
203 | + const body = await readJson(response); | |
204 | + expect(response.status).toBe(200); | |
205 | + expect(body?.data?.deleted).toBe(true); | |
206 | + expect(mockDb.__state.deleteCalls).toHaveLength(1); | |
207 | + }); | |
208 | +}); | |
0 | 209 | |