Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/routes/api.collab.token.test.ts
T

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);
});
});