feat: add direct PostgreSQL and ACK deployment support
This commit is contained in:
126
tests/account-store-postgres-auth.test.ts
Normal file
126
tests/account-store-postgres-auth.test.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { hashLocalPassword, queryDatabase, transactionQuery, verifyLocalPassword, withDatabaseTransaction, state } = vi.hoisted(() => {
|
||||
const state = {
|
||||
user: {} as Record<string, unknown>,
|
||||
transactionTail: Promise.resolve() as Promise<unknown>
|
||||
};
|
||||
const transactionQuery = vi.fn(async (text: string, values: readonly unknown[] = []) => {
|
||||
if (text.includes("SELECT * FROM platform_users")) return { rows: [{ ...state.user }], rowCount: 1 };
|
||||
if (text.includes("SET failed_login_count=$2")) {
|
||||
state.user.failed_login_count = values[1];
|
||||
state.user.locked_until = values[2];
|
||||
state.user.updated_at = values[3];
|
||||
return { rows: [], rowCount: 1 };
|
||||
}
|
||||
if (text.includes("SET failed_login_count=0")) {
|
||||
state.user.failed_login_count = 0;
|
||||
state.user.locked_until = null;
|
||||
state.user.last_login_at = values[1];
|
||||
state.user.updated_at = values[1];
|
||||
return { rows: [{ ...state.user }], rowCount: 1 };
|
||||
}
|
||||
if (text.includes("SET password_hash=$2")) {
|
||||
state.user.password_hash = values[1];
|
||||
state.user.password_salt = values[2];
|
||||
state.user.session_version = Number(state.user.session_version) + 1;
|
||||
state.user.updated_at = values[3];
|
||||
return { rows: [{ ...state.user }], rowCount: 1 };
|
||||
}
|
||||
throw new Error(`Unexpected SQL: ${text}`);
|
||||
});
|
||||
const withDatabaseTransaction = vi.fn(<T>(callback: (client: { query: typeof transactionQuery }) => Promise<T>) => {
|
||||
const run = state.transactionTail.then(() => callback({ query: transactionQuery }));
|
||||
state.transactionTail = run.catch(() => undefined);
|
||||
return run;
|
||||
});
|
||||
return {
|
||||
hashLocalPassword: vi.fn(async (password: string) => ({ hash: `hash:${password}`, salt: `salt:${password}` })),
|
||||
queryDatabase: vi.fn(),
|
||||
transactionQuery,
|
||||
verifyLocalPassword: vi.fn(async (password: string, hash: string) => hash === `hash:${password}`),
|
||||
withDatabaseTransaction,
|
||||
state
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/server/database", () => ({
|
||||
isPostgresBackend: () => true,
|
||||
queryDatabase,
|
||||
withDatabaseTransaction
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/data-store", () => ({
|
||||
reassignOwnerData: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server/auth/password", () => ({
|
||||
hashLocalPassword,
|
||||
verifyLocalPassword
|
||||
}));
|
||||
|
||||
import { authenticatePlatformUser, changeOwnPassword } from "@/lib/server/account-store";
|
||||
|
||||
describe("PostgreSQL account authentication", () => {
|
||||
beforeEach(() => {
|
||||
queryDatabase.mockReset();
|
||||
transactionQuery.mockClear();
|
||||
withDatabaseTransaction.mockClear();
|
||||
hashLocalPassword.mockClear();
|
||||
verifyLocalPassword.mockClear();
|
||||
state.transactionTail = Promise.resolve();
|
||||
state.user = {
|
||||
id: "user-1",
|
||||
phone: "13800138000",
|
||||
display_name: "Concurrent user",
|
||||
role: "super_admin",
|
||||
organization_id: null,
|
||||
status: "active",
|
||||
password_hash: "hash",
|
||||
password_salt: "salt",
|
||||
failed_login_count: 0,
|
||||
locked_until: null,
|
||||
session_version: 1,
|
||||
last_login_at: null,
|
||||
legacy_subject: null,
|
||||
created_at: new Date("2026-08-12T00:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-12T00:00:00.000Z")
|
||||
};
|
||||
});
|
||||
|
||||
it("allows only one concurrent password change using the same current password", async () => {
|
||||
state.user.password_hash = "hash:current-password";
|
||||
state.user.password_salt = "salt:current-password";
|
||||
|
||||
const attempts = await Promise.allSettled([
|
||||
changeOwnPassword("user-1", "current-password", "next-password-one"),
|
||||
changeOwnPassword("user-1", "current-password", "next-password-two")
|
||||
]);
|
||||
|
||||
expect(attempts[0]).toMatchObject({ status: "fulfilled", value: { sessionVersion: 2 } });
|
||||
expect(attempts[1]).toMatchObject({ status: "rejected", reason: { status: 400 } });
|
||||
expect(state.user.password_hash).toBe("hash:next-password-one");
|
||||
expect(state.user.session_version).toBe(2);
|
||||
expect(withDatabaseTransaction).toHaveBeenCalledTimes(2);
|
||||
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(2);
|
||||
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("session_version=session_version+1"))).toHaveLength(1);
|
||||
expect(hashLocalPassword).toHaveBeenCalledTimes(1);
|
||||
expect(queryDatabase).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("serializes concurrent failures on the user row and locks on the fifth attempt", async () => {
|
||||
const attempts = await Promise.allSettled(
|
||||
Array.from({ length: 5 }, () => authenticatePlatformUser("138 0013 8000", "wrong-password"))
|
||||
);
|
||||
|
||||
expect(attempts.map((attempt) => attempt.status === "rejected" && attempt.reason.status)).toEqual([401, 401, 401, 401, 423]);
|
||||
expect(state.user.failed_login_count).toBe(0);
|
||||
expect(new Date(String(state.user.locked_until)).getTime()).toBeGreaterThan(Date.now());
|
||||
expect(withDatabaseTransaction).toHaveBeenCalledTimes(5);
|
||||
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("FOR UPDATE"))).toHaveLength(5);
|
||||
expect(queryDatabase).not.toHaveBeenCalled();
|
||||
|
||||
await expect(authenticatePlatformUser("13800138000", "wrong-password")).rejects.toMatchObject({ status: 423 });
|
||||
expect(transactionQuery.mock.calls.filter(([sql]) => String(sql).includes("SET failed_login_count=$2"))).toHaveLength(5);
|
||||
});
|
||||
});
|
||||
@@ -30,8 +30,7 @@ describe("platform account ownership lifecycle", () => {
|
||||
vi.stubEnv("ZHINIAN_DATA_DIR", dataDirectory);
|
||||
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
|
||||
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", "test-platform-session-secret");
|
||||
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
|
||||
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
|
||||
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
|
||||
const organization = await createPlatformOrganization("归档组织");
|
||||
archiveOwnerId = organization.archiveOwnerId;
|
||||
user = await createPlatformUser({
|
||||
|
||||
@@ -23,8 +23,7 @@ describe("platform password auth route", () => {
|
||||
vi.stubEnv("ZHINIAN_AUTH_REQUIRED", "1");
|
||||
vi.stubEnv("ZHINIAN_AUTH_SESSION_SECRET", SESSION_SECRET);
|
||||
vi.stubEnv("ZHINIAN_AUTH_DISABLED", "");
|
||||
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
|
||||
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
|
||||
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
|
||||
resetLocalAuthRateLimitForTests();
|
||||
|
||||
const organization = await createPlatformOrganization("测试组织");
|
||||
|
||||
201
tests/billing-postgres-mapping.test.ts
Normal file
201
tests/billing-postgres-mapping.test.ts
Normal file
@@ -0,0 +1,201 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { queryDatabase } = vi.hoisted(() => ({ queryDatabase: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/server/database", () => ({
|
||||
getDataBackend: () => "postgres",
|
||||
isPostgresBackend: () => true,
|
||||
queryDatabase,
|
||||
withDatabaseTransaction: vi.fn()
|
||||
}));
|
||||
|
||||
import {
|
||||
BillingStoreError,
|
||||
createBillingPriceRule,
|
||||
listBillingPriceRules,
|
||||
postWalletEntry,
|
||||
updateBillingPriceRule
|
||||
} from "@/lib/server/billing-store";
|
||||
|
||||
describe("billing PostgreSQL mapping", () => {
|
||||
beforeEach(() => queryDatabase.mockReset());
|
||||
|
||||
it("maps pg bigint, numeric, and timestamptz values safely", async () => {
|
||||
queryDatabase.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: "price-1",
|
||||
provider: "bailian",
|
||||
capability: "image.generate",
|
||||
req_key: "image",
|
||||
variant_key: null,
|
||||
unit: "image",
|
||||
standard_unit_price_fen: "9007199254740991",
|
||||
markup_multiplier: "1.2500",
|
||||
enabled: true,
|
||||
conditions: {},
|
||||
quantity_source: "image_count",
|
||||
priority: 1,
|
||||
note: null,
|
||||
source: null,
|
||||
parameter_dimensions: [],
|
||||
created_at: new Date("2026-08-12T01:02:03.000Z"),
|
||||
updated_at: new Date("2026-08-12T04:05:06.000Z")
|
||||
}]
|
||||
});
|
||||
|
||||
const [rule] = await listBillingPriceRules();
|
||||
|
||||
expect(rule).toMatchObject({
|
||||
standardUnitPriceFen: Number.MAX_SAFE_INTEGER,
|
||||
markupMultiplier: 1.25,
|
||||
createdAt: "2026-08-12T01:02:03.000Z",
|
||||
updatedAt: "2026-08-12T04:05:06.000Z"
|
||||
});
|
||||
expect(queryDatabase.mock.calls[0][0]).toContain("$1::boolean");
|
||||
expect(queryDatabase.mock.calls[0][1]).toEqual([false]);
|
||||
});
|
||||
|
||||
it("rejects bigint values that cannot be represented without precision loss", async () => {
|
||||
queryDatabase.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: "price-unsafe",
|
||||
provider: "bailian",
|
||||
capability: "image.generate",
|
||||
unit: "image",
|
||||
standard_unit_price_fen: "9007199254740992",
|
||||
markup_multiplier: "1.1",
|
||||
enabled: true,
|
||||
conditions: {},
|
||||
priority: 0,
|
||||
parameter_dimensions: [],
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
}]
|
||||
});
|
||||
|
||||
await expect(listBillingPriceRules()).rejects.toMatchObject({ status: 500 } satisfies Partial<BillingStoreError>);
|
||||
});
|
||||
|
||||
it("canonicalizes nested condition objects and arrays before PostgreSQL inserts", async () => {
|
||||
queryDatabase.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: "price-canonical",
|
||||
provider: "bailian",
|
||||
capability: "image.generate",
|
||||
req_key: "image",
|
||||
variant_key: null,
|
||||
unit: "image",
|
||||
standard_unit_price_fen: "100",
|
||||
markup_multiplier: "1.2",
|
||||
enabled: true,
|
||||
conditions: { quality: { values: ["high", "standard"] } },
|
||||
quantity_source: "image_count",
|
||||
priority: 0,
|
||||
note: null,
|
||||
source: null,
|
||||
parameter_dimensions: [],
|
||||
created_at: new Date("2026-08-12T01:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-12T01:00:00.000Z")
|
||||
}]
|
||||
});
|
||||
|
||||
await createBillingPriceRule({
|
||||
id: "price-canonical",
|
||||
provider: "bailian",
|
||||
capability: "image.generate",
|
||||
reqKey: "image",
|
||||
unit: "image",
|
||||
standardUnitPriceFen: 100,
|
||||
markupMultiplier: 1.2,
|
||||
enabled: true,
|
||||
conditions: { quality: { values: ["standard", "high"] } },
|
||||
quantitySource: "image_count"
|
||||
});
|
||||
|
||||
expect(queryDatabase.mock.calls[0][1][9]).toBe(JSON.stringify({ quality: { values: ["high", "standard"] } }));
|
||||
});
|
||||
|
||||
it("maps PostgreSQL unique violations during rule updates to conflict", async () => {
|
||||
queryDatabase
|
||||
.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
id: "price-update",
|
||||
provider: "bailian",
|
||||
capability: "image.generate",
|
||||
req_key: "image",
|
||||
variant_key: null,
|
||||
unit: "image",
|
||||
standard_unit_price_fen: "100",
|
||||
markup_multiplier: "1.2",
|
||||
enabled: true,
|
||||
conditions: {},
|
||||
quantity_source: "image_count",
|
||||
priority: 0,
|
||||
note: null,
|
||||
source: null,
|
||||
parameter_dimensions: [],
|
||||
created_at: new Date("2026-08-12T01:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-12T01:00:00.000Z")
|
||||
}]
|
||||
})
|
||||
.mockRejectedValueOnce(Object.assign(new Error("duplicate key value violates unique constraint"), { code: "23505" }));
|
||||
|
||||
await expect(updateBillingPriceRule("price-update", {
|
||||
conditions: { quality: { values: ["standard", "high"] } }
|
||||
})).rejects.toMatchObject({ status: 409 } satisfies Partial<BillingStoreError>);
|
||||
|
||||
expect(queryDatabase.mock.calls[1][1][9]).toBe(JSON.stringify({ quality: { values: ["high", "standard"] } }));
|
||||
});
|
||||
|
||||
it("calls the atomic wallet function with positional parameters and maps its row", async () => {
|
||||
queryDatabase.mockResolvedValueOnce({
|
||||
rows: [{
|
||||
ledger_id: "ledger-1",
|
||||
balance_after_fen: "1250",
|
||||
balance_fen: "1250",
|
||||
total_recharged_fen: "1500",
|
||||
total_charged_fen: "250",
|
||||
delta_fen: "-250",
|
||||
created_at: new Date("2026-08-12T06:00:00.000Z"),
|
||||
updated_at: new Date("2026-08-12T06:00:00.000Z")
|
||||
}]
|
||||
});
|
||||
|
||||
const result = await postWalletEntry({
|
||||
organizationId: "org-1",
|
||||
accountId: "account-1",
|
||||
jobId: "job-1",
|
||||
kind: "charge",
|
||||
deltaFen: -250,
|
||||
idempotencyKey: "charge:job-1",
|
||||
description: "generation charge",
|
||||
metadata: { provider: "bailian" }
|
||||
});
|
||||
|
||||
expect(result.entry).toMatchObject({
|
||||
id: "ledger-1",
|
||||
deltaFen: -250,
|
||||
balanceAfterFen: 1250,
|
||||
createdAt: "2026-08-12T06:00:00.000Z"
|
||||
});
|
||||
expect(result.wallet).toMatchObject({ balanceFen: 1250, totalRechargedFen: 1500, totalChargedFen: 250 });
|
||||
expect(queryDatabase.mock.calls[0][0]).toContain("billing_post_wallet_entry");
|
||||
expect(queryDatabase.mock.calls[0][0]).toContain("$10::jsonb");
|
||||
expect(queryDatabase.mock.calls[0][1].slice(1)).toEqual([
|
||||
"org-1", "account-1", "job-1", "charge", -250, "CNY", "charge:job-1",
|
||||
"generation charge", JSON.stringify({ provider: "bailian" })
|
||||
]);
|
||||
});
|
||||
|
||||
it("maps idempotency payload drift to a conflict", async () => {
|
||||
queryDatabase.mockRejectedValueOnce(new Error("BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH"));
|
||||
|
||||
await expect(postWalletEntry({
|
||||
organizationId: "org-1",
|
||||
kind: "recharge",
|
||||
deltaFen: 100,
|
||||
idempotencyKey: "same-key",
|
||||
description: "request"
|
||||
})).rejects.toMatchObject({ status: 409 } satisfies Partial<BillingStoreError>);
|
||||
});
|
||||
});
|
||||
@@ -29,8 +29,7 @@ describe("organization billing", () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-billing-"));
|
||||
vi.stubEnv("ZHINIAN_RUNTIME_DIR", runtimeDir);
|
||||
vi.stubEnv("ZHINIAN_BILLING_REQUIRED", "1");
|
||||
vi.stubEnv("NEXT_PUBLIC_SUPABASE_URL", "");
|
||||
vi.stubEnv("SUPABASE_SERVICE_ROLE_KEY", "");
|
||||
vi.stubEnv("ZHINIAN_DATA_BACKEND", "local");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -433,7 +432,8 @@ describe("organization billing", () => {
|
||||
accountId: "user-1",
|
||||
jobId: job.id,
|
||||
idempotencyKey: `job-charge:${job.id}`,
|
||||
description: "重复扣费"
|
||||
description: "重试时更新的审计文案",
|
||||
metadata: { retry: true }
|
||||
})).entry.id).toBe(charged.billing?.ledgerEntryId);
|
||||
|
||||
const failed = await updateGenerationJob(charged.id, { status: "failed" });
|
||||
@@ -445,6 +445,41 @@ describe("organization billing", () => {
|
||||
expect(ledger[0].balanceAfterFen).toBe(1000);
|
||||
});
|
||||
|
||||
it("scopes local wallet idempotency by organization and rejects payload drift", async () => {
|
||||
const first = await postWalletEntry({
|
||||
organizationId: "org-1",
|
||||
kind: "recharge",
|
||||
deltaFen: 100,
|
||||
idempotencyKey: "shared-key",
|
||||
description: "same request"
|
||||
});
|
||||
const otherOrganization = await postWalletEntry({
|
||||
organizationId: "org-2",
|
||||
kind: "recharge",
|
||||
deltaFen: 100,
|
||||
idempotencyKey: "shared-key",
|
||||
description: "same request"
|
||||
});
|
||||
|
||||
expect(otherOrganization.entry.id).not.toBe(first.entry.id);
|
||||
const retried = await postWalletEntry({
|
||||
organizationId: "org-1",
|
||||
kind: "recharge",
|
||||
deltaFen: 100,
|
||||
idempotencyKey: "shared-key",
|
||||
description: "updated audit label",
|
||||
metadata: { retry: true }
|
||||
});
|
||||
expect(retried.entry.id).toBe(first.entry.id);
|
||||
await expect(postWalletEntry({
|
||||
organizationId: "org-1",
|
||||
kind: "recharge",
|
||||
deltaFen: 200,
|
||||
idempotencyKey: "shared-key",
|
||||
description: "same request"
|
||||
})).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("blocks an ordinary generation before provider dispatch when the organization balance is insufficient", async () => {
|
||||
await createBillingPriceRule({
|
||||
id: "rule-insufficient-image",
|
||||
|
||||
@@ -11,24 +11,20 @@ import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
|
||||
let runtimeDir = "";
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let previousSupabaseUrl: string | undefined;
|
||||
let previousSupabaseKey: string | undefined;
|
||||
let previousDataBackend: string | undefined;
|
||||
|
||||
describe("local data store concurrency", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-store-"));
|
||||
previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR;
|
||||
previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
previousDataBackend = process.env.ZHINIAN_DATA_BACKEND;
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
process.env.ZHINIAN_DATA_BACKEND = "local";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir);
|
||||
restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl);
|
||||
restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey);
|
||||
restoreEnv("ZHINIAN_DATA_BACKEND", previousDataBackend);
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
|
||||
25
tests/database-readiness-contract.test.ts
Normal file
25
tests/database-readiness-contract.test.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("PostgreSQL readiness contract", () => {
|
||||
it("checks every runtime table and both atomic functions", async () => {
|
||||
const source = await readFile(new URL("../lib/server/database.ts", import.meta.url), "utf8");
|
||||
for (const table of [
|
||||
"assets",
|
||||
"generation_jobs",
|
||||
"usage_events",
|
||||
"projects",
|
||||
"image_templates",
|
||||
"platform_organizations",
|
||||
"platform_users",
|
||||
"platform_account_migrations",
|
||||
"billing_price_rules",
|
||||
"billing_wallets",
|
||||
"billing_ledger"
|
||||
]) {
|
||||
expect(source).toContain(`('${table}',`);
|
||||
}
|
||||
expect(source).toContain("claim_generation_jobs(text,integer,integer)");
|
||||
expect(source).toContain("billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)");
|
||||
});
|
||||
});
|
||||
@@ -13,24 +13,20 @@ import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders"
|
||||
|
||||
let runtimeDir = "";
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let previousSupabaseUrl: string | undefined;
|
||||
let previousSupabaseKey: string | undefined;
|
||||
let previousDataBackend: string | undefined;
|
||||
|
||||
describe("image templates", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-templates-"));
|
||||
previousRuntimeDir = process.env.ZHINIAN_RUNTIME_DIR;
|
||||
previousSupabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
previousSupabaseKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
previousDataBackend = process.env.ZHINIAN_DATA_BACKEND;
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
process.env.ZHINIAN_DATA_BACKEND = "local";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir);
|
||||
restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl);
|
||||
restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey);
|
||||
restoreEnv("ZHINIAN_DATA_BACKEND", previousDataBackend);
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
|
||||
30
tests/postgres-client-config.test.ts
Normal file
30
tests/postgres-client-config.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
createPostgresPool,
|
||||
getScriptDataBackend,
|
||||
quotePostgresIdentifier
|
||||
} from "../scripts/postgres-client.mjs";
|
||||
|
||||
describe("PostgreSQL script configuration", () => {
|
||||
it("requires an explicit data backend", () => {
|
||||
expect(() => getScriptDataBackend({ NODE_ENV: "test" })).toThrow("ZHINIAN_DATA_BACKEND");
|
||||
expect(getScriptDataBackend({ NODE_ENV: "test", ZHINIAN_DATA_BACKEND: "postgres" })).toBe("postgres");
|
||||
});
|
||||
|
||||
it("rejects connection-string SSL options that could override the verified CA configuration", () => {
|
||||
expect(() => createPostgresPool({
|
||||
env: {
|
||||
NODE_ENV: "test",
|
||||
ZHINIAN_DATA_BACKEND: "postgres",
|
||||
DATABASE_URL: "postgresql://app:secret@rds.example:5432/app?sslmode=no-verify"
|
||||
}
|
||||
})).toThrow("must not contain SSL query parameters");
|
||||
});
|
||||
|
||||
it("quotes PostgreSQL role identifiers without allowing SQL syntax injection", () => {
|
||||
expect(quotePostgresIdentifier("zhinian_app")).toBe('"zhinian_app"');
|
||||
expect(() => quotePostgresIdentifier('app"role')).toThrow("identifier");
|
||||
expect(() => quotePostgresIdentifier("bad\0role")).toThrow("identifier");
|
||||
});
|
||||
});
|
||||
14
tests/postgres-privilege-contract.test.ts
Normal file
14
tests/postgres-privilege-contract.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("PostgreSQL application-role privilege contract", () => {
|
||||
it("uses explicit current-object grants and no blanket future-object grants", async () => {
|
||||
const source = await readFile(new URL("../scripts/migrate-postgres.mjs", import.meta.url), "utf8");
|
||||
expect(source).toContain('["billing_wallets", "SELECT, INSERT, UPDATE"]');
|
||||
expect(source).toContain('["billing_ledger", "SELECT, INSERT"]');
|
||||
expect(source).toContain("applicationRoleTablePrivileges().map");
|
||||
expect(source).toContain("REVOKE ALL ON TABLE");
|
||||
expect(source).not.toContain("ALTER DEFAULT PRIVILEGES");
|
||||
expect(source).not.toContain("ALL FUNCTIONS IN SCHEMA");
|
||||
});
|
||||
});
|
||||
14
tests/postgres-script-contract.test.ts
Normal file
14
tests/postgres-script-contract.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
describe("PostgreSQL account script concurrency contract", () => {
|
||||
it("serializes bootstrap and legacy imports that target the same logical account", async () => {
|
||||
const [bootstrap, legacyImport] = await Promise.all([
|
||||
readFile(new URL("../scripts/bootstrap-admin.mjs", import.meta.url), "utf8"),
|
||||
readFile(new URL("../scripts/import-legacy-accounts.mjs", import.meta.url), "utf8")
|
||||
]);
|
||||
expect(bootstrap).toContain("pg_advisory_xact_lock");
|
||||
expect(legacyImport).toContain("pg_advisory_xact_lock(hashtextextended($1, 0))");
|
||||
expect(legacyImport).toContain("session_version=platform_users.session_version+1");
|
||||
});
|
||||
});
|
||||
1
tests/server-only-stub.ts
Normal file
1
tests/server-only-stub.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {};
|
||||
@@ -20,8 +20,7 @@ let runtimeDir = "";
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
const envNames = [
|
||||
"ZHINIAN_RUNTIME_DIR",
|
||||
"NEXT_PUBLIC_SUPABASE_URL",
|
||||
"SUPABASE_SERVICE_ROLE_KEY",
|
||||
"ZHINIAN_DATA_BACKEND",
|
||||
"ZHINIAN_API_KEYS",
|
||||
"JIMENG_VISUAL_MOCK",
|
||||
"IMAGE_GENERATE_ENGINE",
|
||||
@@ -42,13 +41,12 @@ describe("task management and public API helpers", () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-tasks-"));
|
||||
for (const name of envNames) previousEnv.set(name, process.env[name]);
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
process.env.ZHINIAN_DATA_BACKEND = "local";
|
||||
process.env.ZHINIAN_API_KEYS = "agent-a:secret-a,agent-b:secret-b";
|
||||
process.env.JIMENG_VISUAL_MOCK = "true";
|
||||
delete process.env.IMAGE_GENERATE_ENGINE;
|
||||
delete process.env.EVOLINK_MOCK;
|
||||
delete process.env.EVOLINK_API_KEY;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
delete process.env.VOLCENGINE_ACCESS_KEY_ID;
|
||||
delete process.env.VOLCENGINE_SECRET_ACCESS_KEY;
|
||||
delete process.env.ALI_OSS_ENDPOINT;
|
||||
|
||||
@@ -15,15 +15,14 @@ import type { GenerationJob, UsageContext } from "@/lib/types";
|
||||
|
||||
let runtimeDir = "";
|
||||
const previousEnv = new Map<string, string | undefined>();
|
||||
const envNames = ["ZHINIAN_RUNTIME_DIR", "NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_SERVICE_ROLE_KEY"];
|
||||
const envNames = ["ZHINIAN_RUNTIME_DIR", "ZHINIAN_DATA_BACKEND"];
|
||||
|
||||
describe("usage metering and reports", () => {
|
||||
beforeEach(async () => {
|
||||
runtimeDir = await mkdtemp(join(tmpdir(), "zhinian-usage-"));
|
||||
for (const name of envNames) previousEnv.set(name, process.env[name]);
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
process.env.ZHINIAN_DATA_BACKEND = "local";
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
|
||||
58
tests/wallet-sql-contract.test.ts
Normal file
58
tests/wallet-sql-contract.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const schemaPaths = [
|
||||
"../database/migrations/0001_initial_schema.sql",
|
||||
"../supabase/schema.sql"
|
||||
];
|
||||
|
||||
function readSchema(relativePath: string): string {
|
||||
return readFileSync(fileURLToPath(new URL(relativePath, import.meta.url)), "utf8").replace(/\r\n/g, "\n");
|
||||
}
|
||||
|
||||
describe.each(schemaPaths)("wallet SQL contract: %s", (schemaPath) => {
|
||||
const sql = readSchema(schemaPath);
|
||||
|
||||
it("scopes wallet idempotency to an organization", () => {
|
||||
expect(sql).not.toMatch(/idempotency_key\s+text\s+not\s+null\s+unique/i);
|
||||
expect(sql).toMatch(/create unique index if not exists billing_ledger_organization_idempotency_idx\s+on billing_ledger\s*\(organization_id, idempotency_key\)/i);
|
||||
expect(sql).toMatch(/drop constraint %I/);
|
||||
expect(sql).toMatch(/drop index %I\.%I/);
|
||||
expect(sql).toMatch(/hashtextextended\(jsonb_build_array\(p_organization_id, p_idempotency_key\)::text, 0\)/i);
|
||||
expect(sql).toMatch(/from billing_ledger\s+where organization_id = p_organization_id\s+and idempotency_key = p_idempotency_key/i);
|
||||
});
|
||||
|
||||
it("rejects reuse of an idempotency key with a different payload", () => {
|
||||
for (const comparison of [
|
||||
"v_existing.account_id is distinct from v_account_id",
|
||||
"v_existing.job_id is distinct from p_job_id",
|
||||
"v_existing.kind is distinct from p_kind",
|
||||
"v_existing.delta_fen is distinct from p_delta_fen",
|
||||
"v_existing.currency is distinct from p_currency"
|
||||
]) {
|
||||
expect(sql.toLowerCase()).toContain(comparison);
|
||||
}
|
||||
expect(sql.toLowerCase()).not.toContain("v_existing.description is distinct from p_description");
|
||||
expect(sql.toLowerCase()).not.toContain("v_existing.metadata is distinct from coalesce(p_metadata");
|
||||
expect(sql).toMatch(/errcode = 'P0001'[\s\S]*message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH'/);
|
||||
});
|
||||
|
||||
it("fails safely instead of deleting duplicate usage events", () => {
|
||||
expect(sql).not.toMatch(/delete\s+from\s+usage_events/i);
|
||||
expect(sql).toMatch(/group by job_id\s+having count\(\*\) > 1/i);
|
||||
expect(sql).toMatch(/errcode = '23505'[\s\S]*USAGE_EVENTS_DUPLICATE_JOB_ID/);
|
||||
expect(sql.indexOf("USAGE_EVENTS_DUPLICATE_JOB_ID")).toBeLessThan(sql.indexOf("create unique index if not exists usage_events_job_id_idx"));
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps the migration and Supabase compatibility snapshot synchronized", () => {
|
||||
const migration = readSchema(schemaPaths[0]);
|
||||
const snapshot = readSchema(schemaPaths[1]).replace(
|
||||
/^-- Compatibility snapshot for existing Supabase deployments\.\r?\n-- New PostgreSQL\/RDS deployments must use `npm run db:migrate`; do not use this\r?\n-- file as an unversioned migration source\.\r?\n\r?\n/,
|
||||
""
|
||||
);
|
||||
|
||||
expect(snapshot).toBe(migration);
|
||||
});
|
||||
@@ -21,4 +21,11 @@ describe("worker script configuration", () => {
|
||||
stderr: expect.stringContaining("ZHINIAN_INTERNAL_WORKER_TOKEN is required")
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds each internal tick request with AbortSignal.timeout", async () => {
|
||||
const source = await import("node:fs/promises").then(({ readFile }) =>
|
||||
readFile(new URL("../scripts/worker.mjs", import.meta.url), "utf8")
|
||||
);
|
||||
expect(source).toContain("AbortSignal.timeout(requestTimeoutMs)");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user