forked from EduCraft/curriculum-project-hub
feat: secure organization Feishu credentials
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import Fastify from "fastify";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mintSessionToken, sessionCookieHeader } from "../../src/admin/routes/authRoutes.js";
|
||||
import { registerAdminPlugin } from "../../src/admin/plugin.js";
|
||||
import { FeishuReadinessError } from "../../src/connections/feishuReadiness.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
const SESSION_SECRET = "integration-test-session-secret";
|
||||
|
||||
async function buildApp(readinessProbe: (input: {
|
||||
readonly appId: string;
|
||||
readonly appSecret: string;
|
||||
readonly botOpenId: string;
|
||||
}) => Promise<void>) {
|
||||
const app = Fastify({ logger: false });
|
||||
await registerAdminPlugin(app, {
|
||||
prisma,
|
||||
sessionSecret: SESSION_SECRET,
|
||||
publicBaseUrl: "http://127.0.0.1:8788",
|
||||
feishuAppId: "cli_legacy_auth_only",
|
||||
feishuAppSecret: "legacy-auth-secret",
|
||||
projectWorkspaceRoot: "/tmp/cph-test-workspaces",
|
||||
secretEnvelope: testSecretEnvelope,
|
||||
feishuConnectionReadinessProbe: readinessProbe,
|
||||
cookieSecure: false,
|
||||
});
|
||||
await app.ready();
|
||||
return app;
|
||||
}
|
||||
|
||||
async function seedUser(role: "OWNER" | "ADMIN" | "MEMBER"): Promise<string> {
|
||||
const id = `feishu-${role.toLowerCase()}`;
|
||||
const openId = `ou_feishu_${role.toLowerCase()}`;
|
||||
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: DEFAULT_ORG_ID, userId: id, role },
|
||||
});
|
||||
return mintSessionToken({ userId: id, feishuOpenId: openId }, SESSION_SECRET);
|
||||
}
|
||||
|
||||
describe("Organization Feishu Application Connection API", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("accepts write-only credentials and returns only redacted metadata", async () => {
|
||||
const token = await seedUser("OWNER");
|
||||
const readiness = vi.fn(async () => {});
|
||||
const app = await buildApp(readiness);
|
||||
const credential = {
|
||||
appId: "cli_customer",
|
||||
appSecret: "api-secret-never-return",
|
||||
botOpenId: "ou_customer_bot",
|
||||
verificationToken: "verification-never-return",
|
||||
encryptKey: "encrypt-key-never-return",
|
||||
};
|
||||
try {
|
||||
const created = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/feishu-application-connection",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(token),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload: credential,
|
||||
});
|
||||
|
||||
expect(created.statusCode).toBe(201);
|
||||
expect(created.json()).toMatchObject({
|
||||
status: "ACTIVE",
|
||||
activeVersion: 1,
|
||||
keyId: "test-active",
|
||||
});
|
||||
for (const plaintext of Object.values(credential)) {
|
||||
expect(created.body).not.toContain(plaintext);
|
||||
}
|
||||
expect(readiness).toHaveBeenCalledWith({
|
||||
appId: credential.appId,
|
||||
appSecret: credential.appSecret,
|
||||
botOpenId: credential.botOpenId,
|
||||
verificationToken: credential.verificationToken,
|
||||
encryptKey: credential.encryptKey,
|
||||
schemaVersion: 1,
|
||||
});
|
||||
|
||||
const read = await app.inject({
|
||||
method: "GET",
|
||||
url: "/api/org/test-default/feishu-application-connection",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(read.statusCode).toBe(200);
|
||||
expect(read.json()).toEqual({ connection: expect.objectContaining({ activeVersion: 1 }) });
|
||||
for (const plaintext of Object.values(credential)) {
|
||||
expect(read.body).not.toContain(plaintext);
|
||||
}
|
||||
|
||||
const disabled = await app.inject({
|
||||
method: "DELETE",
|
||||
url: "/api/org/test-default/feishu-application-connection",
|
||||
headers: { cookie: sessionCookieHeader(token) },
|
||||
});
|
||||
expect(disabled.statusCode).toBe(200);
|
||||
expect(disabled.json()).toMatchObject({ status: "DISABLED", activeVersion: 1 });
|
||||
for (const plaintext of Object.values(credential)) {
|
||||
expect(disabled.body).not.toContain(plaintext);
|
||||
}
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects members and does not persist credentials rejected by Feishu", async () => {
|
||||
const memberToken = await seedUser("MEMBER");
|
||||
const app = await buildApp(async () => {
|
||||
throw new FeishuReadinessError(
|
||||
"feishu_readiness_rejected",
|
||||
"Feishu bot identity does not match the configured botOpenId",
|
||||
"provider_rejection",
|
||||
);
|
||||
});
|
||||
const payload = {
|
||||
appId: "cli_rejected",
|
||||
appSecret: "rejected-secret",
|
||||
botOpenId: "ou_rejected",
|
||||
};
|
||||
try {
|
||||
const denied = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/feishu-application-connection",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(memberToken),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload,
|
||||
});
|
||||
expect(denied.statusCode).toBe(403);
|
||||
|
||||
const adminToken = await seedUser("ADMIN");
|
||||
const rejected = await app.inject({
|
||||
method: "PUT",
|
||||
url: "/api/org/test-default/feishu-application-connection",
|
||||
headers: {
|
||||
cookie: sessionCookieHeader(adminToken),
|
||||
"content-type": "application/json",
|
||||
},
|
||||
payload,
|
||||
});
|
||||
expect(rejected.statusCode).toBe(400);
|
||||
expect(rejected.json()).toMatchObject({ error: { code: "feishu_credential_rejected" } });
|
||||
await expect(prisma.organizationFeishuApplicationConnection.count()).resolves.toBe(0);
|
||||
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(0);
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
TEST_SECRET_KEY_ID,
|
||||
} from "./helpers.js";
|
||||
import { ProviderConnectionService } from "../../src/connections/providerConnections.js";
|
||||
import { FeishuApplicationConnectionService } from "../../src/connections/feishuApplicationConnections.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const TEST_DATABASE_URL = "postgresql://paradigm:paradigm@127.0.0.1:5432/cph_hub_test";
|
||||
@@ -115,9 +116,12 @@ describe("deployment preflight CLI", () => {
|
||||
const result = await runCli(fixture.args);
|
||||
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.stderr).toContain("stored provider envelope verification failed");
|
||||
expect(result.stderr).toContain("stored connection envelope verification failed");
|
||||
expect(result.stderr).toContain("provider: secret envelope authentication failed");
|
||||
expect(result.stderr).toContain("Feishu: secret envelope authentication failed");
|
||||
expect(result.stderr).toContain("secret envelope authentication failed");
|
||||
expect(result.stderr).not.toContain("stored-envelope-secret");
|
||||
expect(result.stderr).not.toContain("stored-feishu-envelope-secret");
|
||||
});
|
||||
|
||||
it("loads the actual Hub dependency graph and queries PostgreSQL in the runtime probe", async () => {
|
||||
@@ -282,6 +286,13 @@ describe("deployment preflight CLI", () => {
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "stored-envelope-secret",
|
||||
});
|
||||
await new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {}).rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "preflight-admin",
|
||||
appId: "cli_preflight",
|
||||
appSecret: "stored-feishu-envelope-secret",
|
||||
botOpenId: "ou_preflight_bot",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
FeishuApplicationConnectionService,
|
||||
rewrapStoredFeishuApplicationEnvelopes,
|
||||
resolveActiveFeishuApplication,
|
||||
verifyStoredFeishuApplicationEnvelopes,
|
||||
} from "../../src/connections/feishuApplicationConnections.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb, seedTestOrganization, testSecretEnvelope } from "./helpers.js";
|
||||
|
||||
describe("Organization Feishu Application Connections", () => {
|
||||
beforeEach(async () => {
|
||||
await resetDb();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
|
||||
it("stores write-only app credentials and resolves them only from the explicit Organization", async () => {
|
||||
await seedAdmin("feishu-admin", DEFAULT_ORG_ID, "ADMIN");
|
||||
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
const credential = {
|
||||
appId: "cli_shared_provider_local_id",
|
||||
appSecret: "feishu-secret-never-return",
|
||||
botOpenId: "ou_shared_bot",
|
||||
verificationToken: "verification-never-return",
|
||||
encryptKey: "encrypt-key-never-return",
|
||||
};
|
||||
|
||||
const created = await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-admin",
|
||||
...credential,
|
||||
});
|
||||
|
||||
expect(created).toMatchObject({
|
||||
created: true,
|
||||
status: "ACTIVE",
|
||||
activeVersion: 1,
|
||||
keyId: "test-active",
|
||||
});
|
||||
const persisted = await prisma.$queryRaw<Array<{ envelope: unknown }>>`
|
||||
SELECT "envelope" FROM "FeishuApplicationCredentialVersion"
|
||||
`;
|
||||
const serialized = JSON.stringify(persisted);
|
||||
for (const plaintext of Object.values(credential)) expect(serialized).not.toContain(plaintext);
|
||||
|
||||
await expect(resolveActiveFeishuApplication(prisma, testSecretEnvelope, {
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
})).resolves.toMatchObject({
|
||||
connectionId: created.id,
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
...credential,
|
||||
});
|
||||
});
|
||||
|
||||
it("allows colliding provider-local identifiers in two connection namespaces", async () => {
|
||||
await seedTestOrganization("org_feishu_other", "feishu-other");
|
||||
await seedAdmin("feishu-admin-a", DEFAULT_ORG_ID, "OWNER");
|
||||
await seedAdmin("feishu-admin-b", "org_feishu_other", "ADMIN");
|
||||
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
const shared = {
|
||||
appSecret: "same-secret-is-still-separately-encrypted",
|
||||
botOpenId: "ou_same_bot",
|
||||
};
|
||||
const first = await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-admin-a",
|
||||
appId: "cli_app_a",
|
||||
...shared,
|
||||
});
|
||||
const second = await service.rotateCustomerApplication({
|
||||
organizationId: "org_feishu_other",
|
||||
actorUserId: "feishu-admin-b",
|
||||
appId: "cli_app_b",
|
||||
...shared,
|
||||
});
|
||||
|
||||
expect(first.id).not.toBe(second.id);
|
||||
const resolved = await Promise.all([
|
||||
resolveActiveFeishuApplication(prisma, testSecretEnvelope, { connectionId: first.id }),
|
||||
resolveActiveFeishuApplication(prisma, testSecretEnvelope, { connectionId: second.id }),
|
||||
]);
|
||||
expect(resolved.map((item) => item.organizationId)).toEqual([DEFAULT_ORG_ID, "org_feishu_other"]);
|
||||
});
|
||||
|
||||
it("refuses to relabel a stable connection or share one app across Organizations", async () => {
|
||||
await seedTestOrganization("org_feishu_other", "feishu-other");
|
||||
await seedAdmin("feishu-owner-a", DEFAULT_ORG_ID, "OWNER");
|
||||
await seedAdmin("feishu-owner-b", "org_feishu_other", "OWNER");
|
||||
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner-a",
|
||||
appId: "cli_stable",
|
||||
appSecret: "secret-one",
|
||||
botOpenId: "ou_bot",
|
||||
});
|
||||
|
||||
await expect(service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner-a",
|
||||
appId: "cli_replacement",
|
||||
appSecret: "replacement-secret",
|
||||
botOpenId: "ou_replacement_bot",
|
||||
})).rejects.toThrow("cannot change app identity");
|
||||
await expect(service.rotateCustomerApplication({
|
||||
organizationId: "org_feishu_other",
|
||||
actorUserId: "feishu-owner-b",
|
||||
appId: "cli_stable",
|
||||
appSecret: "secret-one",
|
||||
botOpenId: "ou_bot",
|
||||
})).rejects.toThrow("cannot be shared across Organizations");
|
||||
await expect(prisma.organizationFeishuApplicationConnection.count()).resolves.toBe(1);
|
||||
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("rotates immutable versions and rejects a non-admin service caller", async () => {
|
||||
await seedAdmin("feishu-owner", DEFAULT_ORG_ID, "OWNER");
|
||||
await seedAdmin("feishu-member", DEFAULT_ORG_ID, "MEMBER");
|
||||
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner",
|
||||
appId: "cli_one",
|
||||
appSecret: "secret-one",
|
||||
botOpenId: "ou_bot",
|
||||
});
|
||||
const rotated = await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner",
|
||||
appId: "cli_one",
|
||||
appSecret: "secret-two",
|
||||
botOpenId: "ou_bot",
|
||||
});
|
||||
expect(rotated).toMatchObject({ created: false, activeVersion: 2 });
|
||||
await expect(prisma.feishuApplicationCredentialVersion.findMany({
|
||||
orderBy: { version: "asc" },
|
||||
select: { version: true, retiredAt: true },
|
||||
})).resolves.toEqual([
|
||||
{ version: 1, retiredAt: expect.any(Date) },
|
||||
{ version: 2, retiredAt: null },
|
||||
]);
|
||||
|
||||
await expect(service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-member",
|
||||
appId: "cli_forbidden",
|
||||
appSecret: "forbidden-secret",
|
||||
botOpenId: "ou_bot",
|
||||
})).rejects.toThrow("requires an active Organization OWNER or ADMIN");
|
||||
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(2);
|
||||
});
|
||||
|
||||
it("disables immediately through an authorized audited lifecycle write", async () => {
|
||||
await seedAdmin("feishu-owner", DEFAULT_ORG_ID, "OWNER");
|
||||
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
const created = await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner",
|
||||
appId: "cli_disable",
|
||||
appSecret: "disable-secret",
|
||||
botOpenId: "ou_bot",
|
||||
});
|
||||
|
||||
await expect(service.disable({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner",
|
||||
})).resolves.toMatchObject({ id: created.id, status: "DISABLED", activeVersion: 1 });
|
||||
await expect(resolveActiveFeishuApplication(prisma, testSecretEnvelope, {
|
||||
connectionId: created.id,
|
||||
})).rejects.toThrow("active Feishu Application Connection not found");
|
||||
await expect(prisma.auditEntry.count({
|
||||
where: { organizationId: DEFAULT_ORG_ID, action: "feishu_application.disabled" },
|
||||
})).resolves.toBe(1);
|
||||
});
|
||||
|
||||
it("fails closed when the stored app fingerprint is detached from the encrypted app id", async () => {
|
||||
await seedAdmin("feishu-owner", DEFAULT_ORG_ID, "OWNER");
|
||||
const service = new FeishuApplicationConnectionService(prisma, testSecretEnvelope, async () => {});
|
||||
const created = await service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner",
|
||||
appId: "cli_original",
|
||||
appSecret: "original-secret",
|
||||
botOpenId: "ou_bot",
|
||||
});
|
||||
const replacementFingerprint = createHash("sha256")
|
||||
.update("cph-feishu-app\0")
|
||||
.update("cli_replacement", "utf8")
|
||||
.digest("hex");
|
||||
await prisma.organizationFeishuApplicationConnection.update({
|
||||
where: { id: created.id },
|
||||
data: { appIdentityFingerprint: replacementFingerprint },
|
||||
});
|
||||
|
||||
await expect(resolveActiveFeishuApplication(prisma, testSecretEnvelope, {
|
||||
connectionId: created.id,
|
||||
})).rejects.toThrow("app identity fingerprint mismatch");
|
||||
await expect(verifyStoredFeishuApplicationEnvelopes(
|
||||
prisma,
|
||||
testSecretEnvelope,
|
||||
)).rejects.toThrow("app identity fingerprint mismatch");
|
||||
await expect(prisma.$transaction((tx) => rewrapStoredFeishuApplicationEnvelopes(
|
||||
tx,
|
||||
testSecretEnvelope,
|
||||
))).rejects.toThrow("app identity fingerprint mismatch");
|
||||
await expect(service.rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "feishu-owner",
|
||||
appId: "cli_replacement",
|
||||
appSecret: "replacement-secret",
|
||||
botOpenId: "ou_replacement_bot",
|
||||
})).rejects.toThrow("app identity fingerprint mismatch");
|
||||
await expect(prisma.feishuApplicationCredentialVersion.count()).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
async function seedAdmin(
|
||||
userId: string,
|
||||
organizationId: string,
|
||||
role: "OWNER" | "ADMIN" | "MEMBER",
|
||||
): Promise<void> {
|
||||
await prisma.user.create({
|
||||
data: { id: userId, feishuOpenId: `legacy_${userId}`, displayName: userId },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId, userId, role },
|
||||
});
|
||||
}
|
||||
@@ -36,6 +36,8 @@ export const prisma = new PrismaClient({
|
||||
export async function resetDb(): Promise<void> {
|
||||
const tables = [
|
||||
"FeishuEventReceipt",
|
||||
"FeishuApplicationCredentialVersion",
|
||||
"OrganizationFeishuApplicationConnection",
|
||||
"ProviderCredentialVersion",
|
||||
"OrganizationProviderConnection",
|
||||
"AgentFileChange",
|
||||
|
||||
@@ -3,6 +3,10 @@ import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { ProviderConnectionService, verifyStoredProviderEnvelopes } from "../../src/connections/providerConnections.js";
|
||||
import {
|
||||
FeishuApplicationConnectionService,
|
||||
verifyStoredFeishuApplicationEnvelopes,
|
||||
} from "../../src/connections/feishuApplicationConnections.js";
|
||||
import { LocalSecretEnvelope, loadLocalSecretKeyringFile, type SecretEnvelopeV1 } from "../../src/security/secretEnvelope.js";
|
||||
import { LOCAL_KEK_ROTATION_ADVISORY_LOCK, rotateLocalKek } from "../../src/security/localKekRotation.js";
|
||||
import { DEFAULT_ORG_ID, prisma, resetDb } from "./helpers.js";
|
||||
@@ -36,10 +40,21 @@ describe("local KEK rotation", () => {
|
||||
baseUrl: "https://provider.example/api",
|
||||
authToken: "credential-stays-encrypted",
|
||||
});
|
||||
await new FeishuApplicationConnectionService(prisma, oldSecrets, async () => {}).rotateCustomerApplication({
|
||||
organizationId: DEFAULT_ORG_ID,
|
||||
actorUserId: "rotation-admin",
|
||||
appId: "cli_rotation",
|
||||
appSecret: "feishu-credential-stays-encrypted",
|
||||
botOpenId: "ou_rotation_bot",
|
||||
});
|
||||
const before = await prisma.providerCredentialVersion.findFirstOrThrow({
|
||||
include: { connection: true },
|
||||
});
|
||||
const beforeEnvelope = before.envelope as unknown as SecretEnvelopeV1;
|
||||
const beforeFeishu = await prisma.feishuApplicationCredentialVersion.findFirstOrThrow({
|
||||
include: { connection: true },
|
||||
});
|
||||
const beforeFeishuEnvelope = beforeFeishu.envelope as unknown as SecretEnvelopeV1;
|
||||
const directory = await mkdtemp(join(tmpdir(), "cph-kek-rotation-"));
|
||||
temporaryDirectories.push(directory);
|
||||
const keyringFile = join(directory, "keyring.json");
|
||||
@@ -99,29 +114,47 @@ describe("local KEK rotation", () => {
|
||||
expect(result).toMatchObject({
|
||||
previousActiveKeyId: "local-old",
|
||||
activeKeyId: expect.stringMatching(/^local-20260710T123456Z-/),
|
||||
rewrapped: 1,
|
||||
verified: 1,
|
||||
rewrapped: 2,
|
||||
verified: 2,
|
||||
});
|
||||
const after = await prisma.providerCredentialVersion.findUniqueOrThrow({ where: { id: before.id } });
|
||||
const afterEnvelope = after.envelope as unknown as SecretEnvelopeV1;
|
||||
expect(after.keyId).toBe(result.activeKeyId);
|
||||
expect(afterEnvelope.payload).toEqual(beforeEnvelope.payload);
|
||||
expect(afterEnvelope.wrappedDek).not.toEqual(beforeEnvelope.wrappedDek);
|
||||
const afterFeishu = await prisma.feishuApplicationCredentialVersion.findUniqueOrThrow({
|
||||
where: { id: beforeFeishu.id },
|
||||
});
|
||||
const afterFeishuEnvelope = afterFeishu.envelope as unknown as SecretEnvelopeV1;
|
||||
expect(afterFeishu.keyId).toBe(result.activeKeyId);
|
||||
expect(afterFeishuEnvelope.payload).toEqual(beforeFeishuEnvelope.payload);
|
||||
expect(afterFeishuEnvelope.wrappedDek).not.toEqual(beforeFeishuEnvelope.wrappedDek);
|
||||
const keyringSource = await readFile(keyringFile, "utf8");
|
||||
expect(keyringSource).toContain("local-old");
|
||||
expect(keyringSource).toContain(result.activeKeyId);
|
||||
expect(keyringSource).not.toContain("credential-stays-encrypted");
|
||||
expect(keyringSource).not.toContain("feishu-credential-stays-encrypted");
|
||||
|
||||
const rotatedSecrets = new LocalSecretEnvelope(await loadLocalSecretKeyringFile(keyringFile));
|
||||
await expect(verifyStoredProviderEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
||||
await expect(verifyStoredFeishuApplicationEnvelopes(prisma, rotatedSecrets)).resolves.toBe(1);
|
||||
expect(() => oldSecrets.decryptJson({
|
||||
purpose: "provider-connection",
|
||||
organizationId: before.connection.organizationId,
|
||||
connectionId: before.connection.id,
|
||||
secretVersionId: before.id,
|
||||
}, afterEnvelope)).toThrow("secret envelope key is unavailable");
|
||||
expect(() => oldSecrets.decryptJson({
|
||||
purpose: "feishu-application",
|
||||
organizationId: beforeFeishu.connection.organizationId,
|
||||
connectionId: beforeFeishu.connection.id,
|
||||
secretVersionId: beforeFeishu.id,
|
||||
}, afterFeishuEnvelope)).toThrow("secret envelope key is unavailable");
|
||||
await expect(prisma.auditEntry.count({
|
||||
where: { action: "provider_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
||||
})).resolves.toBe(1);
|
||||
await expect(prisma.auditEntry.count({
|
||||
where: { action: "feishu_application_secret.kek_rewrapped", organizationId: DEFAULT_ORG_ID },
|
||||
})).resolves.toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { FeishuReadinessError, probeFeishuApplication } from "../../src/connections/feishuReadiness.js";
|
||||
|
||||
describe("Feishu application readiness", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("authenticates the app and verifies the exact bot identity", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce(Response.json({ code: 0, tenant_access_token: "tenant-secret" }))
|
||||
.mockResolvedValueOnce(Response.json({ code: 0, bot: { open_id: "ou_expected" } }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(probeFeishuApplication({
|
||||
appId: "cli_expected",
|
||||
appSecret: "app-secret",
|
||||
botOpenId: "ou_expected",
|
||||
})).resolves.toBeUndefined();
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("https://open.feishu.cn/open-apis/bot/v3/info");
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toMatchObject({
|
||||
method: "GET",
|
||||
redirect: "manual",
|
||||
headers: { authorization: "Bearer tenant-secret" },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a valid app credential paired with the wrong bot id", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn()
|
||||
.mockResolvedValueOnce(Response.json({ code: 0, tenant_access_token: "tenant-secret" }))
|
||||
.mockResolvedValueOnce(Response.json({ code: 0, bot: { open_id: "ou_actual" } })));
|
||||
|
||||
const failure = probeFeishuApplication({
|
||||
appId: "cli_expected",
|
||||
appSecret: "app-secret",
|
||||
botOpenId: "ou_wrong",
|
||||
});
|
||||
await expect(failure).rejects.toBeInstanceOf(FeishuReadinessError);
|
||||
await expect(failure).rejects.toMatchObject({
|
||||
code: "feishu_readiness_rejected",
|
||||
category: "provider_rejection",
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user