215 lines
10 KiB
TypeScript
215 lines
10 KiB
TypeScript
// @vitest-environment node
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import jwt from "jsonwebtoken";
|
|
|
|
// Mocks installed before the module under test is imported.
|
|
const getUserInfoMock = vi.fn();
|
|
const evaluateFlagForContextMock = vi.fn();
|
|
const getTenantUserTypeMock = vi.fn();
|
|
const getRowByIdMock = vi.fn();
|
|
const getUserRowPermissionMock = vi.fn();
|
|
const knowledgeBaseArticleBelongsToTenantMock = vi.fn();
|
|
|
|
vi.mock("~/utils/session.server", () => ({
|
|
getUserInfo: getUserInfoMock,
|
|
}));
|
|
vi.mock("~/utils/services/.server/featureFlagEvaluator.server", () => ({
|
|
evaluateFlagForContext: evaluateFlagForContextMock,
|
|
}));
|
|
vi.mock("~/utils/db/tenants.db.server", () => ({
|
|
getTenantUserType: getTenantUserTypeMock,
|
|
}));
|
|
vi.mock("~/utils/db/entities/rows.db.server", () => ({
|
|
getRowById: getRowByIdMock,
|
|
}));
|
|
vi.mock("~/utils/helpers/.server/PermissionsService", () => ({
|
|
getUserRowPermission: getUserRowPermissionMock,
|
|
}));
|
|
vi.mock("~/modules/knowledgeBase/db/kbArticles.db.server", () => ({
|
|
knowledgeBaseArticleBelongsToTenant: knowledgeBaseArticleBelongsToTenantMock,
|
|
}));
|
|
|
|
const TEST_SECRET = "test-collab-secret-please-rotate-min-32";
|
|
|
|
async function importAction() {
|
|
const mod = await import("./api.collab.token");
|
|
return mod.action;
|
|
}
|
|
|
|
function buildRequest(body: unknown, method: "POST" | "GET" = "POST"): Request {
|
|
return new Request("http://localhost/api/collab/token", {
|
|
method,
|
|
headers: { "content-type": "application/json" },
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
});
|
|
}
|
|
|
|
describe("POST /api/collab/token", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
getUserInfoMock.mockReset();
|
|
evaluateFlagForContextMock.mockReset();
|
|
getTenantUserTypeMock.mockReset();
|
|
getRowByIdMock.mockReset();
|
|
getUserRowPermissionMock.mockReset();
|
|
knowledgeBaseArticleBelongsToTenantMock.mockReset();
|
|
// Resource-access defaults to allowed so existing flag/membership tests are unaffected.
|
|
knowledgeBaseArticleBelongsToTenantMock.mockResolvedValue(true);
|
|
getRowByIdMock.mockResolvedValue({ id: "row_1", permissions: [], createdByUserId: null });
|
|
getUserRowPermissionMock.mockResolvedValue({ canRead: true, canUpdate: true });
|
|
process.env.HOCUSPOCUS_AUTH_SECRET = TEST_SECRET;
|
|
});
|
|
afterEach(() => {
|
|
delete process.env.HOCUSPOCUS_AUTH_SECRET;
|
|
});
|
|
|
|
it("returns 503 when the server secret is not configured", async () => {
|
|
delete process.env.HOCUSPOCUS_AUTH_SECRET;
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "t", resourceType: "kb-article", resourceId: "r" }) } as never);
|
|
expect(res.status).toBe(503);
|
|
});
|
|
|
|
it("returns 405 for non-POST methods", async () => {
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest(undefined, "GET") } as never);
|
|
expect(res.status).toBe(405);
|
|
});
|
|
|
|
it("returns 401 when no userId on session", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: null });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "t", resourceType: "kb-article", resourceId: "r" }) } as never);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns 400 when body is missing fields", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "t" }) } as never);
|
|
expect(res.status).toBe(400);
|
|
});
|
|
|
|
it("returns 400 when tenantId or resourceType would make an ambiguous document name", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
const action = await importAction();
|
|
const tenantRes = await action({ request: buildRequest({ tenantId: "tenant:abc", resourceType: "kb-article", resourceId: "art_1" }) } as never);
|
|
const resourceTypeRes = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "kb:article", resourceId: "art_1" }) } as never);
|
|
|
|
expect(tenantRes.status).toBe(400);
|
|
expect(resourceTypeRes.status).toBe(400);
|
|
expect(getTenantUserTypeMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows resourceId colons because hocuspocus parses the third document-name segment as the remainder", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "folder:art_1" }) } as never);
|
|
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { documentName: string };
|
|
expect(json.documentName).toBe("tenant_abc:kb-article:folder:art_1");
|
|
});
|
|
|
|
it("returns 403 before feature-flag evaluation when the user is not an active member of the requested tenant", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue(null);
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "other_tenant", resourceType: "kb-article", resourceId: "art_1" }) } as never);
|
|
|
|
expect(res.status).toBe(403);
|
|
expect(await res.json()).toEqual({ error: "forbidden", reason: "tenant-membership-required" });
|
|
expect(getTenantUserTypeMock).toHaveBeenCalledWith("u_1", "other_tenant");
|
|
expect(evaluateFlagForContextMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns 403 when the feature-flag is disabled for the tenant", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: false, reason: "disabled" });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "t", resourceType: "kb-article", resourceId: "art_1" }) } as never);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("issues a signed JWT with the expected claims and 15min TTL when enabled", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_42" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_xyz" }) } as never);
|
|
expect(res.status).toBe(200);
|
|
const json = (await res.json()) as { token: string; expiresIn: number; refreshAfter: number; documentName: string };
|
|
expect(json.expiresIn).toBe(900);
|
|
expect(json.refreshAfter).toBe(840);
|
|
expect(json.documentName).toBe("tenant_abc:kb-article:art_xyz");
|
|
expect(getTenantUserTypeMock).toHaveBeenCalledWith("u_42", "tenant_abc");
|
|
const decoded = jwt.verify(json.token, TEST_SECRET, { algorithms: ["HS256"] }) as {
|
|
userId: string;
|
|
tenantId: string;
|
|
resourceType: string;
|
|
resourceId: string;
|
|
exp: number;
|
|
iat: number;
|
|
};
|
|
expect(decoded.userId).toBe("u_42");
|
|
expect(decoded.tenantId).toBe("tenant_abc");
|
|
expect(decoded.resourceType).toBe("kb-article");
|
|
expect(decoded.resourceId).toBe("art_xyz");
|
|
expect(decoded.exp - decoded.iat).toBe(900);
|
|
});
|
|
|
|
it("issues a token for a row the member can read", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
getUserRowPermissionMock.mockResolvedValue({ canRead: true, canUpdate: false });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "row", resourceId: "row_1" }) } as never);
|
|
expect(res.status).toBe(200);
|
|
expect(getRowByIdMock).toHaveBeenCalledWith("row_1", { tenantId: "tenant_abc" });
|
|
});
|
|
|
|
it("returns 403 for a row the member has no permission on (intra-tenant IDOR)", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
getUserRowPermissionMock.mockResolvedValue({ canRead: false, canUpdate: false });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "row", resourceId: "row_secret" }) } as never);
|
|
expect(res.status).toBe(403);
|
|
expect(await res.json()).toEqual({ error: "forbidden", reason: "resource-access-denied" });
|
|
});
|
|
|
|
it("returns 403 for a row that does not exist in the tenant", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
getRowByIdMock.mockResolvedValue(null);
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "row", resourceId: "row_other_tenant" }) } as never);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns 403 for a kb-article that does not belong to the tenant", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
knowledgeBaseArticleBelongsToTenantMock.mockResolvedValue(false);
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "kb-article", resourceId: "art_foreign" }) } as never);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
|
|
it("returns 403 for an unknown resource type (fail closed)", async () => {
|
|
getUserInfoMock.mockResolvedValue({ userId: "u_1" });
|
|
getTenantUserTypeMock.mockResolvedValue({ status: 2, type: 1 });
|
|
evaluateFlagForContextMock.mockResolvedValue({ enabled: true, reason: "matched" });
|
|
const action = await importAction();
|
|
const res = await action({ request: buildRequest({ tenantId: "tenant_abc", resourceType: "secret-blob", resourceId: "x" }) } as never);
|
|
expect(res.status).toBe(403);
|
|
});
|
|
});
|