mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
refactor(state): resolve Gateway profiles in state workers (#162414)
* refactor(state): resolve Gateway profiles in state workers Move the remaining Gateway email acquisition and canonical profile selection callers onto existing state worker operations. Retain request and profile authority across waits and recheck it before writes, network effects, and metadata publication. Remove the synchronous Gateway access helpers. Keep schemas, stored data, permissions, and update behavior unchanged. Remaining profile reads/setters and account stores follow in a second slice. Isolate session-catalog fixture writes from background reclamation so request cleanup assertions remain deterministic across the worker wait. * test(gateway): assert snapshot rejection at handler boundary Profile acquisition now rejects discovery snapshots before model catalog loading. Preserve zero credential and HTTP effects while testing the direct handler rejection; WebSocket dispatch owns the error response. * test(gateway): await concurrent metadata reader admission Synchronize the concurrency fixture on all reader callbacks entering after asynchronous profile preparation. Preserve the original unrelated-creation, response, and cleanup assertions without timers or polling. * fix(gateway): retain one catalog acquisition authority check Keep the shared post-await authority assertion before catalog effects and the final publication check, without duplicate session path validation. Migrate authorization fixtures to the async profile owner contracts and remove their retired resolver mock. Existing real Gateway freshness budgets and permission assertions remain unchanged.
This commit is contained in:
parent
4a1c07851f
commit
9a835adfb8
13 changed files with 391 additions and 134 deletions
|
|
@ -19,7 +19,10 @@ import { SessionMutationAuthorizationChangedError } from "./session-sharing.js";
|
|||
import { resolveGatewaySessionStoreTargetWithStore } from "./session-utils.js";
|
||||
|
||||
const METHOD = "workboard.cards.dispatch";
|
||||
const ensureProfileForEmail = vi.hoisted(() => vi.fn());
|
||||
const ensureProfileIdForEmail = vi.hoisted(() => vi.fn());
|
||||
const prepareUserProfileRoleAuthority = vi.hoisted(() =>
|
||||
vi.fn(async (profileId: string) => ({ profileId, isCurrent: () => true })),
|
||||
);
|
||||
const getUserProfileDisplay = vi.hoisted(() =>
|
||||
vi.fn((profileId: string) => ({
|
||||
id: profileId,
|
||||
|
|
@ -28,16 +31,19 @@ const getUserProfileDisplay = vi.hoisted(() =>
|
|||
hasAvatar: false,
|
||||
})),
|
||||
);
|
||||
const resolveUserProfileId = vi.hoisted(() => vi.fn());
|
||||
const setDisplayName = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../state/user-profile-email.js", () => ({ ensureProfileIdForEmail }));
|
||||
vi.mock("../state/user-channel-identity-operations.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../state/user-channel-identity-operations.js")>()),
|
||||
prepareUserProfileRoleAuthority,
|
||||
}));
|
||||
|
||||
vi.mock("../state/user-profiles.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../state/user-profiles.js")>()),
|
||||
ensureProfileForEmail,
|
||||
getUserProfileDisplay,
|
||||
getUserProfileListItem: vi.fn(),
|
||||
linkEmail: vi.fn(),
|
||||
resolveUserProfileId,
|
||||
setAvatar: vi.fn(),
|
||||
setDisplayName,
|
||||
UserProfileNotFoundError: class UserProfileNotFoundError extends Error {},
|
||||
|
|
@ -45,9 +51,9 @@ vi.mock("../state/user-profiles.js", async (importOriginal) => ({
|
|||
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
ensureProfileForEmail.mockReset();
|
||||
ensureProfileIdForEmail.mockReset();
|
||||
prepareUserProfileRoleAuthority.mockClear();
|
||||
getUserProfileDisplay.mockClear();
|
||||
resolveUserProfileId.mockReset();
|
||||
setDisplayName.mockReset();
|
||||
});
|
||||
|
||||
|
|
@ -242,8 +248,7 @@ describe("gateway method authorization", () => {
|
|||
|
||||
it("allows an identified write caller to edit its own profile", async () => {
|
||||
const profile = { id: "profile-1" };
|
||||
ensureProfileForEmail.mockReturnValue(profile);
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
setDisplayName.mockReturnValue(profile);
|
||||
|
||||
expect(
|
||||
|
|
@ -256,8 +261,7 @@ describe("gateway method authorization", () => {
|
|||
});
|
||||
|
||||
it("requires admin when an identified write caller targets another profile", async () => {
|
||||
ensureProfileForEmail.mockReturnValue({ id: "profile-1" });
|
||||
resolveUserProfileId.mockReturnValue("profile-2");
|
||||
ensureProfileIdForEmail.mockResolvedValue("profile-1");
|
||||
|
||||
expect(
|
||||
await dispatchProfileMutation({
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ import {
|
|||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { GatewayOperatorRoleDefinition } from "../../config/types.gateway.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import * as userModelAccounts from "../../state/user-model-accounts.js";
|
||||
import { ensureProfileForEmail, setUserProfileRole } from "../../state/user-profiles.js";
|
||||
import { observeMainThreadSql } from "../../test-utils/main-thread-sql-spies.test-support.js";
|
||||
import { withOpenClawTestState } from "../../test-utils/openclaw-test-state.js";
|
||||
import { invalidateOperatorRolePolicy } from "../operator-role-policy.js";
|
||||
import { ADMIN_SCOPE, READ_SCOPE, SESSION_READ_SCOPE } from "../operator-scopes.js";
|
||||
|
|
@ -127,6 +129,61 @@ function dispatchMetadata(
|
|||
}
|
||||
|
||||
describe("chat metadata ownership", () => {
|
||||
it("creates and reuses a legacy requester profile through chat.metadata without host SQL", async () => {
|
||||
await withOpenClawTestState({ layout: "state-only" }, async () => {
|
||||
ensureProfileForEmail("admitted@example.test");
|
||||
const config: OpenClawConfig = { agents: { entries: { main: { default: true } } } };
|
||||
const metadata = { models: [], swarmEnabled: false };
|
||||
const readChatMetadata = vi.fn<GatewayRequestContext["readChatMetadata"]>(
|
||||
async () => metadata,
|
||||
);
|
||||
const context = createDirectChatContext({ getRuntimeConfig: () => config, readChatMetadata });
|
||||
const respond = vi.fn<RespondFn>();
|
||||
const client: NonNullable<GatewayRequestHandlerOptions["client"]> = {
|
||||
connId: "metadata-legacy-connection",
|
||||
authenticatedUserId: "metadata-legacy@example.test",
|
||||
connect: {
|
||||
minProtocol: 1,
|
||||
maxProtocol: 1,
|
||||
client: { id: "openclaw-control-ui", version: "test", platform: "test", mode: "webchat" },
|
||||
role: "operator",
|
||||
scopes: [READ_SCOPE],
|
||||
},
|
||||
};
|
||||
requireNodeSqlite();
|
||||
const sql = observeMainThreadSql();
|
||||
try {
|
||||
sql.calibrate();
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
await expectDefined(
|
||||
chatHistoryHandlers["chat.metadata"],
|
||||
"metadata handler",
|
||||
)({
|
||||
params: { agentId: "main" },
|
||||
context,
|
||||
client,
|
||||
respond,
|
||||
req: { type: "req", id: `legacy-profile-${attempt}`, method: "chat.metadata" },
|
||||
isWebchatConnect: () => false,
|
||||
});
|
||||
}
|
||||
sql.expectIdle();
|
||||
} finally {
|
||||
sql.restore();
|
||||
}
|
||||
const profile = ensureProfileForEmail("metadata-legacy@example.test");
|
||||
expect(readChatMetadata).toHaveBeenCalledTimes(2);
|
||||
for (const [scope] of readChatMetadata.mock.calls) {
|
||||
expect(scope.requesterProfileId).toBe(profile.id);
|
||||
expect(scope.assertCurrent).not.toThrow();
|
||||
}
|
||||
expect(respond.mock.calls).toEqual([
|
||||
[true, metadata],
|
||||
[true, metadata],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([READ_SCOPE, SESSION_READ_SCOPE])(
|
||||
"previews a retained personal account with %s without changing its cleared default",
|
||||
async (scope) => {
|
||||
|
|
|
|||
|
|
@ -28,17 +28,20 @@ import {
|
|||
} from "./chat-metadata-contract.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
import { preparePersonalModelAccountSelection } from "./users-model-account-access.js";
|
||||
import { resolveAuthenticatedProfileId } from "./users-profile-access.js";
|
||||
import { prepareAuthenticatedProfile } from "./users-profile-access.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
/** Resolve saved-session grants or capture a new draft's current human authority. */
|
||||
export function resolveChatMetadataReadParams(
|
||||
options: Pick<GatewayRequestHandlerOptions, "respond" | "context" | "client" | "signal">,
|
||||
export async function resolveChatMetadataReadParams(
|
||||
options: GatewayRequestHandlerOptions,
|
||||
params: ChatMetadataParams,
|
||||
draftAccountSelection?: UserModelAccountSelection,
|
||||
): ChatMetadataReadParams | undefined {
|
||||
): Promise<ChatMetadataReadParams | undefined> {
|
||||
const { respond, context, client, signal } = options;
|
||||
const cfg = context.getRuntimeConfig();
|
||||
draftAccountSelection?.assertCurrent();
|
||||
const requester = await prepareAuthenticatedProfile(options);
|
||||
requester.assertCurrent();
|
||||
// Session mutations are checked against the retained target before publication.
|
||||
const roleRevision = readOperatorRolePolicyRevision();
|
||||
const aliasRevision = readUserProfileAliasRevision();
|
||||
|
|
@ -56,6 +59,8 @@ export function resolveChatMetadataReadParams(
|
|||
"Chat metadata access changed while preparing its metadata. Retry the request.",
|
||||
);
|
||||
}
|
||||
requester.assertCurrent();
|
||||
draftAccountSelection?.assertCurrent();
|
||||
};
|
||||
if (params.sessionKey) {
|
||||
const sessionKey = params.sessionKey;
|
||||
|
|
@ -69,7 +74,7 @@ export function resolveChatMetadataReadParams(
|
|||
return undefined;
|
||||
}
|
||||
// Persisted session state owns account pins; a caller cannot replace them with a draft id.
|
||||
const requesterProfileId = resolveAuthenticatedProfileId(client);
|
||||
const requesterProfileId = requester.profileId;
|
||||
const session = retainGatewaySessionEntryReadOnly(
|
||||
params.sessionKey,
|
||||
requested.agentId,
|
||||
|
|
@ -131,10 +136,9 @@ export function resolveChatMetadataReadParams(
|
|||
return undefined;
|
||||
}
|
||||
assertRequestCurrent();
|
||||
draftAccountSelection?.assertCurrent();
|
||||
return {
|
||||
agentId: resolved.agentId,
|
||||
requesterProfileId: draftAccountSelection?.owner ?? resolveAuthenticatedProfileId(client),
|
||||
requesterProfileId: draftAccountSelection?.owner ?? requester.profileId,
|
||||
isCurrent: isRequestCurrent,
|
||||
assertCurrent: assertRequestCurrent,
|
||||
...(draftAccountSelection ? { draftAccountSelection } : {}),
|
||||
|
|
@ -158,10 +162,11 @@ export async function handleChatMetadataRequest(
|
|||
SESSION_READ_SCOPE,
|
||||
)
|
||||
: undefined;
|
||||
scope = resolveChatMetadataReadParams(options, params, draftAccountSelection);
|
||||
scope = await resolveChatMetadataReadParams(options, params, draftAccountSelection);
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
scope.assertCurrent?.();
|
||||
const metadata = await context.readChatMetadata(scope);
|
||||
scope.draftAccountSelection?.assertCurrent();
|
||||
scope.assertCurrent?.();
|
||||
|
|
|
|||
|
|
@ -2,11 +2,9 @@ import { expectDefined, safeParseJsonRecord } from "@openclaw/normalization-core
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import { getPreparedModelRuntimeAuthStore } from "../../agents/prepared-model-runtime-auth.js";
|
||||
import {
|
||||
loadSessionEntry,
|
||||
patchSessionEntryCore,
|
||||
upsertSessionEntryCore,
|
||||
} from "../../config/sessions/session-accessor.js";
|
||||
import { loadSessionEntry, patchSessionEntryCore } from "../../config/sessions/session-accessor.js";
|
||||
import { createFallbackSessionEntry } from "../../config/sessions/session-accessor.sqlite-normalize.js";
|
||||
import type { SessionEntry } from "../../config/sessions/types.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry-empty.js";
|
||||
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
|
|
@ -38,6 +36,16 @@ import { WITHOUT_OPENAI_ENV_AUTH } from "./models-list-result.openai-routes.test
|
|||
import { modelsHandlers } from "./models.js";
|
||||
import type { GatewayRequestHandlerOptions, RespondFn } from "./types.js";
|
||||
|
||||
function writeSessionFixture(
|
||||
scope: Parameters<typeof patchSessionEntryCore>[0],
|
||||
patch: Partial<SessionEntry>,
|
||||
) {
|
||||
return patchSessionEntryCore(scope, () => patch, {
|
||||
skipMaintenance: true,
|
||||
fallbackEntry: createFallbackSessionEntry(patch),
|
||||
});
|
||||
}
|
||||
|
||||
function fixture() {
|
||||
const person = ensureProfileForEmail("catalog-reader@example.test");
|
||||
setDisplayName(person.id, "Catalog Reader");
|
||||
|
|
@ -157,7 +165,7 @@ describe("direct session model catalogs", () => {
|
|||
const f = fixture();
|
||||
await state.writeConfig(f.config);
|
||||
const sessionKey = "agent:main:hidden-catalog";
|
||||
await upsertSessionEntryCore(
|
||||
await writeSessionFixture(
|
||||
{ agentId: "main", sessionKey },
|
||||
{
|
||||
sessionId: "hidden-catalog-session",
|
||||
|
|
@ -190,7 +198,7 @@ describe("direct session model catalogs", () => {
|
|||
const f = fixture();
|
||||
await state.writeConfig(f.config);
|
||||
const scope = { agentId: "main", sessionKey: "agent:main:catalog-read-marker" };
|
||||
await upsertSessionEntryCore(scope, {
|
||||
await writeSessionFixture(scope, {
|
||||
sessionId: "catalog-read-marker-session",
|
||||
lifecycleRevision: "catalog-read-marker-lifecycle",
|
||||
updatedAt: 1,
|
||||
|
|
@ -240,7 +248,10 @@ describe("direct session model catalogs", () => {
|
|||
throw new Error("Model catalog completed before the preparation hold");
|
||||
}),
|
||||
]);
|
||||
await patchSessionEntryCore(scope, () => ({ lastReadAt: 2 }), { preserveActivity: true });
|
||||
await patchSessionEntryCore(scope, () => ({ lastReadAt: 2 }), {
|
||||
preserveActivity: true,
|
||||
skipMaintenance: true,
|
||||
});
|
||||
expect(loadSessionEntry(scope)).toEqual({ ...before, lastReadAt: 2 });
|
||||
expect(readRow()).toEqual({
|
||||
...beforeRow,
|
||||
|
|
@ -270,7 +281,7 @@ describe("direct session model catalogs", () => {
|
|||
const f = fixture();
|
||||
await state.writeConfig(f.config);
|
||||
const scope = { agentId: "main", sessionKey: "agent:main:held-saved" };
|
||||
await upsertSessionEntryCore(scope, {
|
||||
await writeSessionFixture(scope, {
|
||||
sessionId: "original",
|
||||
updatedAt: 1,
|
||||
createdActor: { type: "human", source: "profile", id: f.person.id },
|
||||
|
|
@ -290,9 +301,9 @@ describe("direct session model catalogs", () => {
|
|||
await Promise.race([entered.promise, pending]);
|
||||
expect(f.readPrepared).toHaveBeenCalledOnce();
|
||||
if (change === "selected patch") {
|
||||
await upsertSessionEntryCore(scope, { label: "changed" });
|
||||
await writeSessionFixture(scope, { label: "changed" });
|
||||
} else if (change === "selected reset") {
|
||||
await upsertSessionEntryCore(scope, {
|
||||
await writeSessionFixture(scope, {
|
||||
sessionId: "replacement",
|
||||
lifecycleRevision: "replacement",
|
||||
});
|
||||
|
|
@ -345,12 +356,12 @@ describe("direct session model catalogs", () => {
|
|||
await state.writeConfig(f.config);
|
||||
const selected = { agentId: "main", sessionKey: "agent:main:metadata-selected" };
|
||||
const other = { ...selected, sessionKey: "agent:main:metadata-other" };
|
||||
await upsertSessionEntryCore(selected, {
|
||||
await writeSessionFixture(selected, {
|
||||
sessionId: "selected",
|
||||
updatedAt: 1,
|
||||
createdActor: { type: "human", source: "profile", id: f.person.id },
|
||||
});
|
||||
await upsertSessionEntryCore(other, {
|
||||
await writeSessionFixture(other, {
|
||||
sessionId: "other",
|
||||
updatedAt: 1,
|
||||
createdActor: { type: "human", source: "profile", id: f.person.id },
|
||||
|
|
@ -375,7 +386,7 @@ describe("direct session model catalogs", () => {
|
|||
void pending.catch(() => {});
|
||||
try {
|
||||
await Promise.race([entered.promise, pending]);
|
||||
await upsertSessionEntryCore(changeSelected ? selected : other, {
|
||||
await writeSessionFixture(changeSelected ? selected : other, {
|
||||
modelOverride: "replacement",
|
||||
});
|
||||
} finally {
|
||||
|
|
@ -405,7 +416,7 @@ describe("direct session model catalogs", () => {
|
|||
};
|
||||
await state.writeConfig(f.config);
|
||||
const sessionKey = "agent:main:saved";
|
||||
await upsertSessionEntryCore(
|
||||
await writeSessionFixture(
|
||||
{ agentId: "main", sessionKey },
|
||||
{
|
||||
sessionId: "saved-catalog-session",
|
||||
|
|
@ -455,7 +466,7 @@ describe("direct session model catalogs", () => {
|
|||
await state.writeConfig(f.config);
|
||||
clearUserProfileAuthLink({ profileId: f.person.id, provider: "openai" });
|
||||
const sessionKey = "agent:main:harness:catalog-native:saved";
|
||||
await upsertSessionEntryCore(
|
||||
await writeSessionFixture(
|
||||
{ agentId: "main", sessionKey },
|
||||
{
|
||||
sessionId: "catalog-native-session",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ export const modelsHandlers: GatewayRequestHandlers = {
|
|||
)
|
||||
: undefined;
|
||||
scope = scoped
|
||||
? resolveChatMetadataReadParams(options, params, draftAccountSelection)
|
||||
? await resolveChatMetadataReadParams(options, params, draftAccountSelection)
|
||||
: undefined;
|
||||
if (scoped && !scope) {
|
||||
return;
|
||||
|
|
@ -85,17 +85,18 @@ export const modelsHandlers: GatewayRequestHandlers = {
|
|||
allowedScopes: scopes,
|
||||
});
|
||||
if (limitedSessionRead) {
|
||||
scope = resolveChatMetadataReadParams(options, { agentId: resolved.agentId });
|
||||
scope = await resolveChatMetadataReadParams(options, { agentId: resolved.agentId });
|
||||
if (!scope) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
publicationScope =
|
||||
scope ?? resolveChatMetadataReadParams(options, { agentId: resolved.agentId });
|
||||
scope ?? (await resolveChatMetadataReadParams(options, { agentId: resolved.agentId }));
|
||||
if (!publicationScope) {
|
||||
return;
|
||||
}
|
||||
publicationScope.assertCurrent?.();
|
||||
if (params.refresh !== true) {
|
||||
refreshExpiredPreparedModelCatalog({ agentId: resolved.agentId, config: cfg });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
// Shared self-or-admin mutation policy for durable user profile methods.
|
||||
import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { GATEWAY_OWNER_PROFILE_ID } from "../../../packages/gateway-protocol/src/schema/users.js";
|
||||
import { formatErrorMessage } from "../../infra/errors.js";
|
||||
import { prepareUserProfileRoleAuthority } from "../../state/user-channel-identity-operations.js";
|
||||
import { ensureProfileForEmail, resolveUserProfileId } from "../../state/user-profiles.js";
|
||||
import { ensureProfileIdForEmail } from "../../state/user-profile-email.js";
|
||||
import {
|
||||
resolveGatewayOperatorRoleActor,
|
||||
resolveOperatorRolePolicyForAssignment,
|
||||
|
|
@ -11,50 +11,85 @@ import { ADMIN_SCOPE } from "../operator-scopes.js";
|
|||
import { readGatewayRequestMutationAuthority } from "./session-mutation-guards.js";
|
||||
import type { GatewayRequestHandlerOptions } from "./types.js";
|
||||
|
||||
export function resolveAuthenticatedProfileId(
|
||||
client: GatewayRequestHandlerOptions["client"],
|
||||
): string | undefined {
|
||||
if (client?.authenticatedUserProfile?.profileId) {
|
||||
return resolveUserProfileId(client.authenticatedUserProfile.profileId);
|
||||
export async function prepareAuthenticatedProfile(options: GatewayRequestHandlerOptions) {
|
||||
const { client } = options;
|
||||
const lifetime = readGatewayRequestMutationAuthority(options);
|
||||
const profileReference = client?.authenticatedUserProfile?.profileId;
|
||||
const email = client?.authenticatedUserId;
|
||||
const sync = client?.authenticatedGitHubIdentitySync;
|
||||
const provider = client?.authenticatedUserIsTailscaleProvider;
|
||||
const connectionId = client?.connId;
|
||||
const role = client?.connect.role;
|
||||
const scopes = [...(client?.connect.scopes ?? [])];
|
||||
const assertConnection = () => {
|
||||
lifetime.assertLifetimeCurrent();
|
||||
lifetime.expectedProfileBinding?.assertCurrent();
|
||||
if (
|
||||
options.client !== client ||
|
||||
client?.connId !== connectionId ||
|
||||
client?.connect.role !== role ||
|
||||
scopes.some((scope) => !client?.connect.scopes?.includes(scope)) ||
|
||||
client?.connectionSignal?.aborted ||
|
||||
client?.authenticatedUserProfile?.profileId !== profileReference ||
|
||||
client?.authenticatedUserId !== email ||
|
||||
client?.authenticatedGitHubIdentitySync !== sync ||
|
||||
client?.authenticatedUserIsTailscaleProvider !== provider
|
||||
) {
|
||||
throw new Error("Gateway requester profile changed");
|
||||
}
|
||||
};
|
||||
assertConnection();
|
||||
// Failed provider acquisition must never create an email alias for its login.
|
||||
const legacyEmail = !profileReference && !sync && !provider ? email : undefined;
|
||||
const reference =
|
||||
profileReference ??
|
||||
(legacyEmail ? await ensureProfileIdForEmail(legacyEmail, {}, assertConnection) : undefined);
|
||||
assertConnection();
|
||||
const profile = reference ? await prepareUserProfileRoleAuthority(reference) : undefined;
|
||||
assertConnection();
|
||||
// Bind the alias after authority capture to reject relinking between the two reads.
|
||||
if (
|
||||
legacyEmail &&
|
||||
(await ensureProfileIdForEmail(legacyEmail, {}, assertConnection)) !== profile?.profileId
|
||||
) {
|
||||
throw new Error("Gateway requester profile changed");
|
||||
}
|
||||
if (client?.authenticatedGitHubIdentitySync) {
|
||||
return undefined;
|
||||
}
|
||||
const authenticatedUserId = client?.authenticatedUserId;
|
||||
if (!authenticatedUserId) {
|
||||
return undefined;
|
||||
}
|
||||
// A failed Tailscale profile snapshot must not recreate its provider login
|
||||
// through the legacy email resolver on a later self-profile request.
|
||||
if (client.authenticatedUserIsTailscaleProvider) {
|
||||
return undefined;
|
||||
}
|
||||
return ensureProfileForEmail(authenticatedUserId).id;
|
||||
const assertCurrent = () => {
|
||||
assertConnection();
|
||||
if (profile && !profile.isCurrent()) {
|
||||
throw new Error("Gateway requester profile changed");
|
||||
}
|
||||
};
|
||||
assertCurrent();
|
||||
return { profileId: profile?.profileId, assertCurrent };
|
||||
}
|
||||
|
||||
export function requireProfileMutationAccess(
|
||||
client: GatewayRequestHandlerOptions["client"],
|
||||
export async function prepareProfileMutationAccess(
|
||||
options: GatewayRequestHandlerOptions,
|
||||
profileId: string,
|
||||
respond: GatewayRequestHandlerOptions["respond"],
|
||||
): boolean {
|
||||
// These methods are write-scoped so an identified caller can edit only its own profile;
|
||||
// edits targeting any other profile remain admin-only.
|
||||
if (client?.connect.scopes?.includes(ADMIN_SCOPE)) {
|
||||
return true;
|
||||
) {
|
||||
const requester = await prepareAuthenticatedProfile(options);
|
||||
requester.assertCurrent();
|
||||
const target = options.client?.connect.scopes?.includes(ADMIN_SCOPE)
|
||||
? undefined
|
||||
: await prepareUserProfileRoleAuthority(profileId);
|
||||
const assertCurrent = () => {
|
||||
requester.assertCurrent();
|
||||
if (
|
||||
options.client?.connect.scopes?.includes(ADMIN_SCOPE) ||
|
||||
(target?.isCurrent() && requester.profileId === target.profileId)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
throw new Error("profile edits require the owning user or operator.admin");
|
||||
};
|
||||
try {
|
||||
assertCurrent();
|
||||
return assertCurrent;
|
||||
} catch (error) {
|
||||
options.respond(false, undefined, errorShape(ErrorCodes.FORBIDDEN, formatErrorMessage(error)));
|
||||
return undefined;
|
||||
}
|
||||
const authenticatedProfileId = resolveAuthenticatedProfileId(client);
|
||||
if (
|
||||
authenticatedProfileId !== undefined &&
|
||||
authenticatedProfileId === resolveUserProfileId(profileId)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
respond(
|
||||
false,
|
||||
undefined,
|
||||
errorShape(ErrorCodes.FORBIDDEN, "profile edits require the owning user or operator.admin"),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function prepareUserProfileAdministration(options: GatewayRequestHandlerOptions) {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
validateUsersSetDisplayNameResult,
|
||||
validateUsersSetRoleResult,
|
||||
} from "../../../packages/gateway-protocol/src/index.js";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import { UserProfileOwnerError } from "../../state/user-profiles-schema.js";
|
||||
import { usersHandlers } from "./users.js";
|
||||
|
||||
|
|
@ -16,10 +17,15 @@ const setAvatar = vi.hoisted(() => vi.fn());
|
|||
const setDisplayName = vi.hoisted(() => vi.fn());
|
||||
const setUserProfileRole = vi.hoisted(() => vi.fn());
|
||||
const invalidateOperatorRolePolicy = vi.hoisted(() => vi.fn());
|
||||
const ensureProfileForEmail = vi.hoisted(() => vi.fn());
|
||||
const ensureProfileIdForEmail = vi.hoisted(() => vi.fn());
|
||||
const getUserProfileDisplay = vi.hoisted(() => vi.fn());
|
||||
const getUserProfileListItem = vi.hoisted(() => vi.fn());
|
||||
const resolveUserProfileId = vi.hoisted(() => vi.fn());
|
||||
const prepareUserProfileRoleAuthority = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../../state/user-profile-email.js", () => ({ ensureProfileIdForEmail }));
|
||||
vi.mock("../../state/user-channel-identity-operations.js", () => ({
|
||||
prepareUserProfileRoleAuthority,
|
||||
}));
|
||||
|
||||
vi.mock("../../state/user-profile-writes.js", () => ({
|
||||
linkCanonicalUserProfileEmail: linkEmail,
|
||||
|
|
@ -39,11 +45,9 @@ vi.mock("../../state/user-profiles.js", async () => {
|
|||
typeof import("../../state/user-profiles-schema.js")
|
||||
>("../../state/user-profiles-schema.js");
|
||||
return {
|
||||
ensureProfileForEmail,
|
||||
getUserProfileDisplay,
|
||||
getUserProfileListItem,
|
||||
listProfiles,
|
||||
resolveUserProfileId,
|
||||
setAvatar,
|
||||
setDisplayName,
|
||||
UserProfileNotFoundError,
|
||||
|
|
@ -60,12 +64,19 @@ async function runUsersHandler(
|
|||
params: object,
|
||||
client?: object,
|
||||
context: object = {},
|
||||
options: { signal?: AbortSignal } = {},
|
||||
) {
|
||||
const respond = vi.fn();
|
||||
await expectDefined(
|
||||
usersHandlers[method],
|
||||
`${method} test invariant`,
|
||||
)({ client, context: { getRuntimeConfig: () => ({}), ...context }, params, respond } as never);
|
||||
)({
|
||||
client,
|
||||
context: { getRuntimeConfig: () => ({}), ...context },
|
||||
params,
|
||||
respond,
|
||||
...options,
|
||||
} as never);
|
||||
return respond;
|
||||
}
|
||||
|
||||
|
|
@ -91,10 +102,14 @@ describe("users gateway methods", () => {
|
|||
};
|
||||
|
||||
beforeEach(() => {
|
||||
ensureProfileForEmail.mockReset();
|
||||
ensureProfileIdForEmail.mockReset();
|
||||
getUserProfileDisplay.mockReset();
|
||||
getUserProfileListItem.mockReset();
|
||||
resolveUserProfileId.mockReset();
|
||||
prepareUserProfileRoleAuthority.mockReset();
|
||||
prepareUserProfileRoleAuthority.mockImplementation(async (profileId: string) => ({
|
||||
profileId,
|
||||
isCurrent: () => true,
|
||||
}));
|
||||
linkEmail.mockReset();
|
||||
listProfiles.mockReset();
|
||||
setAvatar.mockReset();
|
||||
|
|
@ -154,10 +169,10 @@ describe("users gateway methods", () => {
|
|||
]);
|
||||
expect(invalid).toEqual(original);
|
||||
for (const effect of [
|
||||
ensureProfileForEmail,
|
||||
ensureProfileIdForEmail,
|
||||
prepareUserProfileRoleAuthority,
|
||||
getUserProfileDisplay,
|
||||
getUserProfileListItem,
|
||||
resolveUserProfileId,
|
||||
linkEmail,
|
||||
listProfiles,
|
||||
setAvatar,
|
||||
|
|
@ -178,7 +193,7 @@ describe("users gateway methods", () => {
|
|||
});
|
||||
|
||||
it("creates and returns the caller's profile idempotently", async () => {
|
||||
ensureProfileForEmail.mockReturnValue({ id: profile.id });
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
getUserProfileListItem.mockReturnValue(profile);
|
||||
|
||||
const first = await runUsersHandler("users.self", {}, selfClient);
|
||||
|
|
@ -187,8 +202,6 @@ describe("users gateway methods", () => {
|
|||
expect(first).toHaveBeenCalledWith(true, { profile });
|
||||
expect(second).toHaveBeenCalledWith(true, { profile });
|
||||
expect(validateUsersSelfResult(first.mock.calls[0]?.[1])).toBe(true);
|
||||
expect(ensureProfileForEmail).toHaveBeenNthCalledWith(1, "ada@example.com");
|
||||
expect(ensureProfileForEmail).toHaveBeenNthCalledWith(2, "ada@example.com");
|
||||
expect(getUserProfileListItem).toHaveBeenNthCalledWith(1, profile.id);
|
||||
expect(getUserProfileListItem).toHaveBeenNthCalledWith(2, profile.id);
|
||||
});
|
||||
|
|
@ -212,13 +225,12 @@ describe("users gateway methods", () => {
|
|||
"uses the connect-time %s profile without recreating an email alias",
|
||||
async (kind) => {
|
||||
const providerClient = connectedProfileClient(kind);
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
getUserProfileListItem.mockReturnValue({ ...profile, emails: [] });
|
||||
|
||||
const respond = await runUsersHandler("users.self", {}, providerClient);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, { profile: { ...profile, emails: [] } });
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
expect(ensureProfileIdForEmail).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -244,7 +256,6 @@ describe("users gateway methods", () => {
|
|||
}),
|
||||
);
|
||||
providerClient.authenticatedGitHubIdentitySync = authenticatedGitHubIdentitySync;
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
getUserProfileListItem.mockReturnValue(profile);
|
||||
|
||||
const pending = runUsersHandler("users.self", {}, providerClient);
|
||||
|
|
@ -276,7 +287,6 @@ describe("users gateway methods", () => {
|
|||
return { profileId: profile.id, updatedAt: profile.updatedAt };
|
||||
});
|
||||
providerClient.authenticatedGitHubIdentitySync = authenticatedGitHubIdentitySync;
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
getUserProfileListItem.mockReturnValue(profile);
|
||||
|
||||
expect(await runUsersHandler("users.self", {}, providerClient)).toHaveBeenCalledWith(
|
||||
|
|
@ -299,13 +309,13 @@ describe("users gateway methods", () => {
|
|||
authenticatedUserId: "ada@github",
|
||||
connect: { scopes: ["operator.write"] },
|
||||
};
|
||||
ensureProfileForEmail.mockReturnValue({ id: profile.id });
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
getUserProfileListItem.mockReturnValue(profile);
|
||||
|
||||
const respond = await runUsersHandler("users.self", {}, proxyClient);
|
||||
|
||||
expect(respond).toHaveBeenCalledWith(true, { profile });
|
||||
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@github");
|
||||
expect(ensureProfileIdForEmail).toHaveBeenCalledWith("ada@github", {}, expect.any(Function));
|
||||
});
|
||||
|
||||
it("does not recreate a failed Tailscale provider snapshot as an email alias", async () => {
|
||||
|
|
@ -322,7 +332,7 @@ describe("users gateway methods", () => {
|
|||
undefined,
|
||||
expect.objectContaining({ code: "UNAVAILABLE", retryable: true }),
|
||||
);
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
expect(ensureProfileIdForEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects users.self without an authenticated user", async () => {
|
||||
|
|
@ -336,7 +346,7 @@ describe("users gateway methods", () => {
|
|||
message: "users.self requires an authenticated user",
|
||||
}),
|
||||
);
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
expect(ensureProfileIdForEmail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("validates and routes email links", async () => {
|
||||
|
|
@ -652,8 +662,7 @@ describe("users gateway methods", () => {
|
|||
});
|
||||
|
||||
it("allows an identified write caller to edit its own profile", async () => {
|
||||
ensureProfileForEmail.mockReturnValue(profile);
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
setDisplayName.mockReturnValue(profile);
|
||||
setAvatar.mockReturnValue({ ok: true, value: profile });
|
||||
|
||||
|
|
@ -673,14 +682,103 @@ describe("users gateway methods", () => {
|
|||
profile,
|
||||
avatarRevision: String(profile.updatedAt),
|
||||
});
|
||||
expect(ensureProfileForEmail).toHaveBeenCalledWith("ada@example.com");
|
||||
expect(ensureProfileIdForEmail).toHaveBeenCalledWith(
|
||||
"ada@example.com",
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"unchanged",
|
||||
"disconnect",
|
||||
"request cancellation",
|
||||
"profile replacement",
|
||||
"email replacement",
|
||||
"email relink",
|
||||
"role revocation",
|
||||
] as const)("settles profile acquisition before mutation after %s", async (change) => {
|
||||
const entered = createDeferred();
|
||||
const release = createDeferred();
|
||||
const connection = new AbortController();
|
||||
const request = new AbortController();
|
||||
const client = {
|
||||
...selfClient,
|
||||
connId: "profile-requester",
|
||||
connectionSignal: connection.signal,
|
||||
authenticatedUserProfile: undefined as { profileId: string } | undefined,
|
||||
};
|
||||
let authorityCurrent = true;
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
prepareUserProfileRoleAuthority.mockImplementationOnce(async () => {
|
||||
entered.resolve();
|
||||
await release.promise;
|
||||
return { profileId: profile.id, isCurrent: () => authorityCurrent };
|
||||
});
|
||||
setDisplayName.mockReturnValue(profile);
|
||||
const pending = runUsersHandler(
|
||||
"users.setDisplayName",
|
||||
{ profileId: profile.id, displayName: "Ada Lovelace" },
|
||||
client,
|
||||
{},
|
||||
{ signal: request.signal },
|
||||
);
|
||||
try {
|
||||
await Promise.race([entered.promise, pending]);
|
||||
expect(prepareUserProfileRoleAuthority).toHaveBeenCalled();
|
||||
expect(setDisplayName).not.toHaveBeenCalled();
|
||||
if (change === "disconnect") {
|
||||
connection.abort();
|
||||
} else if (change === "request cancellation") {
|
||||
request.abort();
|
||||
} else if (change === "profile replacement") {
|
||||
client.authenticatedUserProfile = { profileId: "replacement-profile" };
|
||||
} else if (change === "email replacement") {
|
||||
client.authenticatedUserId = "replacement@example.test";
|
||||
} else if (change === "email relink") {
|
||||
ensureProfileIdForEmail.mockResolvedValue("replacement-profile");
|
||||
} else if (change === "role revocation") {
|
||||
authorityCurrent = false;
|
||||
}
|
||||
} finally {
|
||||
release.resolve();
|
||||
await pending;
|
||||
}
|
||||
const respond = await pending;
|
||||
if (change === "unchanged") {
|
||||
expect(respond).toHaveBeenCalledExactlyOnceWith(true, { profile });
|
||||
expect(setDisplayName).toHaveBeenCalledOnce();
|
||||
} else {
|
||||
expect(respond).toHaveBeenCalledExactlyOnceWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "UNAVAILABLE" }),
|
||||
);
|
||||
expect(setDisplayName).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it("reports rejected profile acquisition without applying an avatar mutation", async () => {
|
||||
ensureProfileIdForEmail.mockRejectedValueOnce(new Error("profile worker unavailable"));
|
||||
|
||||
const respond = await runUsersHandler(
|
||||
"users.setAvatar",
|
||||
{ profileId: profile.id, mime: "image/png", avatarBase64: "AQ==" },
|
||||
selfClient,
|
||||
);
|
||||
|
||||
expect(respond).toHaveBeenCalledExactlyOnceWith(
|
||||
false,
|
||||
undefined,
|
||||
expect.objectContaining({ code: "UNAVAILABLE", message: "profile worker unavailable" }),
|
||||
);
|
||||
expect(setAvatar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["provider", "owner"])(
|
||||
"authorizes %s profile edits from the connect-time profile id",
|
||||
async (kind) => {
|
||||
const providerClient = connectedProfileClient(kind);
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
setDisplayName.mockReturnValue(profile);
|
||||
|
||||
expect(
|
||||
|
|
@ -690,13 +788,12 @@ describe("users gateway methods", () => {
|
|||
providerClient,
|
||||
),
|
||||
).toHaveBeenCalledWith(true, { profile });
|
||||
expect(ensureProfileForEmail).not.toHaveBeenCalled();
|
||||
expect(ensureProfileIdForEmail).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("denies an identified write caller changing another profile's avatar", async () => {
|
||||
ensureProfileForEmail.mockReturnValue(profile);
|
||||
resolveUserProfileId.mockReturnValue("profile-2");
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
|
||||
expect(
|
||||
await runUsersHandler(
|
||||
|
|
@ -716,8 +813,11 @@ describe("users gateway methods", () => {
|
|||
});
|
||||
|
||||
it("allows an owner to edit through a tombstoned durable profile id", async () => {
|
||||
ensureProfileForEmail.mockReturnValue(profile);
|
||||
resolveUserProfileId.mockReturnValue(profile.id);
|
||||
ensureProfileIdForEmail.mockResolvedValue(profile.id);
|
||||
prepareUserProfileRoleAuthority.mockResolvedValue({
|
||||
profileId: profile.id,
|
||||
isCurrent: () => true,
|
||||
});
|
||||
setDisplayName.mockReturnValue(profile);
|
||||
|
||||
expect(
|
||||
|
|
@ -727,6 +827,6 @@ describe("users gateway methods", () => {
|
|||
selfClient,
|
||||
),
|
||||
).toHaveBeenCalledWith(true, { profile });
|
||||
expect(resolveUserProfileId).toHaveBeenCalledWith("merged-profile-1");
|
||||
expect(prepareUserProfileRoleAuthority).toHaveBeenCalledWith("merged-profile-1");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -48,9 +48,9 @@ import { usersChannelIdentityHandlers } from "./users-channel-identities.js";
|
|||
import { usersGitHubHandlers } from "./users-github.js";
|
||||
import { usersPersonalFileHandlers } from "./users-personal-file.js";
|
||||
import {
|
||||
prepareAuthenticatedProfile,
|
||||
prepareProfileMutationAccess,
|
||||
prepareUserProfileAdministration,
|
||||
requireProfileMutationAccess,
|
||||
resolveAuthenticatedProfileId,
|
||||
} from "./users-profile-access.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
|
||||
|
|
@ -108,7 +108,8 @@ export const usersHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
respond(true, { profiles: await listProfiles() });
|
||||
},
|
||||
"users.self": async ({ client, params, respond }) => {
|
||||
"users.self": async (options) => {
|
||||
const { client, params, respond } = options;
|
||||
if (!assertValidParams(params, validateUsersSelfParams, "users.self", respond)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -128,7 +129,9 @@ export const usersHandlers: GatewayRequestHandlers = {
|
|||
// A previously attached immutable profile stays usable; unresolved aliases stay hidden.
|
||||
}
|
||||
}
|
||||
const profileId = resolveAuthenticatedProfileId(client);
|
||||
const profile = await prepareAuthenticatedProfile(options);
|
||||
profile.assertCurrent();
|
||||
const profileId = profile.profileId;
|
||||
if (!profileId) {
|
||||
respond(false, undefined, authenticatedProfileUnavailableError());
|
||||
return;
|
||||
|
|
@ -282,16 +285,19 @@ export const usersHandlers: GatewayRequestHandlers = {
|
|||
respond(false, undefined, profileError(error));
|
||||
}
|
||||
},
|
||||
"users.setDisplayName": ({ client, context, params, respond }) => {
|
||||
"users.setDisplayName": async (options) => {
|
||||
const { context, params, respond } = options;
|
||||
if (
|
||||
!assertValidParams(params, validateUsersSetDisplayNameParams, "users.setDisplayName", respond)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (!requireProfileMutationAccess(client, params.profileId, respond)) {
|
||||
const assertCurrent = await prepareProfileMutationAccess(options, params.profileId);
|
||||
if (!assertCurrent) {
|
||||
return;
|
||||
}
|
||||
assertCurrent();
|
||||
const profile = setDisplayName(params.profileId, params.displayName);
|
||||
refreshConnectedProfile(context, profile);
|
||||
respond(true, { profile });
|
||||
|
|
@ -334,7 +340,8 @@ export const usersHandlers: GatewayRequestHandlers = {
|
|||
respond(false, undefined, profileError(error));
|
||||
}
|
||||
},
|
||||
"users.setAvatar": ({ client, context, params, respond }) => {
|
||||
"users.setAvatar": async (options) => {
|
||||
const { context, params, respond } = options;
|
||||
if (!assertValidParams(params, validateUsersSetAvatarParams, "users.setAvatar", respond)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -349,9 +356,11 @@ export const usersHandlers: GatewayRequestHandlers = {
|
|||
}
|
||||
const bytes = Buffer.from(avatarBase64, "base64");
|
||||
try {
|
||||
if (!requireProfileMutationAccess(client, params.profileId, respond)) {
|
||||
const assertCurrent = await prepareProfileMutationAccess(options, params.profileId);
|
||||
if (!assertCurrent) {
|
||||
return;
|
||||
}
|
||||
assertCurrent();
|
||||
const result = setAvatar(params.profileId, bytes, params.mime);
|
||||
if (!result.ok) {
|
||||
respond(false, undefined, errorShape(ErrorCodes.INVALID_REQUEST, result.error.code));
|
||||
|
|
|
|||
|
|
@ -8,10 +8,16 @@ import type { GatewayRequestHandlerOptions } from "./types.js";
|
|||
const mocks = vi.hoisted(() => ({
|
||||
prepare: vi.fn(),
|
||||
profile: vi.fn(),
|
||||
assertProfile: vi.fn(),
|
||||
providers: [] as PluginWebSearchProviderEntry[],
|
||||
beforeImport: vi.fn<() => Promise<void>>(),
|
||||
}));
|
||||
vi.mock("./users-profile-access.js", () => ({ resolveAuthenticatedProfileId: mocks.profile }));
|
||||
vi.mock("./users-profile-access.js", () => ({
|
||||
prepareAuthenticatedProfile: async () => ({
|
||||
profileId: mocks.profile(),
|
||||
assertCurrent: mocks.assertProfile,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./web-search-status.js", () => ({ prepareWebSearchStatus: mocks.prepare }));
|
||||
vi.mock("../../plugins/plugin-registry-contributions.js", () => ({
|
||||
resolveManifestContractOwnerPluginId: () => "parallel",
|
||||
|
|
@ -58,6 +64,7 @@ beforeEach(() => {
|
|||
vi.clearAllMocks();
|
||||
mocks.beforeImport.mockResolvedValue(undefined);
|
||||
mocks.profile.mockReturnValue("original-profile");
|
||||
mocks.assertProfile.mockReset();
|
||||
config = {
|
||||
tools: { web: { search: { provider: "parallel", cacheTtlMinutes: 0 } } },
|
||||
plugins: {
|
||||
|
|
@ -112,7 +119,9 @@ describe("Search settings live provider authority", () => {
|
|||
if (loss === "client") {
|
||||
options.client!.invalidated = true;
|
||||
} else if (loss === "profile") {
|
||||
mocks.profile.mockReturnValue("replacement-profile");
|
||||
mocks.assertProfile.mockImplementation(() => {
|
||||
throw new Error("profile authority changed");
|
||||
});
|
||||
} else {
|
||||
config = { tools: { web: { search: { enabled: false } } } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,8 +8,14 @@ const mocks = vi.hoisted(() => ({
|
|||
search: vi.fn(),
|
||||
assertSecret: vi.fn(),
|
||||
profile: vi.fn(),
|
||||
assertProfile: vi.fn(),
|
||||
}));
|
||||
vi.mock("./users-profile-access.js", () => ({
|
||||
prepareAuthenticatedProfile: async () => ({
|
||||
profileId: mocks.profile(),
|
||||
assertCurrent: mocks.assertProfile,
|
||||
}),
|
||||
}));
|
||||
vi.mock("./users-profile-access.js", () => ({ resolveAuthenticatedProfileId: mocks.profile }));
|
||||
vi.mock("./web-search-status.js", () => ({ prepareWebSearchStatus: mocks.prepare }));
|
||||
vi.mock("../../web-search/runtime.js", () => ({ runWebSearch: mocks.search }));
|
||||
vi.mock("../../secrets/runtime-degraded-state.js", () => ({
|
||||
|
|
@ -38,6 +44,7 @@ async function invoke(options: GatewayRequestHandlerOptions) {
|
|||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.profile.mockReturnValue(undefined);
|
||||
mocks.assertProfile.mockReset();
|
||||
config = { tools: { web: { search: { provider: "example", cacheTtlMinutes: 15 } } } };
|
||||
searchStatus = {
|
||||
enabled: true,
|
||||
|
|
@ -175,7 +182,9 @@ describe("Search settings Gateway boundary", () => {
|
|||
it("does not start a provider test after the authenticated account changes", async () => {
|
||||
mocks.profile.mockReturnValue("original");
|
||||
mocks.prepare.mockImplementationOnce(async () => {
|
||||
mocks.profile.mockReturnValue("replacement");
|
||||
mocks.assertProfile.mockImplementation(() => {
|
||||
throw new Error("profile authority changed");
|
||||
});
|
||||
return { status: searchStatus, config, agentDir: "/synthetic/agent" };
|
||||
});
|
||||
expect(await invoke(request("webSearch.test", { query: "query" }))).toHaveBeenCalledWith(
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { assertSecretOwnerAvailable } from "../../secrets/runtime-degraded-state
|
|||
import { runtimeWebSecretOwnerId } from "../../secrets/runtime-web-secret-owner.js";
|
||||
import { runWebSearch } from "../../web-search/runtime.js";
|
||||
import type { GatewayRequestHandlerOptions, GatewayRequestHandlers } from "./types.js";
|
||||
import { resolveAuthenticatedProfileId } from "./users-profile-access.js";
|
||||
import { prepareAuthenticatedProfile } from "./users-profile-access.js";
|
||||
import { assertValidParams } from "./validation.js";
|
||||
import { prepareWebSearchStatus } from "./web-search-status.js";
|
||||
|
||||
|
|
@ -76,14 +76,15 @@ export const webSearchHandlers: GatewayRequestHandlers = {
|
|||
);
|
||||
return;
|
||||
}
|
||||
const requesterProfileId = resolveAuthenticatedProfileId(options.client);
|
||||
try {
|
||||
const prepared = await prepareWebSearchStatus(context, params, requesterProfileId);
|
||||
const requester = await prepareAuthenticatedProfile(options);
|
||||
requester.assertCurrent();
|
||||
const prepared = await prepareWebSearchStatus(context, params, requester.profileId);
|
||||
requester.assertCurrent();
|
||||
if (prepared.error) {
|
||||
respond(false, undefined, prepared.error);
|
||||
} else if (
|
||||
!hasSearchAuthority(options, "read") ||
|
||||
resolveAuthenticatedProfileId(options.client) !== requesterProfileId ||
|
||||
context.getRuntimeConfig() !== prepared.config
|
||||
) {
|
||||
respond(
|
||||
|
|
@ -130,18 +131,20 @@ export const webSearchHandlers: GatewayRequestHandlers = {
|
|||
);
|
||||
return;
|
||||
}
|
||||
const requesterProfileId = resolveAuthenticatedProfileId(options.client);
|
||||
try {
|
||||
const prepared = await prepareWebSearchStatus(context, params, requesterProfileId);
|
||||
const requester = await prepareAuthenticatedProfile(options);
|
||||
requester.assertCurrent();
|
||||
const prepared = await prepareWebSearchStatus(context, params, requester.profileId);
|
||||
requester.assertCurrent();
|
||||
if (prepared.error) {
|
||||
respond(false, undefined, prepared.error);
|
||||
return;
|
||||
}
|
||||
const { status, config, agentDir } = prepared;
|
||||
const hasCurrentAuthority = () =>
|
||||
hasSearchAuthority(options, "admin") &&
|
||||
resolveAuthenticatedProfileId(options.client) === requesterProfileId &&
|
||||
context.getRuntimeConfig() === config;
|
||||
const hasCurrentAuthority = () => {
|
||||
requester.assertCurrent();
|
||||
return hasSearchAuthority(options, "admin") && context.getRuntimeConfig() === config;
|
||||
};
|
||||
if (!hasCurrentAuthority()) {
|
||||
respond(
|
||||
false,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import path from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../test/helpers/promise.js";
|
||||
import { awaitGateBeforeSettlement, createDeferred } from "../../test/helpers/promise.js";
|
||||
import { acceptCompactionSuccessor } from "../agents/embedded-agent-runner/compaction-successor.js";
|
||||
import {
|
||||
applySessionEntryLifecycleMutation,
|
||||
|
|
@ -134,13 +134,19 @@ it("keeps concurrent draft and saved metadata reads available while another sess
|
|||
await upsertSessionEntryCore(scope, { sessionId: "selected", updatedAt: 1 });
|
||||
start();
|
||||
const metadata = { commands: [], models: [], swarmEnabled: false };
|
||||
const readerCount = 50;
|
||||
const entered = createDeferred();
|
||||
const release = createDeferred();
|
||||
let enteredReaders = 0;
|
||||
const readChatMetadata = vi.fn<GatewayRequestContext["readChatMetadata"]>(async () => {
|
||||
if (++enteredReaders === readerCount) {
|
||||
entered.resolve();
|
||||
}
|
||||
await release.promise;
|
||||
return metadata;
|
||||
});
|
||||
const context = createDirectChatContext({ readChatMetadata });
|
||||
const readers = Array.from({ length: 50 }, (_, index) => {
|
||||
const readers = Array.from({ length: readerCount }, (_, index) => {
|
||||
const respond = vi.fn<RespondFn>();
|
||||
const pending = handleChatMetadataRequest({
|
||||
req: { type: "req", id: `metadata-${index}`, method: "chat.metadata" },
|
||||
|
|
@ -154,6 +160,11 @@ it("keeps concurrent draft and saved metadata reads available while another sess
|
|||
});
|
||||
const settled = Promise.allSettled(readers.map(({ pending }) => pending));
|
||||
try {
|
||||
await awaitGateBeforeSettlement(
|
||||
entered.promise,
|
||||
Promise.race(readers.map(({ pending }) => pending)),
|
||||
"Metadata request settled before all readers entered",
|
||||
);
|
||||
expect(readChatMetadata).toHaveBeenCalledTimes(readers.length);
|
||||
await upsertSessionEntryCore(
|
||||
{ ...scope, sessionKey: "agent:main:unrelated-creation" },
|
||||
|
|
|
|||
|
|
@ -371,13 +371,16 @@ describe("Gateway automatic account dispatch authority", () => {
|
|||
expect(readUserModelAuthProfile(selected)).toBeDefined();
|
||||
release.resolve();
|
||||
}
|
||||
const response = await pending;
|
||||
if (scenario === "snapshot") {
|
||||
// Direct handlers reject here; the transport owns the error response.
|
||||
await expect(pending).rejects.toThrow(
|
||||
"Profile authority requires live state, not a discovery snapshot",
|
||||
);
|
||||
expect(transport.resolveAuth).not.toHaveBeenCalled();
|
||||
expect(response.mock.calls[0]?.[0]).toBe(false);
|
||||
expect(requests).toHaveLength(0);
|
||||
return;
|
||||
}
|
||||
const response = await pending;
|
||||
expect(transport.resolveAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ profileId: selected, lockedProfile: true }),
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue