feat: add admin accounts and image templates

This commit is contained in:
inman
2026-07-03 11:25:25 +08:00
parent d98e58adfa
commit c3ea9f0eb1
61 changed files with 7016 additions and 199 deletions

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

View File

@@ -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;

View 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;
}

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

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

View File

@@ -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", () => {