Skip to content

Commit 62c9fbf

Browse files
committed
test(webapp): restore lost/weakened cases from consolidation review
1 parent baac879 commit 62c9fbf

1 file changed

Lines changed: 152 additions & 26 deletions

File tree

‎apps/webapp/test/userActorOrgScope.test.ts‎

Lines changed: 152 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,13 @@ const ctx = vi.hoisted(() => ({ prisma: undefined as unknown as PrismaClient }))
1818
const mocks = vi.hoisted(() => {
1919
const assertSourcePatActive = vi.fn<(...args: any[]) => Promise<boolean>>();
2020
assertSourcePatActive.mockResolvedValue(true);
21-
return { assertSourcePatActive };
21+
const resolveDashboardAgentRepoSnapshot = vi.fn(async (projectId: string) => ({
22+
owner: "acme",
23+
repo: projectId,
24+
sha: "a".repeat(40),
25+
tarballUrl: `https://codeload.example/${projectId}`,
26+
}));
27+
return { assertSourcePatActive, resolveDashboardAgentRepoSnapshot };
2228
});
2329

2430
vi.mock("~/db.server", () => {
@@ -65,12 +71,7 @@ vi.mock("~/services/dashboardAgent.server", async (importOriginal) => {
6571
const actual = await importOriginal<typeof import("~/services/dashboardAgent.server")>();
6672
return {
6773
...actual,
68-
resolveDashboardAgentRepoSnapshot: async (projectId: string) => ({
69-
owner: "acme",
70-
repo: projectId,
71-
sha: "a".repeat(40),
72-
tarballUrl: `https://codeload.example/${projectId}`,
73-
}),
74+
resolveDashboardAgentRepoSnapshot: mocks.resolveDashboardAgentRepoSnapshot,
7475
};
7576
});
7677
vi.mock("~/services/personalAccessToken.server", async (importOriginal) => {
@@ -85,13 +86,27 @@ vi.mock("~/services/personalAccessToken.server", async (importOriginal) => {
8586
});
8687

8788
let patBearerUserId = "";
89+
// The OSS fallback's ability is otherwise purely cap-driven (UAT) or permissive (PAT) — there's
90+
// no real path to a role that fails a specific `can()` check. Set this to force one, for the one
91+
// case that needs it; `undefined` (the default) leaves the real ability untouched.
92+
let forcedAbilityCan: ((action: string, resource: unknown) => boolean) | undefined;
8893

8994
// The real fallback's `authenticatePat` looks up a real PAT row by hash, which the synthetic
9095
// `tr_pat_e2e_token` bearer used below has none of. Only that lookup is stubbed; every UAT check
91-
// (`authenticateUserActor`) stays real.
96+
// (`authenticateUserActor`) stays real, aside from the opt-in ability override above.
9297
vi.mock("~/services/rbac.server", async (importOriginal) => {
9398
const actual = await importOriginal<typeof import("~/services/rbac.server")>();
9499
// Patched in place (not spread): the plugin's methods are bound to its own instance.
100+
const realAuthenticateUserActor = actual.rbac.authenticateUserActor.bind(actual.rbac);
101+
actual.rbac.authenticateUserActor = async (
102+
...args: Parameters<typeof realAuthenticateUserActor>
103+
) => {
104+
const result = await realAuthenticateUserActor(...args);
105+
if (result.ok && forcedAbilityCan) {
106+
return { ...result, ability: { ...result.ability, can: forcedAbilityCan } };
107+
}
108+
return result;
109+
};
95110
actual.rbac.authenticatePat = async () => ({
96111
ok: true as const,
97112
userId: patBearerUserId,
@@ -173,7 +188,7 @@ async function seedWorld(prisma: PrismaClient) {
173188
data: { organizationId: orgB.id, userId: crossMember.id, role: "ADMIN" },
174189
});
175190

176-
async function projectWithEnvironments(name: string, devOwnerMembershipId: string) {
191+
async function projectWithEnvironments(name: string, extraDevOwnerMembershipId?: string) {
177192
const projectSlug = `${slug}_${name}`;
178193
const project = await prisma.project.create({
179194
data: {
@@ -196,19 +211,30 @@ async function seedWorld(prisma: PrismaClient) {
196211
...data,
197212
projectId: project.id,
198213
organizationId: orgA.id,
199-
apiKey: `tr_${data.slug}_${projectSlug}`,
200-
pkApiKey: `pk_${data.slug}_${projectSlug}`,
214+
// Two dev rows can share a slug (unique key is projectId+slug+orgMemberId), so the key
215+
// needs its own uniqueness source too.
216+
apiKey: `tr_${data.slug}_${projectSlug}_${suffix()}`,
217+
pkApiKey: `pk_${data.slug}_${projectSlug}_${suffix()}`,
201218
shortcode: `${data.slug}${suffix()}`,
202219
},
203220
});
204221

205222
const prod = await envFor({ slug: "prod", type: "PRODUCTION" });
206223
const staging = await envFor({ slug: "stg", type: "STAGING" });
207-
const dev = await envFor({
208-
slug: "dev",
224+
// The caller's own dev, on every project — org membership never substitutes for it.
225+
const dev = await envFor({ slug: "dev", type: "DEVELOPMENT", orgMemberId: memberOfA.id });
226+
const devBranch = await envFor({
227+
slug: `dev-feat-${name}`,
209228
type: "DEVELOPMENT",
210-
orgMemberId: devOwnerMembershipId,
229+
branchName: `feat/${name}`,
230+
parentEnvironmentId: dev.id,
231+
orgMemberId: memberOfA.id,
211232
});
233+
// A second member's own dev, same slug — the unique key is (project, slug, orgMember), so
234+
// both rows coexist. Org membership doesn't hand this one over to the caller.
235+
const otherDev = extraDevOwnerMembershipId
236+
? await envFor({ slug: "dev", type: "DEVELOPMENT", orgMemberId: extraDevOwnerMembershipId })
237+
: undefined;
212238
const previewParent = await envFor({ slug: "preview", type: "PREVIEW" });
213239
const previewBranch = await envFor({
214240
slug: `preview-feat-${name}`,
@@ -265,6 +291,7 @@ async function seedWorld(prisma: PrismaClient) {
265291
const prodPromotion = await promote(prod, `${name}-prod-task`);
266292
const stagingPromotion = await promote(staging, `${name}-staging-task`);
267293
await promote(previewBranch, `${name}-preview-task`);
294+
await promote(devBranch, `${name}-dev-branch-task`);
268295

269296
async function runOn(
270297
environment: { id: string },
@@ -298,10 +325,21 @@ async function seedWorld(prisma: PrismaClient) {
298325
const run = await runOn(prod, "PRODUCTION", prodPromotion);
299326
const stagingRun = await runOn(staging, "STAGING", stagingPromotion);
300327

301-
return { project, prod, staging, dev, previewParent, previewBranch, run, stagingRun };
328+
return {
329+
project,
330+
prod,
331+
staging,
332+
dev,
333+
devBranch,
334+
otherDev,
335+
previewParent,
336+
previewBranch,
337+
run,
338+
stagingRun,
339+
};
302340
}
303341

304-
const current = await projectWithEnvironments("current", memberOfA.id);
342+
const current = await projectWithEnvironments("current");
305343
const sibling = await projectWithEnvironments("sibling", otherOfA.id);
306344

307345
const bProject = await prisma.project.create({
@@ -451,6 +489,7 @@ const projects = (opts: { token: string; organizationId?: string }) =>
451489
beforeEach(() => {
452490
mocks.assertSourcePatActive.mockReset();
453491
mocks.assertSourcePatActive.mockResolvedValue(true);
492+
forcedAbilityCan = undefined;
454493
});
455494

456495
describe("an org-wide token reaches a sibling project across every UAT route", () => {
@@ -555,6 +594,18 @@ describe("an org-wide token reaches a sibling project across every UAT route", (
555594
});
556595
expect(scopedSibling.status).toBe(403);
557596
expect(scopedSibling.body.code).toBe("forbidden_environment");
597+
598+
// An org claim naming the right org still needs membership of it.
599+
const outsiderToken = await mintUat({
600+
userId: world.stranger.id,
601+
organizationId: world.orgA.id,
602+
});
603+
const outsider = await route.callSibling({
604+
token: outsiderToken,
605+
projectRef: world.sibling.project.externalRef,
606+
env: "prod",
607+
});
608+
expect(outsider.status).toBe(404);
558609
}
559610

560611
// Route-specific extras: a preview branch of the sibling project, and another member's dev
@@ -584,15 +635,23 @@ describe("an org-wide token reaches a sibling project across every UAT route", (
584635
expect(branchSnapshot.status).toBe(200);
585636
expect(branchSnapshot.body.repo).toBe(world.sibling.project.id);
586637

638+
// Another member's dev env: org membership doesn't hand it over. `crossMember` has no dev
639+
// row of their own on "sibling" — `member` now does, so this uses the one caller who can
640+
// prove that without accidentally hitting their own row instead.
641+
const noDevToken = await mintUat({
642+
userId: world.crossMember.id,
643+
organizationId: world.orgA.id,
644+
environmentId: world.current.prod.id,
645+
});
587646
const otherDevWorker = await worker({
588-
token,
647+
token: noDevToken,
589648
projectRef: world.sibling.project.externalRef,
590649
env: "dev",
591650
});
592651
expect(otherDevWorker.status).toBe(404);
593652

594653
const otherDevSnapshot = await snapshot({
595-
token,
654+
token: noDevToken,
596655
projectRef: world.sibling.project.externalRef,
597656
env: "dev",
598657
});
@@ -629,22 +688,44 @@ postgresTest(
629688
};
630689
const token = await mintUat(minted);
631690

632-
// Every environment of a sibling project, dev included — none of them the token's own.
691+
// Every environment of a sibling project, dev included — the caller's own dev row, not the
692+
// second member's, and none of them the token's own environment.
633693
const sibling = await environments({ token, projectRef: world.sibling.project.externalRef });
634694
expect(sibling.status).toBe(200);
635-
// The member's own dev env lives on "current", not "sibling" — dev is per-user.
636-
expect(sibling.body.map((e: any) => e.slug).sort()).toEqual(["preview", "prod", "stg"]);
695+
expect(sibling.body.map((e: any) => e.slug).sort()).toEqual(["dev", "preview", "prod", "stg"]);
637696

638697
// Its own project answers the same way: with an org claim, the org is the boundary.
639698
const own = await environments({ token, projectRef: world.current.project.externalRef });
640699
expect(own.status).toBe(200);
641700
expect(own.body.map((e: any) => e.slug).sort()).toEqual(["dev", "preview", "prod", "stg"]);
642701

643-
// A project outside the claimed organization.
644-
// The RBAC plugin's own membership floor turns this away before the route's org-scope
645-
// check ever runs, so this proves the same boundary one layer earlier.
646-
const foreign = await environments({ token, projectRef: world.bProject.externalRef });
702+
// A project outside the claimed organization — routed through a member of both orgs so the
703+
// RBAC plugin's own membership floor doesn't intercept first; this exercises the route's own
704+
// org-scope check and its error shape.
705+
const crossEnvToken = await mintUat({
706+
userId: world.crossMember.id,
707+
organizationId: world.orgA.id,
708+
});
709+
const foreign = await environments({
710+
token: crossEnvToken,
711+
projectRef: world.bProject.externalRef,
712+
});
647713
expect(foreign.status).toBe(403);
714+
expect(foreign.body.code).toBe("forbidden_environment");
715+
716+
// An org claim naming the right org still needs membership of it. This route resolves its
717+
// org before authenticating (its `context` looks the project up directly), so the RBAC
718+
// plugin's own membership floor turns a non-member away here — one layer earlier than the
719+
// route's own `findProjectByRef` check would.
720+
const outsiderToken = await mintUat({
721+
userId: world.stranger.id,
722+
organizationId: world.orgA.id,
723+
});
724+
const outsider = await environments({
725+
token: outsiderToken,
726+
projectRef: world.current.project.externalRef,
727+
});
728+
expect(outsider.status).toBe(403);
648729

649730
// An environment claim with no org claim: that environment only, nothing in another project.
650731
const envOnlyToken = await mintUat({
@@ -745,6 +826,20 @@ postgresTest(
745826
const foreign = await jwt({ token, projectRef: world.bProject.externalRef, env: "prod" });
746827
expect(foreign.status).toBe(404);
747828

829+
// A member of both orgs still can't reach the other org's environment with an orgA claim —
830+
// membership resolves the project, so this proves the claim's own org boundary.
831+
const crossToken = await mintUat({
832+
userId: world.crossMember.id,
833+
organizationId: world.orgA.id,
834+
});
835+
const crossForeign = await jwt({
836+
token: crossToken,
837+
projectRef: world.bProject.externalRef,
838+
env: "prod",
839+
});
840+
expect(crossForeign.status).toBe(403);
841+
expect(crossForeign.body.code).toBe("forbidden_environment");
842+
748843
// A non-member of the claimed org.
749844
const outsiderToken = await mintUat({
750845
userId: world.stranger.id,
@@ -815,7 +910,8 @@ postgresTest(
815910
await expect(
816911
assertUserActorEnvironmentAccess(undefined, world.current.prod)
817912
).resolves.toBeUndefined();
818-
}
913+
},
914+
60_000
819915
);
820916

821917
describe("a user-actor token's environment scope, driven through every route", () => {
@@ -925,6 +1021,13 @@ describe("a user-actor token's environment scope, driven through every route", (
9251021
target: world.current.previewBranch,
9261022
fallsBackTo: world.current.previewParent,
9271023
},
1024+
{
1025+
name: "a development branch",
1026+
env: "dev",
1027+
branch: "feat/current",
1028+
target: world.current.devBranch,
1029+
fallsBackTo: world.current.dev,
1030+
},
9281031
];
9291032

9301033
for (const { name, env, branch, target, fallsBackTo } of CASES) {
@@ -1141,6 +1244,29 @@ postgresTest(
11411244
60_000
11421245
);
11431246

1247+
postgresTest(
1248+
"repo-snapshot authorization denies a role the ability itself refuses",
1249+
async ({ prisma }) => {
1250+
ctx.prisma = prisma;
1251+
const world = await seedWorld(prisma);
1252+
// The OSS fallback's ability is otherwise cap-driven (UAT) or permissive (PAT), so a role
1253+
// that fails `can()` independent of cap is forced here rather than reachable for real.
1254+
forcedAbilityCan = () => false;
1255+
mocks.resolveDashboardAgentRepoSnapshot.mockClear();
1256+
1257+
const token = await mintUat({ userId: world.member.id, environmentId: world.current.prod.id });
1258+
const denied = await snapshot({
1259+
token,
1260+
projectRef: world.current.project.externalRef,
1261+
env: "prod",
1262+
});
1263+
1264+
expect(denied.status).toBe(403);
1265+
expect(mocks.resolveDashboardAgentRepoSnapshot).not.toHaveBeenCalled();
1266+
},
1267+
60_000
1268+
);
1269+
11441270
describe("resolveAgentTokenScope", () => {
11451271
it("pins an environment-only token and ignores the request", () => {
11461272
const scope = resolveAgentTokenScope(

0 commit comments

Comments
 (0)