mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
e9e565dc2b
* fix: Access request logic for collection managers * test: Exercise collection-manager path in access request regression tests Grant the non-workspace-admin manager a collection-level Admin membership instead of a direct document-level membership, so authorization flows through the collection-manager path being tested for #12567. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
618 lines
18 KiB
TypeScript
618 lines
18 KiB
TypeScript
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
|
import { AccessRequest, UserMembership } from "@server/models";
|
|
import { AccessRequestStatus } from "@server/models/AccessRequest";
|
|
import {
|
|
buildAdmin,
|
|
buildCollection,
|
|
buildDocument,
|
|
buildTeam,
|
|
buildUser,
|
|
} from "@server/test/factories";
|
|
import { getTestServer } from "@server/test/support";
|
|
|
|
const server = getTestServer();
|
|
|
|
describe("#accessRequests.create", () => {
|
|
it("should require id", async () => {
|
|
const user = await buildUser();
|
|
const res = await server.post("/api/accessRequests.create", user);
|
|
const body = await res.json();
|
|
expect(res.status).toEqual(400);
|
|
expect(body.message).toEqual("documentId: Must be a valid UUID or slug");
|
|
});
|
|
|
|
it("should require authentication", async () => {
|
|
const document = await buildDocument();
|
|
const res = await server.post("/api/accessRequests.create", {
|
|
body: {
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
expect(res.status).toEqual(401);
|
|
});
|
|
|
|
it("should return 404 for non-existent document", async () => {
|
|
const user = await buildUser();
|
|
const res = await server.post("/api/accessRequests.create", user, {
|
|
body: {
|
|
documentId: "a8f22c38-f4eb-4909-8c30-b927af36c5f3",
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
expect(res.status).toEqual(404);
|
|
expect(body.message).toEqual("Resource not found");
|
|
});
|
|
|
|
it("should create access request for a document", async () => {
|
|
const team = await buildTeam();
|
|
const owner = await buildUser({ teamId: team.id });
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const collection = await buildCollection({
|
|
teamId: team.id,
|
|
userId: owner.id,
|
|
permission: null,
|
|
});
|
|
const document = await buildDocument({
|
|
teamId: team.id,
|
|
createdById: owner.id,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.create", requester, {
|
|
body: {
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(body.data.documentId).toEqual(document.id);
|
|
expect(body.data.userId).toEqual(requester.id);
|
|
});
|
|
|
|
it("should reject if user already has access", async () => {
|
|
const team = await buildTeam();
|
|
const owner = await buildUser({ teamId: team.id });
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
teamId: team.id,
|
|
createdById: owner.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.create", requester, {
|
|
body: {
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toEqual(400);
|
|
});
|
|
|
|
it("should work with document urlId", async () => {
|
|
const team = await buildTeam();
|
|
const owner = await buildUser({ teamId: team.id });
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const collection = await buildCollection({
|
|
teamId: team.id,
|
|
userId: owner.id,
|
|
permission: null,
|
|
});
|
|
const document = await buildDocument({
|
|
teamId: team.id,
|
|
createdById: owner.id,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.create", requester, {
|
|
body: {
|
|
documentId: document.urlId,
|
|
},
|
|
});
|
|
expect(res.status).toEqual(200);
|
|
});
|
|
|
|
it("should not allow new request if pending exists", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const collection = await buildCollection({
|
|
teamId: team.id,
|
|
userId: admin.id,
|
|
permission: null,
|
|
});
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
// Create first access request
|
|
const res1 = await server.post("/api/accessRequests.create", requester, {
|
|
body: {
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
|
|
// Try to create another
|
|
const res2 = await server.post("/api/accessRequests.create", requester, {
|
|
body: {
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
|
|
expect(res1.status).toEqual(200);
|
|
expect(res2.status).toEqual(400);
|
|
|
|
// Verify only one access request exists
|
|
const count = await AccessRequest.count({
|
|
where: {
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
},
|
|
});
|
|
expect(count).toEqual(1);
|
|
});
|
|
|
|
it("should allow creating new request after previous was dismissed", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const collection = await buildCollection({
|
|
teamId: team.id,
|
|
userId: admin.id,
|
|
permission: null,
|
|
});
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
// Create and dismiss first request
|
|
const res1 = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
status: AccessRequestStatus.Dismissed,
|
|
responderId: admin.id,
|
|
respondedAt: new Date(),
|
|
});
|
|
|
|
// Create new request
|
|
const res2 = await server.post("/api/accessRequests.create", requester, {
|
|
body: {
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
const body = await res2.json();
|
|
|
|
expect(res2.status).toEqual(200);
|
|
expect(body.data.id).not.toEqual(res1.id);
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Pending);
|
|
});
|
|
});
|
|
|
|
describe("#accessRequests.info", () => {
|
|
it("should require authentication", async () => {
|
|
const res = await server.post("/api/accessRequests.info");
|
|
expect(res.status).toEqual(401);
|
|
});
|
|
|
|
it("should fail if both id and documentId are missing", async () => {
|
|
const user = await buildUser();
|
|
const res = await server.post("/api/accessRequests.info", user);
|
|
expect(res.status).toEqual(400);
|
|
});
|
|
|
|
it("should return access request correctly by id", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.info", requester, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(body.data.id).toEqual(accessRequest.id);
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Pending);
|
|
});
|
|
|
|
it("should return access request correctly by documentId", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.info", requester, {
|
|
body: {
|
|
documentId: document.urlId,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(body.data.id).toEqual(accessRequest.id);
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Pending);
|
|
});
|
|
|
|
it("should return 404 if access request not found", async () => {
|
|
const user = await buildUser();
|
|
const res = await server.post("/api/accessRequests.info", user, {
|
|
body: {
|
|
id: "00000000-0000-0000-0000-000000000000",
|
|
},
|
|
});
|
|
expect(res.status).toEqual(404);
|
|
});
|
|
});
|
|
|
|
describe("#accessRequests.approve", () => {
|
|
it("should require authentication", async () => {
|
|
const res = await server.post("/api/accessRequests.approve");
|
|
expect(res.status).toEqual(401);
|
|
});
|
|
|
|
it("should approve an access request and grant access", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
status: AccessRequestStatus.Pending,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.approve", admin, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
permission: DocumentPermission.ReadWrite,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Approved);
|
|
expect(body.data.responderId).toEqual(admin.id);
|
|
|
|
// // Verify that the user now has access
|
|
const membership = await UserMembership.findOne({
|
|
where: {
|
|
userId: requester.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
expect(membership).toBeTruthy();
|
|
expect(membership?.permission).toEqual(DocumentPermission.ReadWrite);
|
|
});
|
|
|
|
it("should allow a document manager who is not a workspace admin to approve", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const manager = await buildUser({ teamId: team.id });
|
|
const collection = await buildCollection({
|
|
teamId: team.id,
|
|
userId: manager.id,
|
|
permission: null,
|
|
});
|
|
const document = await buildDocument({
|
|
teamId: team.id,
|
|
createdById: manager.id,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
await UserMembership.create({
|
|
userId: manager.id,
|
|
collectionId: collection.id,
|
|
createdById: manager.id,
|
|
permission: CollectionPermission.Admin,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
status: AccessRequestStatus.Pending,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.approve", manager, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
permission: DocumentPermission.Read,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toEqual(200);
|
|
|
|
const membership = await UserMembership.findOne({
|
|
where: {
|
|
userId: requester.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
expect(membership).toBeTruthy();
|
|
expect(membership?.permission).toEqual(DocumentPermission.Read);
|
|
});
|
|
|
|
it("should not allow non-managers to approve requests", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const nonManager = await buildUser();
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
await UserMembership.create({
|
|
userId: admin.id,
|
|
documentId: document.id,
|
|
createdById: admin.id,
|
|
permission: DocumentPermission.ReadWrite,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.approve", nonManager, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
permission: DocumentPermission.ReadWrite,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toEqual(403);
|
|
});
|
|
|
|
it("should be a no-op when user already has membership", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
await UserMembership.create({
|
|
userId: requester.id,
|
|
documentId: document.id,
|
|
createdById: admin.id,
|
|
permission: DocumentPermission.Read,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
status: AccessRequestStatus.Pending,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.approve", admin, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
permission: DocumentPermission.ReadWrite,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toEqual(200);
|
|
|
|
// Existing membership is unchanged.
|
|
const memberships = await UserMembership.findAll({
|
|
where: {
|
|
userId: requester.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
expect(memberships).toHaveLength(1);
|
|
expect(memberships[0].permission).toEqual(DocumentPermission.Read);
|
|
});
|
|
|
|
it("should not allow approving requests that have been responded to", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
// Create access request that's already approved
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
status: AccessRequestStatus.Approved,
|
|
responderId: admin.id,
|
|
respondedAt: new Date(),
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.approve", admin, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
permission: DocumentPermission.ReadWrite,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toEqual(400);
|
|
});
|
|
});
|
|
|
|
describe("#accessRequests.dismiss", () => {
|
|
it("should require authentication", async () => {
|
|
const res = await server.post("/api/accessRequests.dismiss");
|
|
expect(res.status).toEqual(401);
|
|
});
|
|
|
|
it("should dismiss an access request", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
// Create access request
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.dismiss", admin, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Dismissed);
|
|
expect(body.data.responderId).toEqual(admin.id);
|
|
|
|
const membership = await UserMembership.findOne({
|
|
where: {
|
|
userId: requester.id,
|
|
documentId: document.id,
|
|
},
|
|
});
|
|
expect(membership).toBeNull();
|
|
});
|
|
|
|
it("should allow a document manager who is not a workspace admin to dismiss", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const manager = await buildUser({ teamId: team.id });
|
|
const collection = await buildCollection({
|
|
teamId: team.id,
|
|
userId: manager.id,
|
|
permission: null,
|
|
});
|
|
const document = await buildDocument({
|
|
teamId: team.id,
|
|
createdById: manager.id,
|
|
collectionId: collection.id,
|
|
});
|
|
|
|
await UserMembership.create({
|
|
userId: manager.id,
|
|
collectionId: collection.id,
|
|
createdById: manager.id,
|
|
permission: CollectionPermission.Admin,
|
|
});
|
|
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.dismiss", manager, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Dismissed);
|
|
expect(body.data.responderId).toEqual(manager.id);
|
|
});
|
|
|
|
it("should not allow non-managers to dismiss requests", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const nonManager = await buildUser();
|
|
const document = await buildDocument({
|
|
userId: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
// add non-manager to the document with editor access
|
|
await UserMembership.create({
|
|
userId: admin.id,
|
|
documentId: document.id,
|
|
createdById: admin.id,
|
|
permission: DocumentPermission.ReadWrite,
|
|
});
|
|
|
|
// Create access request
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.dismiss", nonManager, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
},
|
|
});
|
|
|
|
expect(res.status).toEqual(403);
|
|
});
|
|
|
|
it("should be a no-op when request has already been responded to", async () => {
|
|
const team = await buildTeam();
|
|
const requester = await buildUser({ teamId: team.id });
|
|
const admin = await buildAdmin({ teamId: team.id });
|
|
const responder = await buildAdmin({ teamId: team.id });
|
|
const document = await buildDocument({
|
|
createdById: admin.id,
|
|
teamId: team.id,
|
|
});
|
|
|
|
const respondedAt = new Date();
|
|
const accessRequest = await AccessRequest.create({
|
|
documentId: document.id,
|
|
userId: requester.id,
|
|
teamId: team.id,
|
|
status: AccessRequestStatus.Dismissed,
|
|
responderId: responder.id,
|
|
respondedAt,
|
|
});
|
|
|
|
const res = await server.post("/api/accessRequests.dismiss", admin, {
|
|
body: {
|
|
id: accessRequest.id,
|
|
},
|
|
});
|
|
const body = await res.json();
|
|
|
|
expect(res.status).toEqual(200);
|
|
expect(body.data.status).toEqual(AccessRequestStatus.Dismissed);
|
|
expect(body.data.responderId).toEqual(responder.id);
|
|
});
|
|
});
|