225 lines
9.0 KiB
TypeScript
225 lines
9.0 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
import { getRlsExecutionContext, runWithRlsContext, runWithRlsTransaction } from "@hivegrid/db/rls-context.server";
|
|
|
|
const dbMock = vi.hoisted(() => ({
|
|
adminUser: { findMany: vi.fn(), findUnique: vi.fn() },
|
|
user: { findMany: vi.fn(), count: vi.fn(), findUnique: vi.fn(), create: vi.fn() },
|
|
tenant: { findMany: vi.fn() },
|
|
tenantUser: { findMany: vi.fn() },
|
|
userRole: { findMany: vi.fn() },
|
|
log: { findMany: vi.fn() },
|
|
$transaction: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db.server", () => ({ db: dbMock }));
|
|
vi.mock("~/utils/auth/passwordHasher.server", () => ({ hashPassword: vi.fn() }));
|
|
vi.mock("~/modules/plugins/services/.server/pluginHookPublisher", () => ({ emitPluginHookFireAndForget: vi.fn() }));
|
|
vi.mock("./user-snapshot-cache.server", () => ({ invalidateUserSnapshot: vi.fn(), loadCachedUserSnapshot: vi.fn() }));
|
|
vi.mock("@hivegrid/db/rls-resolvers.server", () => ({ resolveUserEmailRlsIdentity: vi.fn() }));
|
|
|
|
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
|
|
import { hashPassword } from "~/utils/auth/passwordHasher.server";
|
|
import { resolveUserEmailRlsIdentity } from "@hivegrid/db/rls-resolvers.server";
|
|
import { adminGetAllUsers, emitUserCreatedHook, getUserByEmail, register } from "./users.db.server";
|
|
|
|
const userRoots = [
|
|
{
|
|
id: "user_1",
|
|
email: "one@example.test",
|
|
firstName: "One",
|
|
lastName: "User",
|
|
avatar: null,
|
|
phone: null,
|
|
admin: { userId: "user_1" },
|
|
defaultTenantId: "tenant_1",
|
|
createdAt: new Date("2026-07-14T08:00:00.000Z"),
|
|
locale: "de",
|
|
lightOrDarkMode: null,
|
|
},
|
|
{
|
|
id: "user_2",
|
|
email: "two@example.test",
|
|
firstName: "Two",
|
|
lastName: "User",
|
|
avatar: null,
|
|
phone: null,
|
|
admin: null,
|
|
defaultTenantId: "tenant_2",
|
|
createdAt: new Date("2026-07-14T09:00:00.000Z"),
|
|
locale: "de",
|
|
lightOrDarkMode: null,
|
|
},
|
|
];
|
|
|
|
describe("admin user RLS hydration", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
dbMock.user.findMany.mockResolvedValue(userRoots);
|
|
dbMock.user.count.mockResolvedValue(2);
|
|
dbMock.adminUser.findMany.mockResolvedValue([{ id: "user_1" }]);
|
|
dbMock.$transaction.mockImplementation(async (callback: (tx: typeof dbMock) => Promise<unknown>) => callback(dbMock));
|
|
vi.mocked(hashPassword).mockResolvedValue("password-hash");
|
|
vi.mocked(resolveUserEmailRlsIdentity).mockResolvedValue({ resourceId: "resolved_user", userId: "resolved_user" });
|
|
dbMock.tenant.findMany.mockResolvedValue([{ id: "tenant_1" }, { id: "tenant_2" }]);
|
|
dbMock.userRole.findMany.mockImplementation(async (args) => {
|
|
const tenantId = getRlsExecutionContext()?.tenantId;
|
|
if (args.where.tenantId === null) {
|
|
expect(tenantId).toBeUndefined();
|
|
return [{ id: "global_role_1", userId: "user_1", roleId: "admin_role", tenantId: null, role: { id: "admin_role", type: "admin" } }];
|
|
}
|
|
expect(args.where.tenantId).toBe(tenantId);
|
|
return [
|
|
{
|
|
id: `tenant_role_${tenantId}`,
|
|
userId: tenantId === "tenant_1" ? "user_1" : "user_2",
|
|
roleId: `app_role_${tenantId}`,
|
|
tenantId,
|
|
role: { id: `app_role_${tenantId}`, type: "app" },
|
|
},
|
|
];
|
|
});
|
|
dbMock.tenantUser.findMany.mockImplementation(async (args) => {
|
|
const tenantId = getRlsExecutionContext()?.tenantId;
|
|
expect(args.where.tenantId).toBe(tenantId);
|
|
const userId = tenantId === "tenant_1" ? "user_1" : "user_2";
|
|
return [{ id: `membership_${tenantId}`, userId, tenantId, tenant: { id: tenantId, name: tenantId } }];
|
|
});
|
|
});
|
|
|
|
it("hydrates each membership and tenant role only inside its exact tenant context", async () => {
|
|
const result = await runWithRlsContext({ userId: "operator_1" }, () => adminGetAllUsers());
|
|
|
|
expect(dbMock.tenant.findMany).toHaveBeenCalledWith({ select: { id: true }, orderBy: { id: "asc" } });
|
|
expect(dbMock.tenantUser.findMany).toHaveBeenCalledTimes(2);
|
|
expect(result.items[0]?.tenants.map(({ tenantId }) => tenantId)).toEqual(["tenant_1"]);
|
|
expect(result.items[0]?.roles.map(({ tenantId }) => tenantId)).toEqual([null, "tenant_1"]);
|
|
expect(result.items[1]?.tenants.map(({ tenantId }) => tenantId)).toEqual(["tenant_2"]);
|
|
expect(result.items[1]?.roles.map(({ tenantId }) => tenantId)).toEqual(["tenant_2"]);
|
|
expect(dbMock.$transaction).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it("resolves a tenant filter through TenantUser without a cross-tenant relation include", async () => {
|
|
dbMock.tenantUser.findMany.mockImplementationOnce(async (args) => {
|
|
expect(getRlsExecutionContext()).toMatchObject({ userId: "operator_1", tenantId: "tenant_2" });
|
|
expect(args).toEqual({ where: { tenantId: "tenant_2" }, select: { userId: true } });
|
|
return [{ userId: "user_2" }];
|
|
});
|
|
|
|
await runWithRlsContext({ userId: "operator_1" }, () =>
|
|
adminGetAllUsers({
|
|
query: null,
|
|
properties: [{ name: "tenantId", title: "Tenant", manual: true, value: "tenant_2" }],
|
|
})
|
|
);
|
|
|
|
expect(dbMock.user.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { AND: [{}, { id: { in: ["user_2"] } }] },
|
|
select: expect.objectContaining({ id: true }),
|
|
})
|
|
);
|
|
});
|
|
|
|
it("binds the created user and target tenant when emitting a post-commit hook without context", async () => {
|
|
vi.mocked(emitPluginHookFireAndForget).mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()).toMatchObject({ userId: "user_3", tenantId: "tenant_3" });
|
|
expect(getRlsExecutionContext()?.transaction).toBeUndefined();
|
|
});
|
|
|
|
await emitUserCreatedHook({
|
|
id: "user_3",
|
|
email: "three@example.test",
|
|
locale: "de",
|
|
defaultTenantId: "tenant_3",
|
|
createdAt: new Date("2026-07-15T10:00:00.000Z"),
|
|
});
|
|
|
|
expect(emitPluginHookFireAndForget).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("keeps the authenticated actor but isolates a previous tenant transaction for the post-commit hook", async () => {
|
|
const oldTransaction = {};
|
|
vi.mocked(emitPluginHookFireAndForget).mockImplementationOnce(async () => {
|
|
expect(getRlsExecutionContext()).toMatchObject({ userId: "operator_1", tenantId: "tenant_3" });
|
|
expect(getRlsExecutionContext()?.transaction).toBeUndefined();
|
|
});
|
|
|
|
await runWithRlsContext({ userId: "operator_1", tenantId: "tenant_old" }, () =>
|
|
runWithRlsTransaction(oldTransaction, async () => {
|
|
await emitUserCreatedHook({
|
|
id: "user_3",
|
|
email: "three@example.test",
|
|
locale: "de",
|
|
defaultTenantId: "tenant_3",
|
|
createdAt: new Date("2026-07-15T10:00:00.000Z"),
|
|
});
|
|
expect(getRlsExecutionContext()).toMatchObject({ userId: "operator_1", tenantId: "tenant_old", transaction: oldTransaction });
|
|
})
|
|
);
|
|
|
|
expect(emitPluginHookFireAndForget).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("can defer the user-created hook until an invitation owner claim succeeds", async () => {
|
|
const createdAt = new Date("2026-07-15T11:00:00.000Z");
|
|
dbMock.user.create.mockResolvedValueOnce({
|
|
id: "user_invited",
|
|
email: "invited@example.test",
|
|
defaultTenantId: null,
|
|
locale: "de",
|
|
createdAt,
|
|
});
|
|
|
|
await expect(
|
|
register({
|
|
email: "invited@example.test",
|
|
password: "password",
|
|
firstName: "Invite",
|
|
lastName: "Owner",
|
|
locale: "de",
|
|
request: new Request("https://app.example.test/invitation/token"),
|
|
emitCreatedHook: false,
|
|
})
|
|
).resolves.toMatchObject({ id: "user_invited", createdAt });
|
|
|
|
expect(emitPluginHookFireAndForget).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("loads an email identity by the fail-closed resolver id instead of the caller casing", async () => {
|
|
const resolvedUser = { id: "resolved_user", email: "mixed@example.test", tenants: [], admin: null };
|
|
dbMock.user.findUnique.mockImplementationOnce(async (args) => {
|
|
expect(getRlsExecutionContext()).toMatchObject({ userId: "resolved_user" });
|
|
expect(args.where).toEqual({ id: "resolved_user" });
|
|
expect(args.where).not.toHaveProperty("email");
|
|
return resolvedUser;
|
|
});
|
|
|
|
await expect(getUserByEmail(" Mixed@Example.Test ")).resolves.toEqual(resolvedUser);
|
|
|
|
expect(resolveUserEmailRlsIdentity).toHaveBeenCalledWith(" Mixed@Example.Test ");
|
|
});
|
|
|
|
it("normalizes persisted and returned registration email", async () => {
|
|
dbMock.user.create.mockImplementationOnce(async ({ data }) => ({
|
|
...data,
|
|
createdAt: new Date("2026-07-15T12:00:00.000Z"),
|
|
}));
|
|
|
|
const result = await register({
|
|
email: " New.Owner@Example.Test ",
|
|
password: "password",
|
|
firstName: "New",
|
|
lastName: "Owner",
|
|
request: new Request("https://app.example.test/register"),
|
|
emitCreatedHook: false,
|
|
});
|
|
|
|
expect(dbMock.user.create).toHaveBeenCalledWith({
|
|
data: expect.objectContaining({ email: "new.owner@example.test" }),
|
|
});
|
|
expect(result.email).toBe("new.owner@example.test");
|
|
});
|
|
});
|