mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* fix(collab): bound background shared-project pulls by manifest entry count Publishing a shared project version made every member daemon materialize the whole tree — hub push lane and catch-up sweeps alike — with no upper bound. In incident #6512 that meant a 7442-file website clone landing on every workspace member's disk, unasked. An authorize-only Vela probe now reads the version's manifest entry count BEFORE any blob transfer, and background lanes defer versions over the threshold (default 2000, OD_COLLAB_BACKGROUND_PULL_MAX_ENTRIES) to the foreground open-project pull, which never consults the guard. Deferrals are remembered per exact scope+version so sweeps never re-authorize. Fail-closed here means PULL AS BEFORE: an older bundled CLI without the probe, a countless or mismatched receipt, and any probe failure all resolve to today's behavior, so a project can never be left permanently unmaterialized. * fix(collab): bind the size probe receipt to the full pull scope The probe compared only projectId, version, and workspaceId, but a workspace holds many principals: a receipt authorized for a different owner or viewer was accepted as counted, and an oversized count there cached a deferral for a scope that was never inspected — suppressing background materialization instead of failing open as the guard promises. Reuse the ordinary pull's receipt validator rather than re-listing fields, so the probe cannot drift from the pull as the receipt contract evolves. Any mismatch now returns uncounted (pull as before).
220 lines
8.0 KiB
TypeScript
220 lines
8.0 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest';
|
|
import {
|
|
createProactiveContentPull,
|
|
type ProactiveContentPullDeps,
|
|
} from '../../src/collab/proactive-content-pull.js';
|
|
|
|
// Size guardrail for BACKGROUND shared-project content pulls (issue #6518,
|
|
// incident #6512): when a teammate publishes an oversized project (e.g. 7442
|
|
// files), the hub 'project-content-changed' push and the daemon's catch-up
|
|
// sweeps must NOT materialize the whole tree onto every member's disk. The
|
|
// module consults an injected `assessBackgroundContentPull` policy right
|
|
// before executing the content pull; 'defer' settles the work item without
|
|
// downloading, leaving materialization to the foreground open-project lane
|
|
// (routes/collab-sync.ts POST /collab/pull), which never consults the policy.
|
|
//
|
|
// Fail-closed direction is INVERTED for this policy relative to the module's
|
|
// other guards: "closed" here means PULL AS BEFORE — skipping is the new
|
|
// behavior, so any uncertainty (policy throws, no version) degrades to the
|
|
// existing pull rather than leaving a project permanently unmaterialized.
|
|
|
|
type Deps = ProactiveContentPullDeps;
|
|
|
|
function makeDeps(overrides: Partial<Deps> = {}): Deps & {
|
|
pullCalls: string[];
|
|
} {
|
|
const pullCalls: string[] = [];
|
|
const deps: Deps & { pullCalls: string[] } = {
|
|
pullCalls,
|
|
getLocalBinding: () => ({ workspaceId: 'ws-1', visibility: 'team' }),
|
|
getWorkspaceIdentity: async () => ({
|
|
workspaceId: 'ws-1',
|
|
resourceTeamId: 'team-1',
|
|
workspaceMemberId: 'wm-member',
|
|
}),
|
|
resolveSharedProjectOwner: async () => 'wm-owner',
|
|
pullSharedProject: async (target) => {
|
|
pullCalls.push(target.projectId);
|
|
return { status: 'pulled', version: target.authorizedStageInvocation?.expectedVersion ?? 3 };
|
|
},
|
|
...overrides,
|
|
};
|
|
return deps;
|
|
}
|
|
|
|
const baseEvent = { projectId: 'proj-1', workspaceId: 'ws-1', version: 3 };
|
|
|
|
describe('proactive content pull background size policy', () => {
|
|
it('skips the content pull and settles the hub event when the policy defers', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'defer' as const);
|
|
const onEventSettled = vi.fn();
|
|
const deps = makeDeps({ assessBackgroundContentPull, onEventSettled });
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.handleContentChanged(baseEvent);
|
|
|
|
expect(assessBackgroundContentPull).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
projectId: 'proj-1',
|
|
workspaceId: 'ws-1',
|
|
resourceTeamId: 'team-1',
|
|
viewerMemberId: 'wm-member',
|
|
ownerMemberId: 'wm-owner',
|
|
}),
|
|
3,
|
|
);
|
|
expect(deps.pullCalls).toEqual([]);
|
|
// Deferral is a terminal decision for the background lane: the event is
|
|
// settled, no retry timer may keep re-driving the oversized download.
|
|
expect(onEventSettled).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('pulls exactly as before when the policy allows', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'pull' as const);
|
|
const deps = makeDeps({ assessBackgroundContentPull });
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.handleContentChanged(baseEvent);
|
|
|
|
expect(deps.pullCalls).toEqual(['proj-1']);
|
|
});
|
|
|
|
it('missing-only catch-up sweep defers an oversized head without pulling', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'defer' as const);
|
|
const deps = makeDeps({
|
|
assessBackgroundContentPull,
|
|
getLocalBinding: () => null,
|
|
listSharedProjects: async () => [
|
|
{ projectId: 'proj-1', ownerMemberId: 'wm-owner' },
|
|
],
|
|
hasMaterializedProject: () => false,
|
|
publishedHead: async () => 3,
|
|
});
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.materializeMissingProjects('ws-1', 'proj-1');
|
|
|
|
expect(assessBackgroundContentPull).toHaveBeenCalled();
|
|
expect(deps.pullCalls).toEqual([]);
|
|
});
|
|
|
|
it('full catch-up sweep defers an oversized head without pulling', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'defer' as const);
|
|
const deps = makeDeps({
|
|
assessBackgroundContentPull,
|
|
listSharedProjects: async () => [
|
|
{ projectId: 'proj-1', ownerMemberId: 'wm-owner' },
|
|
],
|
|
publishedHead: async () => 3,
|
|
});
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.catchUpPublishedHeads('ws-1');
|
|
|
|
expect(assessBackgroundContentPull).toHaveBeenCalled();
|
|
expect(deps.pullCalls).toEqual([]);
|
|
});
|
|
|
|
it('repeated sweeps over a deferred head never execute the content pull', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'defer' as const);
|
|
const deps = makeDeps({
|
|
assessBackgroundContentPull,
|
|
getLocalBinding: () => null,
|
|
listSharedProjects: async () => [
|
|
{ projectId: 'proj-1', ownerMemberId: 'wm-owner' },
|
|
],
|
|
hasMaterializedProject: () => false,
|
|
publishedHead: async () => 3,
|
|
});
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.materializeMissingProjects('ws-1');
|
|
await pull.materializeMissingProjects('ws-1');
|
|
await pull.catchUpPublishedHeads('ws-1');
|
|
|
|
expect(deps.pullCalls).toEqual([]);
|
|
});
|
|
|
|
it('fails open to the existing pull when the policy itself throws', async () => {
|
|
const onError = vi.fn();
|
|
const deps = makeDeps({
|
|
onError,
|
|
assessBackgroundContentPull: async () => {
|
|
throw new Error('size probe transport down');
|
|
},
|
|
});
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.handleContentChanged(baseEvent);
|
|
|
|
expect(deps.pullCalls).toEqual(['proj-1']);
|
|
expect(onError).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not consult the policy for versionless events and pulls as before', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'defer' as const);
|
|
const deps = makeDeps({ assessBackgroundContentPull });
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.handleContentChanged({ projectId: 'proj-1', workspaceId: 'ws-1' });
|
|
|
|
expect(assessBackgroundContentPull).not.toHaveBeenCalled();
|
|
expect(deps.pullCalls).toEqual(['proj-1']);
|
|
});
|
|
|
|
it('deferral does not advance the cursor: a newer allowed version still pulls', async () => {
|
|
const assessBackgroundContentPull = vi.fn(
|
|
async (_target: unknown, version: number) =>
|
|
version >= 4 ? ('pull' as const) : ('defer' as const),
|
|
);
|
|
const deps = makeDeps({ assessBackgroundContentPull });
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.handleContentChanged(baseEvent);
|
|
expect(deps.pullCalls).toEqual([]);
|
|
|
|
await pull.handleContentChanged({ ...baseEvent, version: 4 });
|
|
expect(deps.pullCalls).toEqual(['proj-1']);
|
|
});
|
|
|
|
it('a foreground materialization settles the deferred version without re-consulting the policy', async () => {
|
|
const assessBackgroundContentPull = vi.fn(async () => 'defer' as const);
|
|
let persisted: string | null = null;
|
|
const deps = makeDeps({
|
|
assessBackgroundContentPull,
|
|
listSharedProjects: async () => [
|
|
{ projectId: 'proj-1', ownerMemberId: 'wm-owner' },
|
|
],
|
|
publishedHead: async () => 3,
|
|
materializedVersion: () => persisted,
|
|
});
|
|
const pull = createProactiveContentPull(deps);
|
|
|
|
await pull.handleContentChanged(baseEvent);
|
|
expect(deps.pullCalls).toEqual([]);
|
|
expect(assessBackgroundContentPull).toHaveBeenCalledTimes(1);
|
|
|
|
// The user opens the project: the foreground lane (POST /collab/pull)
|
|
// materializes version 3 and reports it back exactly the way server.ts
|
|
// wires onLegacyPullMaterialized -> observeMaterialized.
|
|
persisted = '3';
|
|
await pull.observeMaterialized(
|
|
{
|
|
projectId: 'proj-1',
|
|
workspaceId: 'ws-1',
|
|
resourceTeamId: 'team-1',
|
|
viewerMemberId: 'wm-member',
|
|
ownerMemberId: 'wm-owner',
|
|
},
|
|
3,
|
|
);
|
|
|
|
await pull.catchUpPublishedHeads('ws-1');
|
|
await pull.handleContentChanged(baseEvent);
|
|
|
|
// Cursor satisfaction wins before the policy: no new probe, no pull.
|
|
expect(assessBackgroundContentPull).toHaveBeenCalledTimes(1);
|
|
expect(deps.pullCalls).toEqual([]);
|
|
});
|
|
});
|