Files
HiveGrid-Wireframes/03 Quellen und Referenzen/hivegrid-cloud/apps/web/app/utils/db/users.db.server.admin-rls.test.ts
T

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