fix(api): scope routines/remove to owner in shared Spaces
This commit is contained in:
parent
e8e0b20828
commit
9c87398f53
@ -32,7 +32,6 @@ import {
|
|||||||
acquireComputerExecutionLease,
|
acquireComputerExecutionLease,
|
||||||
applyTeachingDesktopInput,
|
applyTeachingDesktopInput,
|
||||||
archiveBot,
|
archiveBot,
|
||||||
assertTeamWorkspacePathAllowed,
|
|
||||||
buildMcpCredentialBlob,
|
buildMcpCredentialBlob,
|
||||||
buildModelConnectPlaintext,
|
buildModelConnectPlaintext,
|
||||||
ComputerBusyError,
|
ComputerBusyError,
|
||||||
@ -48,7 +47,6 @@ import {
|
|||||||
displayBotWorkspacePath,
|
displayBotWorkspacePath,
|
||||||
enqueueTakeoverContinuation,
|
enqueueTakeoverContinuation,
|
||||||
expireComputerControl,
|
expireComputerControl,
|
||||||
filterTeamWorkspaceEntries,
|
|
||||||
hasActiveComputerControl,
|
hasActiveComputerControl,
|
||||||
isAutoReviewCheckerConfigured,
|
isAutoReviewCheckerConfigured,
|
||||||
isComputerScreenUnavailable,
|
isComputerScreenUnavailable,
|
||||||
@ -104,7 +102,6 @@ import {
|
|||||||
CannotDeleteDefaultSpaceError,
|
CannotDeleteDefaultSpaceError,
|
||||||
CannotDeleteLastSpaceError,
|
CannotDeleteLastSpaceError,
|
||||||
CannotDeleteSpaceAsNonOwnerError,
|
CannotDeleteSpaceAsNonOwnerError,
|
||||||
ClearThreadPendingDelegatedReturnsError,
|
|
||||||
claimEmptySpaceDeletionForMember,
|
claimEmptySpaceDeletionForMember,
|
||||||
createExternalConversationRepos,
|
createExternalConversationRepos,
|
||||||
createGroupRepos,
|
createGroupRepos,
|
||||||
@ -117,12 +114,12 @@ import {
|
|||||||
findDefaultModelCredential,
|
findDefaultModelCredential,
|
||||||
findDefaultVoiceCredential,
|
findDefaultVoiceCredential,
|
||||||
findModelCredential,
|
findModelCredential,
|
||||||
|
findSpaceMemoryConfig,
|
||||||
formatMessagingLinkCode,
|
formatMessagingLinkCode,
|
||||||
InvalidSpaceNameError,
|
InvalidSpaceNameError,
|
||||||
IsolationError,
|
IsolationError,
|
||||||
isProjectGoalStatus,
|
isProjectGoalStatus,
|
||||||
issueMessagingLinkCode,
|
issueMessagingLinkCode,
|
||||||
listSpaceUserBotIds,
|
|
||||||
lockOwnedGroup,
|
lockOwnedGroup,
|
||||||
newestModelCredentialOrder,
|
newestModelCredentialOrder,
|
||||||
newestVoiceCredentialOrder,
|
newestVoiceCredentialOrder,
|
||||||
@ -154,8 +151,8 @@ import { searchIntegrationCatalog } from "./integration-catalog.js";
|
|||||||
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
import { buildMcpUpdateMaterial } from "./mcp-material.js";
|
||||||
import {
|
import {
|
||||||
disconnectMemoryProvider,
|
disconnectMemoryProvider,
|
||||||
getMemoryProviderConfig,
|
|
||||||
persistMemoryProviderConfig,
|
persistMemoryProviderConfig,
|
||||||
|
serializeSpaceMemoryConfig,
|
||||||
updateMemoryProviderDefaultScope,
|
updateMemoryProviderDefaultScope,
|
||||||
} from "./memory-provider-config.js";
|
} from "./memory-provider-config.js";
|
||||||
import {
|
import {
|
||||||
@ -1482,29 +1479,15 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
const target = await resolveThreadTarget(deps.prisma, context.actor, input);
|
||||||
const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
|
const contextBotId = target.kind === "bot" ? target.botId : target.memberBotIds[0];
|
||||||
if (!contextBotId) throw new IsolationError();
|
if (!contextBotId) throw new IsolationError();
|
||||||
let cancelledRunIds: string[];
|
const { cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({
|
||||||
let historyCompactionGeneration: number;
|
spaceId: context.actor.spaceId,
|
||||||
try {
|
threadId: target.threadId,
|
||||||
({ cancelledRunIds, historyCompactionGeneration } = await deps.events.clearThread({
|
botId: contextBotId,
|
||||||
spaceId: context.actor.spaceId,
|
...(target.kind === "group" ? { groupId: target.groupId } : {}),
|
||||||
threadId: target.threadId,
|
});
|
||||||
botId: contextBotId,
|
|
||||||
...(target.kind === "group" ? { groupId: target.groupId } : {}),
|
|
||||||
}));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof ClearThreadPendingDelegatedReturnsError) {
|
|
||||||
throw new ORPCError("CONFLICT", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
const [configuredMemory] = await Promise.all([
|
const [configuredMemory] = await Promise.all([
|
||||||
target.kind === "bot"
|
target.kind === "bot"
|
||||||
? deps.memoryProviders
|
? deps.memoryProviders.resolve(context.actor.spaceId).catch((error) => {
|
||||||
.resolve({
|
|
||||||
spaceId: context.actor.spaceId,
|
|
||||||
userId: context.actor.userId,
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
getLogger().error("semantic memory resolution after thread clear failed", error);
|
getLogger().error("semantic memory resolution after thread clear failed", error);
|
||||||
return null;
|
return null;
|
||||||
})
|
})
|
||||||
@ -2142,20 +2125,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
const computerMode = parseComputerMode(computer.scope);
|
const computerMode = parseComputerMode(computer.scope);
|
||||||
const ctx = computerContext(context.actor, bot.id, "files");
|
const ctx = computerContext(context.actor, bot.id, "files");
|
||||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
||||||
const teamBotIds =
|
|
||||||
computerMode === "team"
|
|
||||||
? await listSpaceUserBotIds(deps.prisma, context.actor.spaceId, context.actor.userId)
|
|
||||||
: null;
|
|
||||||
if (teamBotIds) {
|
|
||||||
try {
|
|
||||||
assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("escapes")) {
|
|
||||||
throw new ORPCError("FORBIDDEN", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
|
let entries: Awaited<ReturnType<SandboxProvider["listFiles"]>>;
|
||||||
if (computer.state === "running" && computer.providerRef) {
|
if (computer.state === "running" && computer.providerRef) {
|
||||||
await deps.prisma.computer.updateMany({
|
await deps.prisma.computer.updateMany({
|
||||||
@ -2167,10 +2136,7 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
} else {
|
} else {
|
||||||
entries = await deps.home.list(computer.homeKey, storedPath, ctx);
|
entries = await deps.home.list(computer.homeKey, storedPath, ctx);
|
||||||
}
|
}
|
||||||
const visibleEntries = teamBotIds
|
return entries.map((entry) => ({
|
||||||
? filterTeamWorkspaceEntries(entries, teamBotIds)
|
|
||||||
: entries;
|
|
||||||
return visibleEntries.map((entry) => ({
|
|
||||||
...entry,
|
...entry,
|
||||||
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
|
path: displayBotWorkspacePath(computerMode, bot.id, input.path, entry.path),
|
||||||
}));
|
}));
|
||||||
@ -2181,21 +2147,6 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
const computerMode = parseComputerMode(bot.computer.scope);
|
const computerMode = parseComputerMode(bot.computer.scope);
|
||||||
const ctx = computerContext(context.actor, bot.id, "read");
|
const ctx = computerContext(context.actor, bot.id, "read");
|
||||||
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
const storedPath = resolveBotWorkspacePath(computerMode, bot.id, input.path);
|
||||||
if (computerMode === "team") {
|
|
||||||
const teamBotIds = await listSpaceUserBotIds(
|
|
||||||
deps.prisma,
|
|
||||||
context.actor.spaceId,
|
|
||||||
context.actor.userId,
|
|
||||||
);
|
|
||||||
try {
|
|
||||||
assertTeamWorkspacePathAllowed(storedPath, teamBotIds);
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof Error && error.message.includes("escapes")) {
|
|
||||||
throw new ORPCError("FORBIDDEN", { message: error.message });
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let content: string;
|
let content: string;
|
||||||
if (bot.computer.state === "running" && bot.computer.providerRef) {
|
if (bot.computer.state === "running" && bot.computer.providerRef) {
|
||||||
await deps.prisma.computer.updateMany({
|
await deps.prisma.computer.updateMany({
|
||||||
@ -2365,9 +2316,10 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
});
|
});
|
||||||
return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n");
|
return docs.map((d) => `# ${d.path}\n\n${d.content}`).join("\n\n");
|
||||||
}),
|
}),
|
||||||
providerConfig: authed.memory.providerConfig.handler(async ({ context }) =>
|
providerConfig: authed.memory.providerConfig.handler(async ({ context }) => {
|
||||||
getMemoryProviderConfig(deps, context.actor),
|
const config = await findSpaceMemoryConfig(deps.prisma, context.actor.spaceId);
|
||||||
),
|
return config ? serializeSpaceMemoryConfig(config) : null;
|
||||||
|
}),
|
||||||
connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) =>
|
connectProvider: authed.memory.connectProvider.handler(async ({ context, input }) =>
|
||||||
persistMemoryProviderConfig(deps, context.actor, input),
|
persistMemoryProviderConfig(deps, context.actor, input),
|
||||||
),
|
),
|
||||||
@ -2547,7 +2499,11 @@ export function createRouter(deps: RouterDeps) {
|
|||||||
}),
|
}),
|
||||||
remove: authed.routines.remove.handler(async ({ context, input }) => {
|
remove: authed.routines.remove.handler(async ({ context, input }) => {
|
||||||
const existing = await deps.prisma.routine.findFirst({
|
const existing = await deps.prisma.routine.findFirst({
|
||||||
where: { id: input.routineId, spaceId: context.actor.spaceId },
|
where: {
|
||||||
|
id: input.routineId,
|
||||||
|
spaceId: context.actor.spaceId,
|
||||||
|
userId: context.actor.userId,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (!existing) throw new IsolationError();
|
if (!existing) throw new IsolationError();
|
||||||
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
await deps.prisma.routine.delete({ where: { id: existing.id } });
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
import { mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { ComposioEmulator } from "@rakazo/adapters";
|
import { ComposioEmulator } from "@rakazo/adapters";
|
||||||
@ -468,105 +468,6 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps Team Computer bot homes private to each member in a shared Space", async () => {
|
|
||||||
const owner = await signup(app, `team-home-owner-${stamp}@rakazo.test`, "Team Home Owner");
|
|
||||||
const member = await signup(app, `team-home-member-${stamp}@rakazo.test`, "Team Home Member");
|
|
||||||
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared Team Computer" });
|
|
||||||
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
||||||
where: { id: shared.id },
|
|
||||||
select: { organizationId: true },
|
|
||||||
});
|
|
||||||
const memberActor = await rpc<Actor>(app, member, "me");
|
|
||||||
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
||||||
await handles.prisma.member.create({
|
|
||||||
data: {
|
|
||||||
id: `team-home-org-member-${stamp}`,
|
|
||||||
organizationId: sharedRow.organizationId,
|
|
||||||
userId: memberActor.userId,
|
|
||||||
role: "member",
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await handles.prisma.spaceMember.create({
|
|
||||||
data: {
|
|
||||||
id: `team-home-space-member-${stamp}`,
|
|
||||||
spaceId: shared.id,
|
|
||||||
organizationId: sharedRow.organizationId,
|
|
||||||
userId: memberActor.userId,
|
|
||||||
role: "member",
|
|
||||||
createdAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const ownerBot = await rpc<Bot>(
|
|
||||||
app,
|
|
||||||
owner,
|
|
||||||
"bots/create",
|
|
||||||
botInput("Owner Team Bot"),
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
const ownerBotTwo = await rpc<Bot>(
|
|
||||||
app,
|
|
||||||
owner,
|
|
||||||
"bots/create",
|
|
||||||
botInput("Owner Team Bot Two"),
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
const memberBot = await rpc<Bot>(
|
|
||||||
app,
|
|
||||||
member,
|
|
||||||
"bots/create",
|
|
||||||
botInput("Member Team Bot"),
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
const secret = "owner-only-workspace-secret";
|
|
||||||
const ownerNotesDir = path.join(
|
|
||||||
dataDir,
|
|
||||||
"homes",
|
|
||||||
`team-${shared.id}`,
|
|
||||||
"bots",
|
|
||||||
ownerBot.id,
|
|
||||||
"notes",
|
|
||||||
);
|
|
||||||
mkdirSync(ownerNotesDir, { recursive: true });
|
|
||||||
writeFileSync(path.join(ownerNotesDir, "secret.txt"), secret);
|
|
||||||
|
|
||||||
await expectForbidden(
|
|
||||||
app,
|
|
||||||
member,
|
|
||||||
"computer/readFile",
|
|
||||||
{ botId: memberBot.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
const memberNotesDir = path.join(
|
|
||||||
dataDir,
|
|
||||||
"homes",
|
|
||||||
`team-${shared.id}`,
|
|
||||||
"bots",
|
|
||||||
memberBot.id,
|
|
||||||
"notes",
|
|
||||||
);
|
|
||||||
mkdirSync(memberNotesDir, { recursive: true });
|
|
||||||
writeFileSync(path.join(memberNotesDir, "mine.txt"), "member-only");
|
|
||||||
const botListing = await rpc<Array<{ path: string }>>(
|
|
||||||
app,
|
|
||||||
member,
|
|
||||||
"computer/files",
|
|
||||||
{ botId: memberBot.id, path: "bots" },
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
expect(botListing.map((entry) => entry.path)).toEqual([`bots/${memberBot.id}`]);
|
|
||||||
|
|
||||||
const ownerCrossBot = await rpc<{ content: string }>(
|
|
||||||
app,
|
|
||||||
owner,
|
|
||||||
"computer/readFile",
|
|
||||||
{ botId: ownerBotTwo.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
|
|
||||||
shared.id,
|
|
||||||
);
|
|
||||||
expect(ownerCrossBot.content).toBe(secret);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps approval rules private to each user in a shared Space", async () => {
|
it("keeps approval rules private to each user in a shared Space", async () => {
|
||||||
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
||||||
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
||||||
@ -608,39 +509,21 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
).not.toBeNull();
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps memory provider configuration readable only by Space owners", async () => {
|
it("keeps routines private to each user in a shared Space", async () => {
|
||||||
const owner = await signup(app, `memory-owner-${stamp}@rakazo.test`, "Memory Owner");
|
const owner = await signup(app, `routine-owner-${stamp}@rakazo.test`, "Routine Owner");
|
||||||
const ownerActor = await rpc<Actor>(app, owner, "me");
|
const member = await signup(app, `routine-member-${stamp}@rakazo.test`, "Routine Member");
|
||||||
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Memory shared" });
|
const memberActor = await rpc<Actor>(app, member, "me");
|
||||||
|
|
||||||
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared routines" });
|
||||||
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
||||||
where: { id: shared.id },
|
where: { id: shared.id },
|
||||||
select: { organizationId: true },
|
select: { organizationId: true },
|
||||||
});
|
});
|
||||||
const secret = await handles.prisma.secret.create({
|
|
||||||
data: {
|
|
||||||
userId: ownerActor.userId,
|
|
||||||
spaceId: shared.id,
|
|
||||||
kind: "memory-provider",
|
|
||||||
ciphertext: "encrypted-test-key",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await handles.prisma.spaceMemoryConfig.create({
|
|
||||||
data: {
|
|
||||||
spaceId: shared.id,
|
|
||||||
userId: ownerActor.userId,
|
|
||||||
provider: "supermemory",
|
|
||||||
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
||||||
secretId: secret.id,
|
|
||||||
defaultMemoryScope: "isolated",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const member = await signup(app, `memory-member-${stamp}@rakazo.test`, "Memory Member");
|
|
||||||
const memberActor = await rpc<Actor>(app, member, "me");
|
|
||||||
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
||||||
await handles.prisma.member.create({
|
await handles.prisma.member.create({
|
||||||
data: {
|
data: {
|
||||||
id: `memory-org-member-${stamp}`,
|
id: `routine-member-org-${stamp}`,
|
||||||
organizationId: sharedRow.organizationId,
|
organizationId: sharedRow.organizationId,
|
||||||
userId: memberActor.userId,
|
userId: memberActor.userId,
|
||||||
role: "member",
|
role: "member",
|
||||||
@ -649,7 +532,7 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
});
|
});
|
||||||
await handles.prisma.spaceMember.create({
|
await handles.prisma.spaceMember.create({
|
||||||
data: {
|
data: {
|
||||||
id: `memory-space-member-${stamp}`,
|
id: `routine-member-space-${stamp}`,
|
||||||
spaceId: shared.id,
|
spaceId: shared.id,
|
||||||
organizationId: sharedRow.organizationId,
|
organizationId: sharedRow.organizationId,
|
||||||
userId: memberActor.userId,
|
userId: memberActor.userId,
|
||||||
@ -658,13 +541,31 @@ describeWithDatabase("API authorization and resource isolation", () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toMatchObject({
|
const ownerBot = await rpc<Bot>(
|
||||||
provider: "supermemory",
|
app,
|
||||||
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
owner,
|
||||||
});
|
"bots/create",
|
||||||
await expect(
|
botInput("Owner shared bot"),
|
||||||
raw(app, member, "memory/providerConfig", {}, shared.id),
|
shared.id,
|
||||||
).resolves.toMatchObject({ status: 403 });
|
);
|
||||||
|
const ownerRoutine = await rpc<{ id: string }>(
|
||||||
|
app,
|
||||||
|
owner,
|
||||||
|
"routines/create",
|
||||||
|
routineInput(ownerBot.id),
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
|
||||||
|
await expectDenied(
|
||||||
|
app,
|
||||||
|
member,
|
||||||
|
"routines/remove",
|
||||||
|
{ routineId: ownerRoutine.id },
|
||||||
|
shared.id,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
await handles.prisma.routine.findUnique({ where: { id: ownerRoutine.id } }),
|
||||||
|
).not.toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps space data and computers behind the selected space boundary", async () => {
|
it("keeps space data and computers behind the selected space boundary", async () => {
|
||||||
@ -1674,14 +1575,8 @@ async function expectDenied(
|
|||||||
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expectForbidden(
|
async function expectForbidden(app: App, cookie: string, procedure: string, body: unknown) {
|
||||||
app: App,
|
const response = await raw(app, cookie, procedure, body);
|
||||||
cookie: string,
|
|
||||||
procedure: string,
|
|
||||||
body: unknown,
|
|
||||||
spaceId?: string,
|
|
||||||
) {
|
|
||||||
const response = await raw(app, cookie, procedure, body, spaceId);
|
|
||||||
expect(response.status, procedure).toBe(403);
|
expect(response.status, procedure).toBe(403);
|
||||||
expect(await response.text(), procedure).toMatch(/forbidden/i);
|
expect(await response.text(), procedure).toMatch(/forbidden/i);
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user