Files
open-design/e2e/tests/collab/project-workspace-scope.test.ts
lefarcenandGitHub 67576cb0b6 fix(collab): keep workspace projects usable during outages and first open (#7027)
* fix(daemon): decouple local data plane from workspace authority

* test(e2e): align workspace authority contracts

* fix(daemon): preserve read-only team mirrors offline

* test(daemon): preserve retryable result literal

* fix(web): keep projects usable during workspace outages

* test(web): keep collaboration mounted during outages

* test(daemon): keep viewer comments local during outages

* fix(collab): open team projects during materialization

* fix(collab): keep project placeholders fail-closed
2026-08-18 11:36:14 +00:00

318 lines
12 KiB
TypeScript

// @vitest-environment node
// A project's workspace scope is durable local project attribution, not
// navigation state. `GET /api/projects/:id/workspace-scope` therefore reads the
// persisted binding without making local rendering depend on Vela. An unbound legacy
// project stays unbound even when the caller's left rail currently selects a
// Team or Personal workspace; borrowing that selection would make a tab switch
// silently retarget later runs, writes, and billing.
//
// Two boundaries are pinned here:
//
// * a project pinned to workspace X keeps answering X even when the directory
// is down or the caller's selected workspace is Y. Billing and cloud
// mutations perform their own authorization at their actual boundary.
// * an unbound project never gains a workspace from request headers, whether
// those headers name a live membership, a removed membership, or nothing.
//
// The membership directory is seeded through the daemon's real vela integration
// (`VELA_CONTROL_KEY` + `VELA_API_URL` -> `GET /api/v1/workspaces`) against a
// temporary server-level mock, and every project is created through
// `POST /api/projects`. No source-level test backdoor.
import { createServer, type Server } from 'node:http';
import { randomUUID } from 'node:crypto';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, test } from 'vitest';
import { requestJson } from '@/vitest/http';
import { createSmokeSuite } from '@/vitest/suite';
type ProjectWorkspaceScopeBody = {
scope: {
kind: 'unbound' | 'unavailable' | 'personal' | 'team';
projectId: string;
workspaceId: string | null;
visibility?: 'personal' | 'team';
context: { workspaceId: string; workspaceMemberId: string; workspaceType: string } | null;
};
};
type CreatedProject = { conversationId: string; project: { id: string; name: string } };
const PERSONAL = {
workspaceId: 'ws-scope-personal',
workspaceName: 'Scope personal',
workspaceType: 'personal' as const,
workspaceMemberId: 'mem-scope-personal',
role: 'owner' as const,
memberStatus: 'active' as const,
lifecycleState: 'active' as const,
};
const TEAM = {
workspaceId: 'ws-scope-team',
workspaceName: 'Scope team',
workspaceType: 'team' as const,
workspaceMemberId: 'mem-scope-team',
role: 'member' as const,
memberStatus: 'active' as const,
lifecycleState: 'active' as const,
};
/** A workspace the signed-in identity is NOT a member of. */
const FOREIGN_WORKSPACE_ID = 'ws-scope-foreign';
const FOREIGN_MEMBER_ID = 'mem-scope-foreign';
let directoryServer: Server;
let directoryUrl: string;
let directoryItems: Array<Record<string, unknown>>;
beforeAll(async () => {
directoryItems = [PERSONAL, TEAM];
directoryServer = createServer((req, res) => {
if (req.url?.startsWith('/api/v1/workspaces') && req.method === 'GET') {
res.writeHead(200, { 'content-type': 'application/json' });
res.end(JSON.stringify({ items: directoryItems }));
return;
}
res.writeHead(404, { 'content-type': 'application/json' });
res.end(JSON.stringify({ error: 'not found' }));
});
await new Promise<void>((resolve) => directoryServer.listen(0, '127.0.0.1', resolve));
const address = directoryServer.address();
if (address == null || typeof address === 'string') throw new Error('mock directory has no port');
directoryUrl = `http://127.0.0.1:${address.port}`;
});
afterAll(async () => {
await new Promise<void>((resolve) => directoryServer.close(() => resolve()));
});
function workspaceHeaders(input: {
workspaceId: string;
workspaceMemberId: string;
workspaceType: 'personal' | 'team';
role?: string;
}): Record<string, string> {
return {
'x-od-workspace-id': input.workspaceId,
'x-od-workspace-type': input.workspaceType,
'x-od-workspace-member-id': input.workspaceMemberId,
'x-od-workspace-role': input.role ?? 'owner',
'x-od-workspace-lifecycle-state': 'active',
'x-od-workspace-member-status': 'active',
'x-od-workspace-can-share-projects': 'true',
'x-od-workspace-can-write-synced-files': 'true',
};
}
async function createProject(
webUrl: string,
name: string,
headers?: Record<string, string>,
): Promise<string> {
const created = await requestJson<CreatedProject>(webUrl, '/api/projects', {
body: {
designSystemId: null,
id: randomUUID(),
metadata: { kind: 'prototype' },
name,
pendingPrompt: null,
skillId: null,
},
method: 'POST',
...(headers ? { headers } : {}),
});
return created.project.id;
}
async function readScope(
webUrl: string,
projectId: string,
headers?: Record<string, string>,
): Promise<ProjectWorkspaceScopeBody['scope']> {
const body = await requestJson<ProjectWorkspaceScopeBody>(
webUrl,
`/api/projects/${encodeURIComponent(projectId)}/workspace-scope`,
headers ? { headers } : {},
);
return body.scope;
}
async function readScopeStatus(
webUrl: string,
projectId: string,
headers?: Record<string, string>,
): Promise<number> {
const response = await fetch(
`${webUrl}/api/projects/${encodeURIComponent(projectId)}/workspace-scope`,
headers ? { headers } : {},
);
return response.status;
}
describe('project workspace scope is independent from the caller selection', () => {
test(
'an unbound project stays unbound while a pinned project keeps its own workspace',
{ timeout: 240_000 },
async () => {
const suite = await createSmokeSuite('collab-project-workspace-scope');
await suite.with.toolsDev(
async ({ webUrl }) => {
directoryItems = [PERSONAL, TEAM];
// --- The bug: an unbound project must not borrow the caller's TEAM
// selection.
const unboundForTeam = await createProject(webUrl, 'Scope unbound for team');
expect(
(await readScope(webUrl, unboundForTeam)).kind,
'created headerless, so the daemon has no binding for it',
).toBe('unbound');
const teamScope = await readScope(
webUrl,
unboundForTeam,
workspaceHeaders(TEAM),
);
expect(teamScope.kind).toBe('unbound');
expect(teamScope.workspaceId).toBeNull();
expect(teamScope.context).toBeNull();
// --- Same invariant for a Personal selection.
const unboundForPersonal = await createProject(webUrl, 'Scope unbound for personal');
const personalScope = await readScope(
webUrl,
unboundForPersonal,
workspaceHeaders(PERSONAL),
);
expect(personalScope.kind).toBe('unbound');
expect(personalScope.workspaceId).toBeNull();
expect(personalScope.context).toBeNull();
// --- BOUNDARY 1: bind while the member is authoritative, then remove
// that membership. The project stays pinned to its own workspace.
// The HTTP data plane fails closed before exposing that binding to an
// unrelated Team caller; it must never borrow TEAM or TEAM's member id.
directoryItems = [
PERSONAL,
TEAM,
{
workspaceId: FOREIGN_WORKSPACE_ID,
workspaceName: 'Scope foreign',
workspaceType: 'team',
workspaceMemberId: FOREIGN_MEMBER_ID,
role: 'owner',
memberStatus: 'active',
lifecycleState: 'active',
},
];
const pinnedElsewhere = await createProject(
webUrl,
'Scope pinned elsewhere',
workspaceHeaders({
workspaceId: FOREIGN_WORKSPACE_ID,
workspaceMemberId: FOREIGN_MEMBER_ID,
workspaceType: 'team',
}),
);
directoryItems = [PERSONAL, TEAM];
expect(
await readScopeStatus(webUrl, pinnedElsewhere, workspaceHeaders(TEAM)),
'an unrelated live Workspace must not read the persisted foreign binding',
).toBe(403);
// --- BOUNDARY 2: unbound + no caller identity has no workspace to
// fall back to.
const unboundAnonymous = await createProject(webUrl, 'Scope unbound anonymous');
const anonymousScope = await readScope(webUrl, unboundAnonymous);
expect(anonymousScope.kind).toBe('unbound');
expect(anonymousScope.workspaceId).toBeNull();
expect(anonymousScope.context).toBeNull();
// --- BOUNDARY 3: a caller whose claimed selection is not an active
// membership cannot conjure a binding.
const unboundUnconfirmed = await createProject(webUrl, 'Scope unbound unconfirmed');
const unconfirmedScope = await readScope(
webUrl,
unboundUnconfirmed,
workspaceHeaders({
workspaceId: FOREIGN_WORKSPACE_ID,
workspaceMemberId: FOREIGN_MEMBER_ID,
workspaceType: 'team',
}),
);
expect(unconfirmedScope.kind).toBe('unbound');
expect(unconfirmedScope.workspaceId).toBeNull();
},
{
env: {
// The daemon's real vela session inputs, pointed at the mock above.
// `readVelaControlApiContext` takes the env branch first, and
// AMR_HOME redirects the config-file fallback at an empty dir, so a
// developer machine that IS signed in to production cannot leak into
// this run.
AMR_HOME: await emptyAmrHome(suite.scratchDir),
VELA_API_URL: directoryUrl,
VELA_CONTROL_KEY: 'e2e-scope-control-key',
},
},
);
},
);
test(
'a bound project keeps its local scope when the membership directory cannot be read',
{ timeout: 240_000 },
async () => {
const suite = await createSmokeSuite('collab-project-scope-directory-unreadable');
// No VELA session at all — the daemon's `fetchWorkspaceDirectory()` never
// reaches an authority. This is the steady state of a signed-out client
// and of every tools-dev runtime, so it is the COMMON `unavailable`, not
// an exotic one, and it says nothing about who owns the project.
await suite.with.toolsDev(
async ({ webUrl }) => {
const bound = await createProject(
webUrl,
'Scope directory unreadable',
workspaceHeaders(TEAM),
);
const scope = await readScope(webUrl, bound, workspaceHeaders(TEAM));
expect(scope.kind).toBe('team');
expect(scope.workspaceId).toBe(TEAM.workspaceId);
expect(scope.context).toMatchObject({
workspaceId: TEAM.workspaceId,
workspaceMemberId: TEAM.workspaceMemberId,
});
// An UNBOUND project in the same outage still has nowhere to fall
// back to: an unreadable directory cannot confirm the caller's own
// workspace either, so it stays `unbound` rather than becoming
// `unavailable` on a workspace it was never bound to.
const unbound = await createProject(webUrl, 'Scope unbound during outage');
const unboundScope = await readScope(webUrl, unbound, workspaceHeaders(TEAM));
expect(unboundScope.kind).toBe('unbound');
expect(unboundScope.workspaceId).toBeNull();
},
{ env: { AMR_HOME: await emptyAmrHome(suite.scratchDir) } },
);
},
);
});
/**
* A vela config home guaranteed to hold no session, so the daemon's
* `readVelaControlApiContext` config-file fallback cannot pick up the developer
* machine's real production login.
*/
async function emptyAmrHome(scratchDir: string): Promise<string> {
const dir = join(scratchDir, 'empty-amr-home');
await mkdir(dir, { recursive: true });
return dir;
}