feat: add admin accounts and image templates
This commit is contained in:
46
tests/auth-permissions.test.ts
Normal file
46
tests/auth-permissions.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { configuredAdminAuthorities, configuredAdminUsers, hasAdminAccess } from "@/lib/auth/permissions";
|
||||
import type { AuthUser } from "@/lib/auth/session";
|
||||
|
||||
const baseUser: AuthUser = {
|
||||
id: "auth:app:1",
|
||||
subject: "user-1",
|
||||
username: "user-1",
|
||||
displayName: "用户1",
|
||||
clientId: "app",
|
||||
authorities: [],
|
||||
scope: ["server"]
|
||||
};
|
||||
|
||||
describe("auth permission helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("treats ordinary users as non-admin", () => {
|
||||
expect(hasAdminAccess({ ...baseUser, authorities: ["ROLE_USER"] })).toBe(false);
|
||||
});
|
||||
|
||||
it("treats ceshiop as the default administrator account", () => {
|
||||
expect(configuredAdminUsers()).toEqual(["ceshiop"]);
|
||||
expect(hasAdminAccess({ ...baseUser, username: "ceshiop", subject: "ceshiop", authorities: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("allows configured admin usernames to override the default account", () => {
|
||||
vi.stubEnv("ZHINIAN_ADMIN_USERS", "ops-admin");
|
||||
expect(configuredAdminUsers()).toEqual(["ops-admin"]);
|
||||
expect(hasAdminAccess({ ...baseUser, username: "ceshiop", subject: "ceshiop", authorities: [] })).toBe(false);
|
||||
expect(hasAdminAccess({ ...baseUser, username: "ops-admin", subject: "ops-admin", authorities: [] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts configured admin authorities and normalizes case", () => {
|
||||
vi.stubEnv("ZHINIAN_ADMIN_AUTHORITIES", "custom-admin, sys_config_view");
|
||||
expect(configuredAdminAuthorities()).toEqual(["custom-admin", "sys_config_view"]);
|
||||
expect(hasAdminAccess({ ...baseUser, authorities: ["CUSTOM_ADMIN"] })).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts known system admin permission prefixes", () => {
|
||||
expect(hasAdminAccess({ ...baseUser, authorities: ["sys_user_view"] })).toBe(true);
|
||||
expect(hasAdminAccess({ ...baseUser, authorities: ["admin:accounts"] })).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,13 @@
|
||||
import { createSign, generateKeyPairSync, type KeyObject } from "node:crypto";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSessionCookieValue, parseSessionCookieValue, type AuthSession } from "@/lib/auth/session";
|
||||
import {
|
||||
chunkCookieValue,
|
||||
chunkedCookieName,
|
||||
createSessionCookieValue,
|
||||
parseSessionCookieValue,
|
||||
readChunkedCookieValue,
|
||||
type AuthSession
|
||||
} from "@/lib/auth/session";
|
||||
import { type AuthRuntimeConfig } from "@/lib/auth/config";
|
||||
import { clearJwksCacheForTests, userFromClaims, verifyAuthJwt } from "@/lib/server/auth/jwt";
|
||||
|
||||
@@ -38,6 +45,8 @@ describe("SSO auth helpers", () => {
|
||||
version: 1,
|
||||
issuedAt: 100,
|
||||
expiresAt: 200,
|
||||
accessToken: "access-token-1",
|
||||
tokenType: "bearer",
|
||||
user: {
|
||||
id: "auth:customPC:1",
|
||||
subject: "zhangsan",
|
||||
@@ -51,12 +60,44 @@ describe("SSO auth helpers", () => {
|
||||
|
||||
const cookie = await createSessionCookieValue(session, authConfig.sessionSecret || "");
|
||||
expect(await parseSessionCookieValue(cookie, authConfig.sessionSecret || "", 150)).toMatchObject({
|
||||
accessToken: "access-token-1",
|
||||
tokenType: "bearer",
|
||||
user: { id: "auth:customPC:1", displayName: "张三" }
|
||||
});
|
||||
expect(await parseSessionCookieValue(`${cookie.slice(0, -1)}x`, authConfig.sessionSecret || "", 150)).toBeNull();
|
||||
expect(await parseSessionCookieValue(cookie, authConfig.sessionSecret || "", 201)).toBeNull();
|
||||
});
|
||||
|
||||
it("reassembles chunked session cookies for large auth payloads", async () => {
|
||||
const session: AuthSession = {
|
||||
version: 1,
|
||||
issuedAt: 100,
|
||||
expiresAt: 200,
|
||||
accessToken: "token.".repeat(1200),
|
||||
tokenType: "bearer",
|
||||
user: {
|
||||
id: "auth:customPC:big-user",
|
||||
subject: "big-user",
|
||||
username: "big-user",
|
||||
displayName: "大权限账号",
|
||||
clientId: "customPC",
|
||||
authorities: Array.from({ length: 200 }, (_, index) => `sys_permission_${index}`),
|
||||
scope: ["server"]
|
||||
}
|
||||
};
|
||||
const cookie = await createSessionCookieValue(session, authConfig.sessionSecret || "");
|
||||
const chunks = chunkCookieValue(cookie, 1000);
|
||||
const chunkMap = new Map(chunks.map((chunk, index) => [chunkedCookieName("zhinian_session", index), chunk]));
|
||||
const reassembled = readChunkedCookieValue("zhinian_session", (name) => chunkMap.get(name));
|
||||
|
||||
expect(chunks.length).toBeGreaterThan(1);
|
||||
expect(reassembled).toBe(cookie);
|
||||
expect(await parseSessionCookieValue(reassembled, authConfig.sessionSecret || "", 150)).toMatchObject({
|
||||
accessToken: session.accessToken,
|
||||
user: { username: "big-user" }
|
||||
});
|
||||
});
|
||||
|
||||
it("verifies RS256 JWTs from JWKS and maps stable owner ids", async () => {
|
||||
const { publicKey, privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||
const jwk = publicKey.export({ format: "jwk" }) as TestJwk;
|
||||
|
||||
122
tests/image-templates.test.ts
Normal file
122
tests/image-templates.test.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
createImageTemplate,
|
||||
deleteImageTemplate,
|
||||
listImageTemplates,
|
||||
updateImageTemplate
|
||||
} from "@/lib/server/data-store";
|
||||
import { normalizeImageTemplateCreate } from "@/lib/server/image-template-input";
|
||||
import { extractMaterialPlaceholders } from "@/lib/prompt/material-placeholders";
|
||||
|
||||
let runtimeDir = "";
|
||||
let previousRuntimeDir: string | undefined;
|
||||
let previousSupabaseUrl: string | undefined;
|
||||
let previousSupabaseKey: 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;
|
||||
process.env.ZHINIAN_RUNTIME_DIR = runtimeDir;
|
||||
delete process.env.NEXT_PUBLIC_SUPABASE_URL;
|
||||
delete process.env.SUPABASE_SERVICE_ROLE_KEY;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
restoreEnv("ZHINIAN_RUNTIME_DIR", previousRuntimeDir);
|
||||
restoreEnv("NEXT_PUBLIC_SUPABASE_URL", previousSupabaseUrl);
|
||||
restoreEnv("SUPABASE_SERVICE_ROLE_KEY", previousSupabaseKey);
|
||||
await rm(runtimeDir, { force: true, recursive: true });
|
||||
});
|
||||
|
||||
it("stores templates inside the account owner boundary", async () => {
|
||||
const first = await createImageTemplate({
|
||||
ownerId: "auth:app:user-a",
|
||||
name: "商品主图",
|
||||
description: "适合电商主视觉",
|
||||
prompt: "生成干净高级的商品主图",
|
||||
previewImageUrl: "/mock/preview-a.png",
|
||||
settings: { width: 2048, height: 2048, forceSingle: true },
|
||||
sortOrder: 2
|
||||
});
|
||||
await createImageTemplate({
|
||||
ownerId: "auth:app:user-b",
|
||||
name: "社媒海报",
|
||||
prompt: "生成适合社媒传播的海报",
|
||||
settings: {},
|
||||
sortOrder: 1
|
||||
});
|
||||
|
||||
const owned = await listImageTemplates("auth:app:user-a");
|
||||
expect(owned).toHaveLength(1);
|
||||
expect(owned[0]).toMatchObject({
|
||||
id: first.id,
|
||||
ownerId: "auth:app:user-a",
|
||||
description: "适合电商主视觉",
|
||||
prompt: "生成干净高级的商品主图",
|
||||
settings: { width: 2048, height: 2048, forceSingle: true }
|
||||
});
|
||||
expect(await listImageTemplates("auth:app:user-b")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("does not update or delete another account's template", async () => {
|
||||
const template = await createImageTemplate({
|
||||
ownerId: "auth:app:user-a",
|
||||
name: "A",
|
||||
prompt: "提示词 A",
|
||||
settings: {},
|
||||
sortOrder: 0
|
||||
});
|
||||
|
||||
expect(await updateImageTemplate(template.id, "auth:app:user-b", { name: "B" })).toBeNull();
|
||||
expect(await deleteImageTemplate(template.id, "auth:app:user-b")).toBeNull();
|
||||
expect((await listImageTemplates("auth:app:user-a"))[0].name).toBe("A");
|
||||
|
||||
const updated = await updateImageTemplate(template.id, "auth:app:user-a", { name: "A+", sortOrder: 3 });
|
||||
expect(updated).toMatchObject({ name: "A+", sortOrder: 3 });
|
||||
expect(await deleteImageTemplate(template.id, "auth:app:user-a")).toMatchObject({ id: template.id });
|
||||
expect(await listImageTemplates("auth:app:user-a")).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("normalizes template creation input", () => {
|
||||
expect(normalizeImageTemplateCreate({
|
||||
name: " 品牌氛围图 ",
|
||||
description: " 品牌视觉模板 ",
|
||||
prompt: " 生成品牌氛围视觉 ",
|
||||
previewImageUrl: "https://example.com/preview.png",
|
||||
settings: { engine: "evolink", width: "1440", height: "2560", forceSingle: false, quality: "high", scale: "999" },
|
||||
sortOrder: "5"
|
||||
})).toMatchObject({
|
||||
name: "品牌氛围图",
|
||||
description: "品牌视觉模板",
|
||||
prompt: "生成品牌氛围视觉",
|
||||
previewImageUrl: "https://example.com/preview.png",
|
||||
settings: { engine: "evolink", width: 1440, height: 2560, forceSingle: false, quality: "high", scale: 100 },
|
||||
sortOrder: 5
|
||||
});
|
||||
expect(() => normalizeImageTemplateCreate({ name: "", prompt: "x" })).toThrow("模板名称不能为空");
|
||||
expect(() => normalizeImageTemplateCreate({ name: "x", prompt: "x", previewImageUrl: "javascript:alert(1)" })).toThrow("效果预览图地址");
|
||||
});
|
||||
|
||||
it("extracts prompt material placeholders for template upload slots", () => {
|
||||
expect(extractMaterialPlaceholders("以 @图片1 为主体,参考 @图2 的色调,再参考 @视频1 的运动感。")).toEqual([
|
||||
{ token: "@图片1", type: "image", index: 1 },
|
||||
{ token: "@图片2", type: "image", index: 2 },
|
||||
{ token: "@视频1", type: "video", index: 1 }
|
||||
]);
|
||||
expect(extractMaterialPlaceholders("以 @图片 为主体,@图片这种普通文字不应变成占位。")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, value: string | undefined) {
|
||||
if (value === undefined) {
|
||||
delete process.env[name];
|
||||
return;
|
||||
}
|
||||
process.env[name] = value;
|
||||
}
|
||||
39
tests/material-draft.test.ts
Normal file
39
tests/material-draft.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
detectMaterialDraftStart,
|
||||
insertMaterialDraftToken,
|
||||
nextMaterialDraftToken,
|
||||
normalizeMaterialDraftToken
|
||||
} from "@/lib/prompt/material-draft";
|
||||
|
||||
describe("material draft input", () => {
|
||||
it("starts a temporary draft only for a single typed @", () => {
|
||||
expect(detectMaterialDraftStart("以商品为主体", "以@商品为主体", 2)).toEqual({
|
||||
text: "以商品为主体",
|
||||
index: 1
|
||||
});
|
||||
expect(detectMaterialDraftStart("以商品为主体", "以@@商品为主体", 3)).toBeNull();
|
||||
expect(detectMaterialDraftStart("以商品为主体", "以@图片1商品为主体", 5)).toBeNull();
|
||||
});
|
||||
|
||||
it("normalizes confirmed material placeholder names", () => {
|
||||
const prompt = "参考 @图片1 和 @视频1";
|
||||
expect(normalizeMaterialDraftToken("图片", prompt)).toBe("@图片2");
|
||||
expect(normalizeMaterialDraftToken("图3", prompt)).toBe("@图片3");
|
||||
expect(normalizeMaterialDraftToken("视频", prompt)).toBe("@视频2");
|
||||
expect(normalizeMaterialDraftToken("音频1", prompt)).toBe("@音频1");
|
||||
expect(normalizeMaterialDraftToken("图片这种普通文字", prompt)).toBeNull();
|
||||
});
|
||||
|
||||
it("inserts confirmed tokens with text boundaries", () => {
|
||||
expect(insertMaterialDraftToken("以主体生成", 1, "@图片1")).toEqual({
|
||||
text: "以 @图片1 主体生成",
|
||||
cursor: 7
|
||||
});
|
||||
expect(insertMaterialDraftToken("以主体生成", 5, "@视频1")).toEqual({
|
||||
text: "以主体生成 @视频1 ",
|
||||
cursor: 11
|
||||
});
|
||||
expect(nextMaterialDraftToken("image", "@图片1 @图片2")).toBe("@图片3");
|
||||
});
|
||||
});
|
||||
258
tests/organization-client.test.ts
Normal file
258
tests/organization-client.test.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addOrganizationMemberAndCreatePlatformUser,
|
||||
createOrganizationGroup,
|
||||
getOrganizationApiConfig,
|
||||
isOrganizationPermissionDenied,
|
||||
isOrganizationRouteNotFound,
|
||||
listOrganizationMembers,
|
||||
resetPlatformUserPassword
|
||||
} from "@/lib/server/organization-client";
|
||||
|
||||
describe("organization account client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("tracks required organization and staff API configuration separately", () => {
|
||||
vi.stubEnv("ZHINIAN_ORG_API_BASE_URL", "https://gateway.example.com/basic");
|
||||
vi.stubEnv("ZHINIAN_ORG_API_TOKEN", "org-token");
|
||||
|
||||
expect(getOrganizationApiConfig()).toMatchObject({
|
||||
configured: true,
|
||||
staffConfigured: false,
|
||||
staffMissing: ["ZHINIAN_STAFF_API_BASE_URL", "ZHINIAN_ORG_TENANT_ID"]
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the current login token instead of a static organization token", () => {
|
||||
vi.stubEnv("ZHINIAN_ORG_API_BASE_URL", "https://gateway.example.com/basic");
|
||||
vi.stubEnv("ZHINIAN_STAFF_API_BASE_URL", "https://gateway.example.com/staff");
|
||||
vi.stubEnv("ZHINIAN_ORG_TENANT_ID", "1");
|
||||
|
||||
expect(getOrganizationApiConfig("login-token")).toMatchObject({
|
||||
organizationToken: "login-token",
|
||||
staffToken: "login-token",
|
||||
configured: true,
|
||||
staffConfigured: true,
|
||||
missing: [],
|
||||
staffMissing: []
|
||||
});
|
||||
});
|
||||
|
||||
it("calls member list through the hotelStaff admin organization proxy by default", async () => {
|
||||
const seen: Array<{ input: string; init?: RequestInit }> = [];
|
||||
stubOrganizationEnv();
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ input: String(input), init });
|
||||
return jsonResponse({
|
||||
code: 0,
|
||||
data: { records: [], total: 0, size: 10, current: 1, pages: 0 }
|
||||
});
|
||||
});
|
||||
|
||||
await listOrganizationMembers({ organizationId: "org_1", pageNum: 1, pageSize: 10 });
|
||||
|
||||
expect(seen[0].input).toBe("https://gateway.example.com/basic/adminOrganization/organizationMember/organizationMemberList");
|
||||
expect(seen[0].init?.method).toBe("POST");
|
||||
expect(seen[0].init?.headers).toMatchObject({
|
||||
Authorization: "Bearer org-token",
|
||||
tenantId: "1"
|
||||
});
|
||||
expect(seen[0].init?.headers).not.toMatchObject({ from: "Y" });
|
||||
});
|
||||
|
||||
it("prefers the current login token when forwarding organization requests", async () => {
|
||||
const seen: Array<{ input: string; init?: RequestInit }> = [];
|
||||
stubOrganizationEnv();
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ input: String(input), init });
|
||||
return jsonResponse({
|
||||
code: 0,
|
||||
data: { records: [], total: 0, size: 10, current: 1, pages: 0 }
|
||||
});
|
||||
});
|
||||
|
||||
await listOrganizationMembers(
|
||||
{ organizationId: "org_1", pageNum: 1, pageSize: 10 },
|
||||
{ accessToken: "login-token" }
|
||||
);
|
||||
|
||||
expect(seen[0].init?.headers).toMatchObject({
|
||||
Authorization: "Bearer login-token",
|
||||
tenantId: "1"
|
||||
});
|
||||
});
|
||||
|
||||
it("creates organization groups through the documented group endpoint", async () => {
|
||||
const seen: Array<{ input: string; init?: RequestInit }> = [];
|
||||
stubOrganizationEnv();
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ input: String(input), init });
|
||||
return jsonResponse({ code: 0, data: true });
|
||||
});
|
||||
|
||||
await createOrganizationGroup({
|
||||
organizationId: "org_1",
|
||||
groupName: "客房部",
|
||||
groupDesc: "处理客房相关工单"
|
||||
}, { accessToken: "login-token" });
|
||||
|
||||
expect(seen[0].input).toBe("https://gateway.example.com/basic/organizationGroup/createOrganizationGroup");
|
||||
expect(seen[0].init?.headers).toMatchObject({
|
||||
Authorization: "Bearer login-token",
|
||||
tenantId: "1"
|
||||
});
|
||||
expect(JSON.parse(String(seen[0].init?.body))).toMatchObject({
|
||||
organizationId: "org_1",
|
||||
groupName: "客房部",
|
||||
groupDesc: "处理客房相关工单"
|
||||
});
|
||||
});
|
||||
|
||||
it("allows overriding the member list path exposed by the gateway", async () => {
|
||||
const seen: Array<{ input: string; init?: RequestInit }> = [];
|
||||
stubOrganizationEnv();
|
||||
vi.stubEnv("ZHINIAN_ORG_MEMBER_LIST_PATH", "/adminOrganization/organizationMember/list");
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ input: String(input), init });
|
||||
return jsonResponse({
|
||||
code: 0,
|
||||
data: { records: [], total: 0, size: 10, current: 1, pages: 0 }
|
||||
});
|
||||
});
|
||||
|
||||
await listOrganizationMembers({ organizationId: "org_1", pageNum: 1, pageSize: 10 });
|
||||
|
||||
expect(seen[0].input).toBe("https://gateway.example.com/basic/adminOrganization/organizationMember/list");
|
||||
expect(seen[0].init?.headers).not.toMatchObject({ from: "Y" });
|
||||
});
|
||||
|
||||
it("keeps the inner marker when the member list path points directly at the basic service", async () => {
|
||||
const seen: Array<{ input: string; init?: RequestInit }> = [];
|
||||
stubOrganizationEnv();
|
||||
vi.stubEnv("ZHINIAN_ORG_MEMBER_LIST_PATH", "/organizationMember/organizationMemberList");
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ input: String(input), init });
|
||||
return jsonResponse({
|
||||
code: 0,
|
||||
data: { records: [], total: 0, size: 10, current: 1, pages: 0 }
|
||||
});
|
||||
});
|
||||
|
||||
await listOrganizationMembers({ organizationId: "org_1", pageNum: 1, pageSize: 10 });
|
||||
|
||||
expect(seen[0].input).toBe("https://gateway.example.com/basic/organizationMember/organizationMemberList");
|
||||
expect(seen[0].init?.headers).toMatchObject({ from: "Y" });
|
||||
});
|
||||
|
||||
it("marks static resource errors as missing organization routes", async () => {
|
||||
stubOrganizationEnv();
|
||||
vi.stubGlobal("fetch", async () => new Response(JSON.stringify({
|
||||
code: 1,
|
||||
msg: "No static resource organizationMember/organizationMemberList."
|
||||
}), {
|
||||
status: 404,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
}));
|
||||
|
||||
try {
|
||||
await listOrganizationMembers({ organizationId: "org_1", pageNum: 1, pageSize: 10 });
|
||||
throw new Error("expected organization request to fail");
|
||||
} catch (error) {
|
||||
expect(isOrganizationRouteNotFound(error)).toBe(true);
|
||||
expect(error).toMatchObject({
|
||||
status: 404,
|
||||
message: expect.stringContaining("ZHINIAN_ORG_MEMBER_LIST_PATH")
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("marks upstream administrator-role errors as organization permission denials", async () => {
|
||||
stubOrganizationEnv();
|
||||
vi.stubGlobal("fetch", async () => jsonResponse({
|
||||
code: 1,
|
||||
msg: "仅管理员角色允许调用"
|
||||
}));
|
||||
|
||||
try {
|
||||
await listOrganizationMembers({ organizationId: "org_1", pageNum: 1, pageSize: 10 });
|
||||
throw new Error("expected organization request to fail");
|
||||
} catch (error) {
|
||||
expect(isOrganizationPermissionDenied(error)).toBe(true);
|
||||
expect(isOrganizationRouteNotFound(error)).toBe(false);
|
||||
expect(error).toMatchObject({
|
||||
status: 502,
|
||||
message: "仅管理员角色允许调用"
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses staff service endpoints for user creation and password reset", async () => {
|
||||
const seen: Array<{ input: string; init?: RequestInit }> = [];
|
||||
stubOrganizationEnv();
|
||||
vi.stubGlobal("fetch", async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
seen.push({ input: String(input), init });
|
||||
if (String(input).endsWith("/resetPlatformUserPassword")) return jsonResponse({ code: 0, data: true });
|
||||
return jsonResponse({
|
||||
code: 0,
|
||||
data: {
|
||||
userId: "10001",
|
||||
username: "zhangsan",
|
||||
phone: "13800000000",
|
||||
tenantId: 1,
|
||||
initialPassword: "Nianxx@123456"
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const user = await addOrganizationMemberAndCreatePlatformUser({
|
||||
tenantId: 1,
|
||||
username: "zhangsan",
|
||||
phone: "13800000000",
|
||||
name: "张三",
|
||||
memberName: "张三",
|
||||
memberPhone: "13800000000",
|
||||
roleId: "role_1",
|
||||
organizationId: "org_1",
|
||||
groupId: "group_1",
|
||||
mustChangePassword: true
|
||||
});
|
||||
await resetPlatformUserPassword({
|
||||
tenantId: 1,
|
||||
userId: 10001,
|
||||
newPassword: "Nianxx@654321",
|
||||
mustChangePassword: true
|
||||
});
|
||||
|
||||
expect(user.initialPassword).toBe("Nianxx@123456");
|
||||
expect(seen[0].input).toBe("https://gateway.example.com/staff/adminOrganization/organizationMember/addOrganizationMemberAndCreatePlatformUser");
|
||||
expect(seen[1].input).toBe("https://gateway.example.com/staff/adminPcUser/resetPlatformUserPassword");
|
||||
expect(seen[0].init?.headers).toMatchObject({
|
||||
Authorization: "Bearer staff-token",
|
||||
tenantId: "1"
|
||||
});
|
||||
expect(JSON.parse(String(seen[1].init?.body))).toMatchObject({
|
||||
tenantId: 1,
|
||||
userId: 10001,
|
||||
newPassword: "Nianxx@654321",
|
||||
mustChangePassword: true
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function stubOrganizationEnv() {
|
||||
vi.stubEnv("ZHINIAN_ORG_API_BASE_URL", "https://gateway.example.com/basic");
|
||||
vi.stubEnv("ZHINIAN_ORG_API_TOKEN", "org-token");
|
||||
vi.stubEnv("ZHINIAN_STAFF_API_BASE_URL", "https://gateway.example.com/staff");
|
||||
vi.stubEnv("ZHINIAN_STAFF_API_TOKEN", "staff-token");
|
||||
vi.stubEnv("ZHINIAN_ORG_TENANT_ID", "1");
|
||||
}
|
||||
|
||||
function jsonResponse(value: unknown) {
|
||||
return new Response(JSON.stringify(value), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" }
|
||||
});
|
||||
}
|
||||
@@ -4,11 +4,14 @@ import { join } from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
authenticatePublicApiRequest,
|
||||
publicApiOwnerId,
|
||||
PublicApiAuthError,
|
||||
type PublicApiClient
|
||||
} from "@/lib/server/public-api-auth";
|
||||
import { createPublicGenerationJob, PublicApiConflictError } from "@/lib/server/public-api-jobs";
|
||||
import { claimGenerationJobs, createGenerationJob, getGenerationJob, listGenerationJobs } from "@/lib/server/data-store";
|
||||
import { getPublicApiAsset, listPublicApiAssets } from "@/lib/server/public-api-assets";
|
||||
import { submitImageJob } from "@/lib/server/generation-service";
|
||||
import { runWorkerTick } from "@/lib/server/task-manager";
|
||||
import { DEFAULT_OWNER_ID } from "@/lib/server/runtime";
|
||||
import { signWebhookBody } from "@/lib/server/webhook";
|
||||
@@ -97,6 +100,35 @@ describe("task management and public API helpers", () => {
|
||||
})).rejects.toBeInstanceOf(PublicApiConflictError);
|
||||
});
|
||||
|
||||
it("partitions public jobs by API account id", async () => {
|
||||
const request = new Request("http://local.test/api/v1/jobs", {
|
||||
headers: { "Idempotency-Key": "same-account-local-key" }
|
||||
});
|
||||
const body = {
|
||||
capability: "image.generate" as const,
|
||||
prompt: "生成一张账号隔离测试图"
|
||||
};
|
||||
const first = await createPublicGenerationJob({
|
||||
client: { id: "agent-a", key: "secret-a" },
|
||||
request,
|
||||
origin: "http://local.test",
|
||||
body
|
||||
});
|
||||
const second = await createPublicGenerationJob({
|
||||
client: { id: "agent-b", key: "secret-b" },
|
||||
request,
|
||||
origin: "http://local.test",
|
||||
body
|
||||
});
|
||||
|
||||
expect(first.job.ownerId).toBe(publicApiOwnerId("agent-a"));
|
||||
expect(second.job.ownerId).toBe(publicApiOwnerId("agent-b"));
|
||||
expect(first.job.id).not.toBe(second.job.id);
|
||||
expect(await listGenerationJobs(publicApiOwnerId("agent-a"), 10)).toHaveLength(1);
|
||||
expect(await listGenerationJobs(publicApiOwnerId("agent-b"), 10)).toHaveLength(1);
|
||||
expect(await listGenerationJobs(DEFAULT_OWNER_ID, 10)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("passes EvoLink quality through public image jobs", async () => {
|
||||
process.env.IMAGE_GENERATE_ENGINE = "evolink";
|
||||
process.env.EVOLINK_MOCK = "true";
|
||||
@@ -118,6 +150,38 @@ describe("task management and public API helpers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("allows image jobs to override the default generation engine", async () => {
|
||||
process.env.IMAGE_GENERATE_ENGINE = "evolink";
|
||||
process.env.EVOLINK_MOCK = "true";
|
||||
process.env.EVOLINK_API_KEY = "test-key";
|
||||
|
||||
const jimengJob = await submitImageJob({
|
||||
ownerId: DEFAULT_OWNER_ID,
|
||||
capability: "image.generate",
|
||||
engine: "jimeng",
|
||||
prompt: "即梦模板",
|
||||
scale: 70
|
||||
}, "http://local.test");
|
||||
expect(jimengJob.requestPayload.engine).toBe("jimeng");
|
||||
expect(jimengJob.requestPayload.providerPayload).toMatchObject({
|
||||
req_key: "jimeng_seedream46_cvtob",
|
||||
scale: 70
|
||||
});
|
||||
|
||||
const image2Job = await submitImageJob({
|
||||
ownerId: DEFAULT_OWNER_ID,
|
||||
capability: "image.generate",
|
||||
engine: "evolink",
|
||||
prompt: "Image2 模板",
|
||||
quality: "high"
|
||||
}, "http://local.test");
|
||||
expect(image2Job.requestPayload.engine).toBe("evolink");
|
||||
expect(image2Job.requestPayload.providerPayload).toMatchObject({
|
||||
model: "gpt-image-2",
|
||||
quality: "high"
|
||||
});
|
||||
});
|
||||
|
||||
it("claims local jobs without duplicate ownership", async () => {
|
||||
await Promise.all(Array.from({ length: 6 }, (_, index) => createGenerationJob({
|
||||
ownerId: DEFAULT_OWNER_ID,
|
||||
@@ -163,7 +227,12 @@ describe("task management and public API helpers", () => {
|
||||
expect(stored?.status).toBe("succeeded");
|
||||
expect(stored?.completedAt).toBeTruthy();
|
||||
expect(stored?.outputAssetIds.length).toBe(1);
|
||||
expect(await listGenerationJobs(DEFAULT_OWNER_ID, 10)).toHaveLength(1);
|
||||
expect(stored?.ownerId).toBe(publicApiOwnerId("agent-a"));
|
||||
expect(await listGenerationJobs(publicApiOwnerId("agent-a"), 10)).toHaveLength(1);
|
||||
expect(await listGenerationJobs(DEFAULT_OWNER_ID, 10)).toHaveLength(0);
|
||||
expect(await listPublicApiAssets("agent-a")).toHaveLength(1);
|
||||
expect(await listPublicApiAssets("agent-b")).toHaveLength(0);
|
||||
expect(await getPublicApiAsset("agent-b", stored!.outputAssetIds[0])).toBeNull();
|
||||
});
|
||||
|
||||
it("signs webhook bodies with the configured secret", () => {
|
||||
|
||||
Reference in New Issue
Block a user