452 lines
18 KiB
TypeScript
452 lines
18 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
type SeedService = typeof import("./SeedService").default;
|
|
|
|
const dbMock = {
|
|
user: {
|
|
findUnique: vi.fn(async ({ where }: { where: { email: string } }) => ({ id: `user-${where.email}` })),
|
|
create: vi.fn(),
|
|
},
|
|
adminUser: {
|
|
findUnique: vi.fn(async () => ({ userId: "admin-id" })),
|
|
create: vi.fn(),
|
|
},
|
|
tenant: {
|
|
findUnique: vi.fn(async ({ where, include }: { where: { slug: string }; include?: unknown }) => ({
|
|
id: `tenant-${where.slug}`,
|
|
slug: where.slug,
|
|
name: where.slug,
|
|
...(include ? { subscription: { id: `subscription-${where.slug}`, products: [] } } : {}),
|
|
})),
|
|
create: vi.fn(),
|
|
update: vi.fn(),
|
|
},
|
|
tenantInboundAddress: {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
tenantSubscription: {
|
|
findUnique: vi.fn(async ({ where }: { where: { tenantId: string } }) => ({ id: `subscription-${where.tenantId}` })),
|
|
create: vi.fn(),
|
|
},
|
|
tenantUser: {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
subscriptionProduct: {
|
|
findFirst: vi.fn(async ({ where }: { where: { stripeId: string } }) => ({ id: `product-${where.stripeId}` })),
|
|
},
|
|
tenantSubscriptionProduct: {
|
|
create: vi.fn(),
|
|
},
|
|
tenantProductConfiguration: {
|
|
upsert: vi.fn(),
|
|
},
|
|
tenantFeatureOverride: {
|
|
upsert: vi.fn(),
|
|
},
|
|
entity: {
|
|
aggregate: vi.fn(async () => ({ _max: { order: 0 } })),
|
|
findMany: vi.fn(async () => []),
|
|
create: vi.fn(async ({ data }: { data: { name: string } }) => ({ id: `entity-${data.name}`, ...data })),
|
|
updateMany: vi.fn(),
|
|
},
|
|
entityWebhook: {
|
|
create: vi.fn(),
|
|
},
|
|
property: {
|
|
create: vi.fn(async ({ data }: { data: { name: string } }) => ({ id: `property-${data.name}`, ...data })),
|
|
},
|
|
propertyOption: {
|
|
deleteMany: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
propertyAttribute: {
|
|
deleteMany: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
permission: {
|
|
findMany: vi.fn(async ({ where }: { where: { name: { in: string[] } } }) => where.name.in.map((name) => ({ id: `permission-${name}`, name }))),
|
|
},
|
|
role: {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(async ({ data }: { data: { name: string } }) => ({ id: `role-${data.name}` })),
|
|
},
|
|
rolePermission: {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
userRole: {
|
|
findFirst: vi.fn(),
|
|
create: vi.fn(),
|
|
},
|
|
knowledgeBase: {
|
|
upsert: vi.fn(async ({ create }: { create: { slug: string } }) => ({ id: `kb-${create.slug}` })),
|
|
},
|
|
knowledgeBaseCategory: {
|
|
upsert: vi.fn(async ({ create }: { create: { slug: string } }) => ({ id: `category-${create.slug}` })),
|
|
},
|
|
knowledgeBaseArticle: {
|
|
upsert: vi.fn(),
|
|
},
|
|
supportTicket: {
|
|
upsert: vi.fn(),
|
|
},
|
|
};
|
|
|
|
const hashMock = vi.fn(async () => "hashed-password");
|
|
const genSaltMock = vi.fn(async () => "generated-salt");
|
|
const seedRolesAndPermissionsMock = vi.fn();
|
|
const seedStandardEntitlementsMock = vi.fn();
|
|
const originalEnv = { ...process.env };
|
|
|
|
const loggerInfo = vi.fn();
|
|
const loggerWarn = vi.fn();
|
|
const loggerError = vi.fn();
|
|
const loggerDebug = vi.fn();
|
|
vi.mock("~/utils/logger.server", () => ({
|
|
createScopedLogger: () => ({ info: loggerInfo, warn: loggerWarn, error: loggerError, debug: loggerDebug }),
|
|
}));
|
|
|
|
vi.mock("bcryptjs", () => ({ default: { genSalt: genSaltMock, hash: hashMock } }));
|
|
vi.mock("@toolctrl/db/entitlements-seed.server", () => ({
|
|
getEntitlementPlanStripeId: ({ id }: { id: string }) => `toolctrl_entitlement_plan_${id}`,
|
|
seedStandardEntitlements: seedStandardEntitlementsMock,
|
|
}));
|
|
vi.mock("@toolctrl/db/seed.server", async () => {
|
|
const actual = await vi.importActual<typeof import("@toolctrl/db/seed.server")>("@toolctrl/db/seed.server");
|
|
return actual;
|
|
});
|
|
vi.mock("~/utils/services/emailService", () => ({ getAvailableTenantInboundAddress: vi.fn(async () => "seed@example.test") }));
|
|
vi.mock("~/utils/services/rolesAndPermissionsService", () => ({ seedRolesAndPermissions: seedRolesAndPermissionsMock }));
|
|
vi.mock("~/utils/db.server", () => ({ db: dbMock }));
|
|
|
|
async function loadSeedService(): Promise<SeedService> {
|
|
vi.resetModules();
|
|
const module = await import("./SeedService");
|
|
return module.default;
|
|
}
|
|
|
|
describe("SeedService", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
process.env = { ...originalEnv };
|
|
delete process.env.SEED_ADMIN_EMAIL;
|
|
delete process.env.SEED_ADMIN_PASSWORD;
|
|
delete process.env.SEED_DEMO_DATA;
|
|
delete process.env.ALLOW_STAGING_DEMO_SEED;
|
|
process.env.NODE_ENV = "test";
|
|
});
|
|
|
|
it("rejects default admin credentials in production before DB writes", async () => {
|
|
process.env.NODE_ENV = "production";
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
const SeedService = await loadSeedService();
|
|
|
|
await expect(SeedService.seed()).rejects.toThrow("default seed credentials");
|
|
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
|
|
expect(dbMock.user.create).not.toHaveBeenCalled();
|
|
expect(seedRolesAndPermissionsMock).not.toHaveBeenCalled();
|
|
expect(seedStandardEntitlementsMock).not.toHaveBeenCalled();
|
|
expect(JSON.stringify(consoleLog.mock.calls)).not.toContain("password");
|
|
});
|
|
|
|
it("uses safe admin credential overrides in production", async () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.SEED_ADMIN_EMAIL = "owner@example.com";
|
|
process.env.SEED_ADMIN_PASSWORD = "safe-production-password";
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
const SeedService = await loadSeedService();
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(dbMock.user.findUnique).toHaveBeenCalledWith({ where: { email: "owner@example.com" } });
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalledWith({ where: { email: "john.doe@company.com" } });
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalledWith({ where: { email: "luna.davis@company.com" } });
|
|
expect(dbMock.tenant.findUnique).not.toHaveBeenCalled();
|
|
expect(genSaltMock).toHaveBeenCalledWith(10);
|
|
expect(hashMock).toHaveBeenCalledWith("safe-production-password", "generated-salt");
|
|
expect(seedRolesAndPermissionsMock).toHaveBeenCalledWith({
|
|
adminEmail: "owner@example.com",
|
|
diagnostics: expect.any(Function),
|
|
});
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({ op: "db-seed", phase: "start", nodeEnv: "production" }),
|
|
"DB seed event",
|
|
);
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({ op: "db-seed", phase: "success", nodeEnv: "production" }),
|
|
"DB seed event",
|
|
);
|
|
// The new logger redacts admin email behind a SHA-256 fingerprint. The
|
|
// raw email must never appear in any logger arg.
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("owner@example.com");
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("safe-production-password");
|
|
expect(JSON.stringify(consoleLog.mock.calls)).not.toContain("safe-production-password");
|
|
});
|
|
|
|
it("rejects blank admin credential overrides in production before DB writes", async () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.SEED_ADMIN_EMAIL = "";
|
|
process.env.SEED_ADMIN_PASSWORD = "safe-production-password";
|
|
const SeedService = await loadSeedService();
|
|
|
|
await expect(SeedService.seed()).rejects.toThrow("missing seed credentials");
|
|
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
|
|
expect(dbMock.user.create).not.toHaveBeenCalled();
|
|
expect(seedRolesAndPermissionsMock).not.toHaveBeenCalled();
|
|
expect(seedStandardEntitlementsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("refuses to promote an existing non-admin user during production seed", async () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.SEED_ADMIN_EMAIL = "owner@example.com";
|
|
process.env.SEED_ADMIN_PASSWORD = "safe-production-password";
|
|
dbMock.user.findUnique.mockResolvedValueOnce({ id: "existing-user", email: "owner@example.com" } as never);
|
|
dbMock.adminUser.findUnique.mockResolvedValueOnce(null as never);
|
|
const SeedService = await loadSeedService();
|
|
|
|
await expect(SeedService.seed()).rejects.toThrow("Production seed refused to promote an existing non-admin user");
|
|
|
|
expect(dbMock.adminUser.create).not.toHaveBeenCalled();
|
|
expect(seedRolesAndPermissionsMock).not.toHaveBeenCalled();
|
|
expect(seedStandardEntitlementsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("skips local demo users and tenants unless explicitly enabled", async () => {
|
|
const SeedService = await loadSeedService();
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(dbMock.user.findUnique).toHaveBeenCalledWith({ where: { email: "admin@email.com" } });
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalledWith({ where: { email: "john.doe@company.com" } });
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalledWith({ where: { email: "luna.davis@company.com" } });
|
|
expect(dbMock.tenant.findUnique).not.toHaveBeenCalled();
|
|
expect(loggerInfo).not.toHaveBeenCalledWith(
|
|
expect.objectContaining({ op: "seed.createDemoUsers" }),
|
|
expect.anything(),
|
|
);
|
|
});
|
|
|
|
it("seeds local demo users and tenants only when explicitly enabled outside production", async () => {
|
|
process.env.SEED_DEMO_DATA = "true";
|
|
const SeedService = await loadSeedService();
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(dbMock.user.findUnique).toHaveBeenCalledWith({ where: { email: "admin@email.com" } });
|
|
expect(dbMock.user.findUnique).toHaveBeenCalledWith({ where: { email: "john.doe@company.com" } });
|
|
expect(dbMock.user.findUnique).toHaveBeenCalledWith({ where: { email: "luna.davis@company.com" } });
|
|
expect(dbMock.tenant.findUnique).toHaveBeenCalledWith({ where: { slug: "demo-starter-crm" } });
|
|
expect(dbMock.tenant.findUnique).toHaveBeenCalledWith({ where: { slug: "demo-pro-crm" } });
|
|
expect(dbMock.tenant.findUnique).toHaveBeenCalledWith({ where: { slug: "demo-business-crm" } });
|
|
expect(dbMock.subscriptionProduct.findFirst).toHaveBeenCalledWith({
|
|
where: { stripeId: "toolctrl_entitlement_plan_starter" },
|
|
select: { id: true },
|
|
});
|
|
expect(dbMock.subscriptionProduct.findFirst).toHaveBeenCalledWith({
|
|
where: { stripeId: "toolctrl_entitlement_plan_pro" },
|
|
select: { id: true },
|
|
});
|
|
expect(dbMock.subscriptionProduct.findFirst).toHaveBeenCalledWith({
|
|
where: { stripeId: "toolctrl_entitlement_plan_business" },
|
|
select: { id: true },
|
|
});
|
|
expect(dbMock.tenantSubscriptionProduct.create).toHaveBeenCalledTimes(3);
|
|
expect(dbMock.entity.findMany).toHaveBeenCalledWith({
|
|
where: { name: { in: ["company", "contact", "opportunity"] } },
|
|
select: { id: true, name: true, type: true },
|
|
});
|
|
expect(dbMock.entity.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ name: "company", type: "all" }) }));
|
|
expect(dbMock.entity.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ name: "contact", type: "all" }) }));
|
|
expect(dbMock.entity.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ name: "opportunity", type: "all" }) }));
|
|
expect(dbMock.entity.create).not.toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ name: "submission" }) }));
|
|
expect(dbMock.tenantProductConfiguration.upsert).toHaveBeenCalledTimes(3);
|
|
expect(dbMock.knowledgeBase.upsert).toHaveBeenCalledTimes(3);
|
|
expect(dbMock.supportTicket.upsert).toHaveBeenCalledTimes(6);
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({ op: "seed.createDemoUsers", count: 2 }),
|
|
"Creating CRM SaaS demo users with tenants",
|
|
);
|
|
});
|
|
|
|
it("refuses demo data in production without the explicit staging override", async () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.SEED_DEMO_DATA = "true";
|
|
process.env.SEED_ADMIN_EMAIL = "owner@example.com";
|
|
process.env.SEED_ADMIN_PASSWORD = "safe-production-password";
|
|
const SeedService = await loadSeedService();
|
|
|
|
await expect(SeedService.seed()).rejects.toThrow("ALLOW_STAGING_DEMO_SEED=true");
|
|
|
|
expect(dbMock.user.findUnique).not.toHaveBeenCalled();
|
|
expect(seedRolesAndPermissionsMock).not.toHaveBeenCalled();
|
|
expect(seedStandardEntitlementsMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("allows explicit staging demo data while production runtime safety stays opt-in", async () => {
|
|
process.env.NODE_ENV = "production";
|
|
process.env.SEED_DEMO_DATA = "true";
|
|
process.env.ALLOW_STAGING_DEMO_SEED = "true";
|
|
process.env.SEED_ADMIN_EMAIL = "owner@example.com";
|
|
process.env.SEED_ADMIN_PASSWORD = "safe-production-password";
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
const SeedService = await loadSeedService();
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(dbMock.user.findUnique).toHaveBeenCalledWith({ where: { email: "john.doe@company.com" } });
|
|
expect(dbMock.tenant.findUnique).toHaveBeenCalledWith({ where: { slug: "demo-starter-crm" } });
|
|
expect(dbMock.tenantProductConfiguration.upsert).toHaveBeenCalled();
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({ op: "db-seed", phase: "start", nodeEnv: "staging-demo" }),
|
|
"DB seed event",
|
|
);
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("owner@example.com");
|
|
});
|
|
|
|
it("seeds standard entitlements once after roles and permissions", async () => {
|
|
const SeedService = await loadSeedService();
|
|
const calls: string[] = [];
|
|
seedRolesAndPermissionsMock.mockImplementation(async () => {
|
|
calls.push("roles");
|
|
});
|
|
seedStandardEntitlementsMock.mockImplementation(async () => {
|
|
calls.push("entitlements");
|
|
return {
|
|
planCount: 3,
|
|
featureCount: 9,
|
|
createdProducts: 3,
|
|
updatedProducts: 0,
|
|
deletedFeatures: 0,
|
|
createdFeatures: 9,
|
|
};
|
|
});
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(seedRolesAndPermissionsMock).toHaveBeenCalledWith({
|
|
adminEmail: "admin@email.com",
|
|
diagnostics: expect.any(Function),
|
|
});
|
|
expect(seedStandardEntitlementsMock).toHaveBeenCalledOnce();
|
|
expect(seedStandardEntitlementsMock).toHaveBeenCalledWith({
|
|
client: dbMock,
|
|
diagnostics: expect.any(Function),
|
|
});
|
|
expect(calls).toEqual(["roles", "entitlements"]);
|
|
});
|
|
|
|
it("logs redacted DB seed failure diagnostics without raw secret errors", async () => {
|
|
process.env.SEED_ADMIN_EMAIL = "owner@example.com";
|
|
process.env.SEED_ADMIN_PASSWORD = "safe-secret-password";
|
|
const SeedService = await loadSeedService();
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
const originalError = new Error("failed with safe-secret-password token sk_live_secret");
|
|
seedRolesAndPermissionsMock.mockRejectedValueOnce(originalError);
|
|
|
|
await expect(SeedService.seed()).rejects.toBe(originalError);
|
|
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({ op: "db-seed", phase: "failure", nodeEnv: "test" }),
|
|
"DB seed event",
|
|
);
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("owner@example.com");
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("safe-secret-password");
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("sk_live_secret");
|
|
expect(JSON.stringify(consoleLog.mock.calls)).not.toContain("safe-secret-password");
|
|
expect(JSON.stringify(consoleLog.mock.calls)).not.toContain("sk_live_secret");
|
|
});
|
|
|
|
it("logs redacted roles and permissions seed diagnostics from the helper", async () => {
|
|
const SeedService = await loadSeedService();
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
seedRolesAndPermissionsMock.mockImplementation(async ({ diagnostics }) => {
|
|
await diagnostics?.({
|
|
phase: "success",
|
|
adminRoleCount: 1,
|
|
appRoleCount: 4,
|
|
defaultPermissionCount: 120,
|
|
dynamicEntityPermissionCount: 3,
|
|
adminUserAssignmentCount: 1,
|
|
tenantUserAssignmentCount: 8,
|
|
password: "password",
|
|
token: "sk_live_secret",
|
|
});
|
|
});
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
op: "seed.rolesAndPermissions",
|
|
phase: "success",
|
|
adminRoleCount: 1,
|
|
appRoleCount: 4,
|
|
defaultPermissionCount: 120,
|
|
dynamicEntityPermissionCount: 3,
|
|
adminUserAssignmentCount: 1,
|
|
tenantUserAssignmentCount: 8,
|
|
}),
|
|
"Roles and permissions seed event",
|
|
);
|
|
// The helper-supplied `password` and `token` fields must be dropped
|
|
// before they hit the logger.
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("password");
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("sk_live_secret");
|
|
});
|
|
|
|
it("logs redacted entitlements seed diagnostics from the helper", async () => {
|
|
const SeedService = await loadSeedService();
|
|
const consoleLog = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
seedStandardEntitlementsMock.mockImplementation(async ({ diagnostics }) => {
|
|
await diagnostics?.({
|
|
phase: "failure",
|
|
planCount: 3,
|
|
featureCount: 9,
|
|
createdProducts: 1,
|
|
updatedProducts: 2,
|
|
deletedFeatures: 4,
|
|
createdFeatures: 5,
|
|
planId: "team",
|
|
error: {
|
|
name: "Error",
|
|
message: "Failed for [redacted]",
|
|
},
|
|
});
|
|
return {
|
|
planCount: 3,
|
|
featureCount: 9,
|
|
createdProducts: 1,
|
|
updatedProducts: 2,
|
|
deletedFeatures: 4,
|
|
createdFeatures: 5,
|
|
};
|
|
});
|
|
|
|
await SeedService.seed();
|
|
|
|
expect(loggerInfo).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
op: "seed.entitlements",
|
|
phase: "failure",
|
|
planCount: 3,
|
|
featureCount: 9,
|
|
createdProducts: 1,
|
|
updatedProducts: 2,
|
|
deletedFeatures: 4,
|
|
createdFeatures: 5,
|
|
planId: "team",
|
|
err: expect.objectContaining({ name: "Error", message: "Failed for [redacted]" }),
|
|
}),
|
|
"Entitlements seed event",
|
|
);
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("sk_live_");
|
|
expect(JSON.stringify(loggerInfo.mock.calls)).not.toContain("cus_secret");
|
|
});
|
|
});
|