480 lines
20 KiB
TypeScript
480 lines
20 KiB
TypeScript
import { expect } from "vitest";
|
|
import { denFetch } from "@openwork/behaviors";
|
|
import type { DenSession } from "@openwork/behaviors";
|
|
import {
|
|
liteLlm,
|
|
needs,
|
|
server,
|
|
SkipError,
|
|
test,
|
|
unmetNeeds,
|
|
} from "@openwork/testkit";
|
|
import type { TestNeeds } from "@openwork/testkit";
|
|
|
|
const ORGANIZATION_NAME = "LiteLLM Per-Member Credentials";
|
|
const PROVIDER_NAME = "Per-Member LiteLLM Gateway";
|
|
const PROVIDER_KEY = "openwork-litellm-per-member";
|
|
const PROVIDER_ENV = "LITELLM_PER_MEMBER_API_KEY";
|
|
const MODEL_ID = "openwork-litellm-per-member-model";
|
|
const MODEL_NAME = "Per-Member Witness Model";
|
|
const REPLY = "The per-member LiteLLM credential is valid.";
|
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
const requirements: TestNeeds = { optIn: ["OPENWORK_EVAL_E2E_TESTS"], commands: ["docker"] };
|
|
const missingRequirements = unmetNeeds(requirements, process.env);
|
|
const title = missingRequirements.length > 0
|
|
? `LiteLLM per-member credential proof skipped — needs: ${missingRequirements.join(", ")}`
|
|
: "a third-party provisioner reconciles and offboards per-member LiteLLM keys";
|
|
|
|
interface ProvisionerConfig {
|
|
denApiUrl: string;
|
|
denToken: string;
|
|
orgId: string;
|
|
providerId: string;
|
|
liteLlmBaseUrl: string;
|
|
liteLlmMasterKey: string;
|
|
models: string[];
|
|
}
|
|
|
|
interface ProvisionerModule {
|
|
syncProviderModelMetadata(input: ProvisionerConfig): Promise<ModelMetadataResult>;
|
|
reconcileMemberKeys(input: ProvisionerConfig): Promise<ReconcileResult>;
|
|
offboardMember(input: ProvisionerConfig & { orgMembershipId: string }): Promise<unknown>;
|
|
}
|
|
|
|
interface ModelMetadataResult {
|
|
action: "unchanged" | "updated";
|
|
models: Array<{ id: string; maxInputTokens: number; maxOutputTokens: number }>;
|
|
}
|
|
|
|
interface ReconcileResult {
|
|
modelMetadata: ModelMetadataResult;
|
|
memberCredentials: unknown[];
|
|
}
|
|
|
|
interface LiveModelMetadata {
|
|
maxInputTokens: number;
|
|
maxOutputTokens: number;
|
|
capabilities: Record<string, boolean>;
|
|
}
|
|
|
|
interface ChatResult {
|
|
status: number;
|
|
reply: string;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isProvisionerModule(value: unknown): value is ProvisionerModule {
|
|
return isRecord(value)
|
|
&& typeof value.syncProviderModelMetadata === "function"
|
|
&& typeof value.reconcileMemberKeys === "function"
|
|
&& typeof value.offboardMember === "function";
|
|
}
|
|
|
|
function auth(session: DenSession): Record<string, string> {
|
|
return { authorization: `Bearer ${session.token}` };
|
|
}
|
|
|
|
function orgHeaders(session: DenSession, orgId: string): Record<string, string> {
|
|
return { ...auth(session), "x-openwork-org-id": orgId };
|
|
}
|
|
|
|
async function organizationId(session: DenSession): Promise<string> {
|
|
const result = await denFetch(session, "/v1/me/orgs", {
|
|
headers: auth(session),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
const organizations = isRecord(result.body) && Array.isArray(result.body.orgs)
|
|
? result.body.orgs.filter(isRecord)
|
|
: [];
|
|
const organization = organizations.find((entry) => entry.name === ORGANIZATION_NAME);
|
|
const id = organization && typeof organization.id === "string" ? organization.id : "";
|
|
if (!result.response.ok || !id) {
|
|
throw new Error(`Finding the test organization failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function memberIdByEmail(admin: DenSession, orgId: string, email: string): Promise<string> {
|
|
const result = await denFetch(admin, "/v1/org", {
|
|
headers: orgHeaders(admin, orgId),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
const members = isRecord(result.body) && Array.isArray(result.body.members)
|
|
? result.body.members.filter(isRecord)
|
|
: [];
|
|
const member = members.find((entry) => isRecord(entry.user) && entry.user.email === email);
|
|
const id = member && typeof member.id === "string" ? member.id : "";
|
|
if (!result.response.ok || !id) {
|
|
throw new Error(`Finding member ${email} failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function createProvider(admin: DenSession, orgId: string, baseUrl: string): Promise<string> {
|
|
const result = await denFetch(admin, "/v1/llm-providers", {
|
|
method: "POST",
|
|
headers: orgHeaders(admin, orgId),
|
|
body: JSON.stringify({
|
|
name: PROVIDER_NAME,
|
|
source: "custom",
|
|
customConfig: {
|
|
id: PROVIDER_KEY,
|
|
name: PROVIDER_NAME,
|
|
npm: "@ai-sdk/openai-compatible",
|
|
env: [PROVIDER_ENV],
|
|
api: baseUrl,
|
|
models: [{
|
|
id: MODEL_ID,
|
|
name: MODEL_NAME,
|
|
family: "preserved-witness-family",
|
|
limit: { context: 32_000, input: 32_000, output: 32_000 },
|
|
}],
|
|
},
|
|
credentialMode: "per_member",
|
|
allMembers: true,
|
|
memberIds: [],
|
|
teamIds: [],
|
|
}),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
const provider = isRecord(result.body) && isRecord(result.body.llmProvider)
|
|
? result.body.llmProvider
|
|
: null;
|
|
const id = provider && typeof provider.id === "string" ? provider.id : "";
|
|
if (result.response.status !== 201 || !id) {
|
|
throw new Error(`Creating the per-member provider failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return id;
|
|
}
|
|
|
|
async function connect(session: DenSession, orgId: string, providerId: string) {
|
|
return denFetch(session, `/v1/llm-providers/${encodeURIComponent(providerId)}/connect`, {
|
|
headers: orgHeaders(session, orgId),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
}
|
|
|
|
function connectKey(value: unknown): string {
|
|
const provider = isRecord(value) && isRecord(value.llmProvider) ? value.llmProvider : null;
|
|
if (!provider || typeof provider.apiKey !== "string" || !provider.apiKey) {
|
|
throw new Error("Provider connect response did not include an apiKey.");
|
|
}
|
|
return provider.apiKey;
|
|
}
|
|
|
|
function connectCredentialState(value: unknown): string {
|
|
const provider = isRecord(value) && isRecord(value.llmProvider) ? value.llmProvider : null;
|
|
const memberCredential = provider && isRecord(provider.memberCredential) ? provider.memberCredential : null;
|
|
if (!memberCredential || typeof memberCredential.state !== "string") {
|
|
throw new Error("Provider connect response did not include memberCredential.state.");
|
|
}
|
|
return memberCredential.state;
|
|
}
|
|
|
|
function memberCredentials(value: unknown): Record<string, unknown>[] {
|
|
if (!isRecord(value) || !Array.isArray(value.memberCredentials)) {
|
|
throw new Error("Member credential response had an invalid shape.");
|
|
}
|
|
return value.memberCredentials.filter(isRecord);
|
|
}
|
|
|
|
async function manageableProvider(admin: DenSession, orgId: string, providerId: string): Promise<Record<string, unknown>> {
|
|
const result = await denFetch(admin, "/v1/llm-providers?scope=manageable", {
|
|
headers: orgHeaders(admin, orgId),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
const providers = isRecord(result.body) && Array.isArray(result.body.llmProviders)
|
|
? result.body.llmProviders.filter(isRecord)
|
|
: [];
|
|
const provider = providers.find((entry) => entry.id === providerId);
|
|
if (!result.response.ok || !provider) {
|
|
throw new Error(`Finding manageable provider ${providerId} failed: HTTP ${result.response.status} ${result.text.slice(0, 500)}`);
|
|
}
|
|
return provider;
|
|
}
|
|
|
|
function providerModelConfig(provider: Record<string, unknown>, modelId: string): Record<string, unknown> {
|
|
const models = Array.isArray(provider.models) ? provider.models.filter(isRecord) : [];
|
|
const model = models.find((entry) => entry.id === modelId);
|
|
if (!model || !isRecord(model.config)) throw new Error(`Manageable provider did not include model config ${modelId}.`);
|
|
return model.config;
|
|
}
|
|
|
|
async function liveModelMetadata(baseUrl: string, apiKey: string): Promise<LiveModelMetadata> {
|
|
const adminBaseUrl = baseUrl.replace(/\/+$/, "").replace(/\/v1$/, "");
|
|
const response = await fetch(`${adminBaseUrl}/model_group/info`, {
|
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
const body: unknown = await response.json();
|
|
const entries = isRecord(body) && Array.isArray(body.data) ? body.data.filter(isRecord) : [];
|
|
const metadata = entries.find((entry) => entry.model_group === MODEL_ID);
|
|
if (!response.ok
|
|
|| !metadata
|
|
|| typeof metadata.max_input_tokens !== "number"
|
|
|| typeof metadata.max_output_tokens !== "number") {
|
|
throw new Error(`LiteLLM model-group metadata was unavailable for ${MODEL_ID}: HTTP ${response.status}.`);
|
|
}
|
|
const capabilities: Record<string, boolean> = {};
|
|
if (typeof metadata.supports_function_calling === "boolean") capabilities.tool_call = metadata.supports_function_calling;
|
|
if (typeof metadata.supports_reasoning === "boolean") capabilities.reasoning = metadata.supports_reasoning;
|
|
if (typeof metadata.supports_vision === "boolean") capabilities.attachment = metadata.supports_vision;
|
|
if (typeof metadata.supports_response_schema === "boolean") capabilities.structured_output = metadata.supports_response_schema;
|
|
if (Array.isArray(metadata.supported_openai_params)) {
|
|
capabilities.temperature = metadata.supported_openai_params.includes("temperature");
|
|
}
|
|
return {
|
|
maxInputTokens: metadata.max_input_tokens,
|
|
maxOutputTokens: metadata.max_output_tokens,
|
|
capabilities,
|
|
};
|
|
}
|
|
|
|
async function chat(baseUrl: string, apiKey: string): Promise<ChatResult> {
|
|
const response = await fetch(`${baseUrl}/chat/completions`, {
|
|
method: "POST",
|
|
headers: {
|
|
authorization: `Bearer ${apiKey}`,
|
|
"content-type": "application/json",
|
|
},
|
|
body: JSON.stringify({
|
|
model: MODEL_ID,
|
|
messages: [{ role: "user", content: "Confirm the deterministic per-member route." }],
|
|
}),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
let body: unknown = null;
|
|
try {
|
|
body = await response.json();
|
|
} catch {
|
|
body = null;
|
|
}
|
|
const choices = isRecord(body) && Array.isArray(body.choices) ? body.choices.filter(isRecord) : [];
|
|
const message = choices[0] && isRecord(choices[0].message) ? choices[0].message : null;
|
|
return {
|
|
status: response.status,
|
|
reply: message && typeof message.content === "string" ? message.content : "",
|
|
};
|
|
}
|
|
|
|
test.skipIf(missingRequirements.length > 0)(title, { timeout: 30 * 60_000 }, async ({ evidence, place }) => {
|
|
needs(requirements);
|
|
if (process.env.OPENWORK_EVAL_DEN_API_URL?.trim()) {
|
|
throw new SkipError("The LiteLLM per-member credential proof requires a cold managed Den");
|
|
}
|
|
|
|
await using gateway = await liteLlm({ place, modelId: MODEL_ID, reply: REPLY, database: true });
|
|
await using den = await server({
|
|
place,
|
|
org: {
|
|
name: ORGANIZATION_NAME,
|
|
admin: { name: "Credential Admin" },
|
|
members: {
|
|
alice: { name: "Alice Credential Member" },
|
|
bob: { name: "Bob Credential Member" },
|
|
},
|
|
},
|
|
});
|
|
const alice = den.members.alice;
|
|
const bob = den.members.bob;
|
|
if (!alice || !bob) throw new Error("The cold managed Den did not provision both members.");
|
|
const orgId = await organizationId(den.admin);
|
|
const [aliceMemberId, bobMemberId] = await Promise.all([
|
|
memberIdByEmail(den.admin, orgId, alice.email),
|
|
memberIdByEmail(den.admin, orgId, bob.email),
|
|
]);
|
|
const providerId = await createProvider(den.admin, orgId, gateway.baseUrl);
|
|
|
|
const aliceMissing = await connect(alice, orgId, providerId);
|
|
expect(aliceMissing.response.status).toBe(200);
|
|
expect(aliceMissing.body).toMatchObject({
|
|
llmProvider: { apiKey: null, apiKeys: null, memberCredential: { state: "missing" } },
|
|
});
|
|
evidence.recordAssertionEvidence(
|
|
"A granted member without a binding receives a compatible missing-credential payload",
|
|
"Alice's connect request returned HTTP 200 with null credentials and memberCredential.state=missing.",
|
|
aliceMissing.response.status === 200
|
|
&& isRecord(aliceMissing.body)
|
|
&& isRecord(aliceMissing.body.llmProvider)
|
|
&& aliceMissing.body.llmProvider.apiKey === null
|
|
&& aliceMissing.body.llmProvider.apiKeys === null
|
|
&& isRecord(aliceMissing.body.llmProvider.memberCredential)
|
|
&& aliceMissing.body.llmProvider.memberCredential.state === "missing",
|
|
);
|
|
|
|
const imported: unknown = await import(new URL(
|
|
"../../examples/litellm-per-member-keys/provision.mjs",
|
|
import.meta.url,
|
|
).href);
|
|
if (!isProvisionerModule(imported)) throw new Error("The example provisioner did not export the expected functions.");
|
|
const provisionerConfig: ProvisionerConfig = {
|
|
denApiUrl: den.ref.apiUrl,
|
|
denToken: den.admin.token,
|
|
orgId,
|
|
providerId,
|
|
liteLlmBaseUrl: gateway.baseUrl,
|
|
liteLlmMasterKey: gateway.apiKey,
|
|
models: [MODEL_ID],
|
|
};
|
|
const gatewayMetadata = await liveModelMetadata(gateway.baseUrl, gateway.apiKey);
|
|
expect(gatewayMetadata).toEqual({
|
|
maxInputTokens: 128_000,
|
|
maxOutputTokens: 16_384,
|
|
capabilities: {
|
|
tool_call: true,
|
|
reasoning: false,
|
|
attachment: true,
|
|
temperature: true,
|
|
},
|
|
});
|
|
const reconcileSummary = await imported.reconcileMemberKeys(provisionerConfig);
|
|
expect(reconcileSummary.modelMetadata).toEqual({
|
|
action: "updated",
|
|
models: [{ id: MODEL_ID, maxInputTokens: 128_000, maxOutputTokens: 16_384 }],
|
|
});
|
|
expect(reconcileSummary.memberCredentials.length).toBeGreaterThanOrEqual(2);
|
|
|
|
const syncedProvider = await manageableProvider(den.admin, orgId, providerId);
|
|
const syncedModel = providerModelConfig(syncedProvider, MODEL_ID);
|
|
expect(syncedModel).toMatchObject({
|
|
family: "preserved-witness-family",
|
|
limit: { context: 128_000, input: 128_000, output: 16_384 },
|
|
...gatewayMetadata.capabilities,
|
|
});
|
|
expect(syncedProvider.access).toMatchObject({ allMembers: true });
|
|
const syncedLimit = isRecord(syncedModel.limit) ? syncedModel.limit : null;
|
|
const metadataCameFromGateway = syncedLimit?.context === gatewayMetadata.maxInputTokens
|
|
&& syncedLimit.input === gatewayMetadata.maxInputTokens
|
|
&& syncedLimit.output === gatewayMetadata.maxOutputTokens
|
|
&& Object.entries(gatewayMetadata.capabilities).every(([key, value]) => syncedModel[key] === value)
|
|
&& syncedModel.family === "preserved-witness-family";
|
|
expect(metadataCameFromGateway).toBe(true);
|
|
evidence.recordAssertionEvidence(
|
|
"The provisioner synchronizes model limits and capabilities from the live LiteLLM gateway without dropping existing model fields or access",
|
|
"LiteLLM /model_group/info reported 128000 input and 16384 output tokens plus capability facts; Den replaced the intentionally wrong 32000 limits with those exact values, preserved the witness family, and retained all-member access.",
|
|
metadataCameFromGateway
|
|
&& isRecord(syncedProvider.access)
|
|
&& syncedProvider.access.allMembers === true,
|
|
);
|
|
|
|
const secondReconcile = await imported.reconcileMemberKeys(provisionerConfig);
|
|
expect(secondReconcile.modelMetadata).toEqual({
|
|
action: "unchanged",
|
|
models: [{ id: MODEL_ID, maxInputTokens: 128_000, maxOutputTokens: 16_384 }],
|
|
});
|
|
expect(secondReconcile.memberCredentials).toEqual([]);
|
|
evidence.recordAssertionEvidence(
|
|
"Repeated reconciliation does not rewrite model metadata that is already synchronized",
|
|
"The second real provisioner run returned modelMetadata.action=unchanged and provisioned no additional member credentials.",
|
|
secondReconcile.modelMetadata.action === "unchanged"
|
|
&& secondReconcile.memberCredentials.length === 0,
|
|
);
|
|
|
|
const [aliceConnect, bobConnect] = await Promise.all([
|
|
connect(alice, orgId, providerId),
|
|
connect(bob, orgId, providerId),
|
|
]);
|
|
expect(aliceConnect.response.status).toBe(200);
|
|
expect(bobConnect.response.status).toBe(200);
|
|
expect(connectCredentialState(aliceConnect.body)).toBe("active");
|
|
expect(connectCredentialState(bobConnect.body)).toBe("active");
|
|
const aliceKey = connectKey(aliceConnect.body);
|
|
const bobKey = connectKey(bobConnect.body);
|
|
expect(aliceKey).toMatch(/^sk-/);
|
|
expect(bobKey).toMatch(/^sk-/);
|
|
expect(aliceKey).not.toBe(gateway.apiKey);
|
|
expect(bobKey).not.toBe(gateway.apiKey);
|
|
expect(aliceKey).not.toBe(bobKey);
|
|
|
|
const listed = await denFetch(den.admin, `/v1/llm-providers/${encodeURIComponent(providerId)}/member-credentials`, {
|
|
headers: orgHeaders(den.admin, orgId),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
expect(listed.response.status).toBe(200);
|
|
expect(listed.text).not.toContain(aliceKey);
|
|
expect(listed.text).not.toContain(bobKey);
|
|
const credentials = memberCredentials(listed.body);
|
|
const aliceBinding = credentials.find((entry) => entry.orgMembershipId === aliceMemberId);
|
|
const bobBinding = credentials.find((entry) => entry.orgMembershipId === bobMemberId);
|
|
const bindingsActive = aliceBinding?.state === "active"
|
|
&& typeof aliceBinding.externalCredentialId === "string"
|
|
&& aliceBinding.externalCredentialId.length > 0
|
|
&& bobBinding?.state === "active"
|
|
&& typeof bobBinding.externalCredentialId === "string"
|
|
&& bobBinding.externalCredentialId.length > 0;
|
|
expect(bindingsActive).toBe(true);
|
|
evidence.recordAssertionEvidence(
|
|
"The provisioner creates distinct write-only LiteLLM bindings for granted members",
|
|
"Alice and Bob received distinct sk- virtual keys; the admin list showed active external credential ids and contained neither key.",
|
|
bindingsActive
|
|
&& aliceKey.startsWith("sk-")
|
|
&& bobKey.startsWith("sk-")
|
|
&& aliceKey !== bobKey
|
|
&& aliceKey !== gateway.apiKey
|
|
&& bobKey !== gateway.apiKey
|
|
&& !listed.text.includes(aliceKey)
|
|
&& !listed.text.includes(bobKey),
|
|
);
|
|
|
|
const aliceCheckpoint = await gateway.checkpoint();
|
|
const aliceChat = await chat(gateway.baseUrl, aliceKey);
|
|
expect(aliceChat.status).toBe(200);
|
|
expect(aliceChat.reply).toContain(REPLY);
|
|
const aliceUpstream = await gateway.waitForUpstreamRequest({
|
|
after: aliceCheckpoint,
|
|
model: MODEL_ID,
|
|
key: gateway.upstreamKey,
|
|
timeoutMs: 120_000,
|
|
});
|
|
const aliceUpstreamRequests = await gateway.upstreamRequests({ after: aliceCheckpoint });
|
|
const aliceKeyReachedUpstream = aliceUpstreamRequests.some((request) => request.tokenId === gateway.tokenId(aliceKey));
|
|
const masterKeyReachedUpstream = aliceUpstreamRequests.some((request) => request.tokenId === gateway.tokenId(gateway.apiKey));
|
|
expect(aliceUpstream.tokenId).toBe(gateway.tokenId(gateway.upstreamKey));
|
|
expect(aliceKeyReachedUpstream).toBe(false);
|
|
expect(masterKeyReachedUpstream).toBe(false);
|
|
evidence.recordAssertionEvidence(
|
|
"LiteLLM validates a database-backed member key and rewrites it for the upstream",
|
|
`Alice received the deterministic reply and upstream sequence ${aliceUpstream.sequence} carried only the configured upstream token fingerprint.`,
|
|
aliceChat.status === 200
|
|
&& aliceChat.reply.includes(REPLY)
|
|
&& aliceUpstream.tokenId === gateway.tokenId(gateway.upstreamKey)
|
|
&& !aliceKeyReachedUpstream
|
|
&& !masterKeyReachedUpstream,
|
|
);
|
|
|
|
await imported.offboardMember({ ...provisionerConfig, orgMembershipId: bobMemberId });
|
|
const bobRejected = await chat(gateway.baseUrl, bobKey);
|
|
expect(bobRejected.status).not.toBe(200);
|
|
const bobBlocked = await connect(bob, orgId, providerId);
|
|
expect(bobBlocked.response.status).toBe(200);
|
|
expect(bobBlocked.body).toMatchObject({
|
|
llmProvider: { apiKey: null, apiKeys: null, memberCredential: { state: "blocked" } },
|
|
});
|
|
const bobSelfWrite = await denFetch(bob, `/v1/llm-providers/${encodeURIComponent(providerId)}/my-credential`, {
|
|
method: "PUT",
|
|
headers: orgHeaders(bob, orgId),
|
|
body: JSON.stringify({ apiKey: "sk-member-cannot-unblock" }),
|
|
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
|
|
});
|
|
expect(bobSelfWrite.response.status).toBe(409);
|
|
expect(bobSelfWrite.body).toEqual({ error: "credential_blocked" });
|
|
const aliceStillWorks = await chat(gateway.baseUrl, aliceKey);
|
|
expect(aliceStillWorks.status).toBe(200);
|
|
expect(aliceStillWorks.reply).toContain(REPLY);
|
|
evidence.recordAssertionEvidence(
|
|
"Offboarding revokes only the selected upstream key and leaves the block admin-owned",
|
|
"Bob's LiteLLM key was rejected, Den returned a blocked credential state, Bob could not self-unblock, and Alice's key still received the deterministic reply.",
|
|
bobRejected.status !== 200
|
|
&& bobBlocked.response.status === 200
|
|
&& connectCredentialState(bobBlocked.body) === "blocked"
|
|
&& bobSelfWrite.response.status === 409
|
|
&& isRecord(bobSelfWrite.body)
|
|
&& bobSelfWrite.body.error === "credential_blocked"
|
|
&& aliceStillWorks.status === 200
|
|
&& aliceStillWorks.reply.includes(REPLY),
|
|
);
|
|
});
|