mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
fix(collab): 别把 daemon 已确认的个人项目当成共享只读 (OPEND-2624)
`sharedReadOnly` 里的 `unknownStatusReadOnly` 是一条 fail-closed 且**没有出口** 的判据。`statusUnknown` 的条件是 `syncState === null`,而 `CollabClient.get()` 对任何非 2xx 直接 throw、`pollStatus` 吞掉异常 —— 所以 `/collab/status` 只要对 这一个项目持续失败,`syncState` 就永远停在 null。 唯一的解药 `knownUnshared` 读 `cachedTeamProjects(context)`,而那个缓存的 key 含 `role`。实测两个端点的 role 不一致:`/api/workspace/context` 给 `owner` (目录里的真实角色),而 `/api/projects/:id/workspace-scope` 是 `project-workspace-scope.ts:65` **硬编码的 `member`**。两个 key 不同 ⇒ **只要你是 workspace 的 owner/admin,这个缓存必然 miss**,历史上加过的两条 「别 fail-closed」补丁对 owner 全部失效,一次 status 失败就是永久只读: 聊天、新建、上传、编辑、导出全灭。 「为什么同工作区其他个人项目正常」也是这条解释的:缓存 key 不匹配是工作区级的, 但只有在 status 失败时才致命 —— 其他项目 status 正常返回 local_only 就没事。 改法是给判据一个出口:daemon 已经确认这个项目是 `personal` 时,让它压过一次 拿不到的 `/collab/status`。这不是新规则 —— `ProjectWorkspaceScope.visibility` 的契约注释原文就是「visibility answers whether the project itself is a private draft or shared with the team」,而 `materializePulledTeamMirror` 对每一个拉下来 的 team mirror 都写 `visibility: 'team'`,所以本机的 `personal` 严格意味着 「这台 daemon 没有任何记录说它是谁的共享项目」。daemon 的写闸读的正是这一行, 所以这是让 UI 的只读闸和执行闸用同一个权威。工作区级冻结和 materializationPending 两条保守路径原样未动。 红测的夹具把壳层 context(owner)和项目 context(member)**拆开**喂 —— 既有的 `use-project-collab.context-seed.test.tsx` 用同一个 `role: 'member'` 对象喂进去, 所以它天然看不见这个缺陷。403 响应体是从跑着的 daemon 上 curl 实测抄来的。
This commit is contained in:
@@ -3,6 +3,7 @@ import type {
|
|||||||
CollabMemberRole,
|
CollabMemberRole,
|
||||||
CollabPresenceMember,
|
CollabPresenceMember,
|
||||||
ProjectContentTransferState,
|
ProjectContentTransferState,
|
||||||
|
ProjectVisibility,
|
||||||
WorkspaceCollabContext,
|
WorkspaceCollabContext,
|
||||||
} from '@open-design/contracts';
|
} from '@open-design/contracts';
|
||||||
import { resolveCollabSession } from './collab-session';
|
import { resolveCollabSession } from './collab-session';
|
||||||
@@ -104,6 +105,14 @@ export interface UseProjectCollabOptions {
|
|||||||
* first paint until this hook completes its own status check.
|
* first paint until this hook completes its own status check.
|
||||||
*/
|
*/
|
||||||
initialMaterializationPending?: boolean;
|
initialMaterializationPending?: boolean;
|
||||||
|
/**
|
||||||
|
* `ProjectWorkspaceScope.visibility` for this exact project, as answered by
|
||||||
|
* `GET /api/projects/:id/workspace-scope`. See
|
||||||
|
* {@link projectIsDaemonConfirmedPersonal} for why the single-writer gate
|
||||||
|
* consumes it. Null/omitted while the scope read is pending, or for a legacy
|
||||||
|
* unbound project that has no workspace row at all.
|
||||||
|
*/
|
||||||
|
projectVisibility?: ProjectVisibility | null;
|
||||||
/** Injectable for tests. */
|
/** Injectable for tests. */
|
||||||
fetch?: typeof fetch;
|
fetch?: typeof fetch;
|
||||||
baseUrl?: string;
|
baseUrl?: string;
|
||||||
@@ -238,6 +247,38 @@ export interface ProjectCollab {
|
|||||||
applyContentTransferState?: (state: ProjectContentTransferState) => void;
|
applyContentTransferState?: (state: ProjectContentTransferState) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the daemon has already answered that this project is a private
|
||||||
|
* draft — nobody's team share, therefore nobody else's single-writer project.
|
||||||
|
*
|
||||||
|
* `ProjectWorkspaceScope.visibility` is the `workspace_projects` row itself:
|
||||||
|
* the row a share/unshare mutation writes, and the row the daemon's own write
|
||||||
|
* gate reads before it allows a file write, rename, or run. It is also the
|
||||||
|
* only surface that can answer `personal` — `materializePulledTeamMirror`
|
||||||
|
* stamps `visibility: 'team'` on every pulled mirror AND on the placeholder it
|
||||||
|
* creates before the first pull, so a project someone else shared is never
|
||||||
|
* recorded personal on this daemon.
|
||||||
|
*
|
||||||
|
* That makes it the right authority for the single-writer gate, and it must
|
||||||
|
* outrank `/collab/status`. Status has to consult the remote hub catalog for
|
||||||
|
* ownership, so it can fail (403/404/5xx, or simply never answer), and its
|
||||||
|
* catalog read is served from a stale-while-revalidate + persisted snapshot
|
||||||
|
* cache. The gate below used to fall back to the browser's team-project
|
||||||
|
* catalog cache in that window, which for a workspace owner/admin never
|
||||||
|
* matches: that cache is keyed by the full workspace identity, and the
|
||||||
|
* project-scope context carries the daemon's hardcoded `role: 'member'` while
|
||||||
|
* the shell context that filled the cache carries the member's REAL role. With
|
||||||
|
* no catalog and no status the gate failed closed forever, and the creator of
|
||||||
|
* a personal, local-only project was shown 「这是共享项目」 with Chat, upload,
|
||||||
|
* editing and export all disabled while the daemon would have accepted every
|
||||||
|
* one of those writes (OPEND-2624).
|
||||||
|
*/
|
||||||
|
export function projectIsDaemonConfirmedPersonal(
|
||||||
|
visibility: ProjectVisibility | null | undefined,
|
||||||
|
): boolean {
|
||||||
|
return visibility === 'personal';
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveProjectWriterAuthority(options: {
|
export function resolveProjectWriterAuthority(options: {
|
||||||
workspaceReadOnly: boolean;
|
workspaceReadOnly: boolean;
|
||||||
workspaceContextReadOnly: boolean;
|
workspaceContextReadOnly: boolean;
|
||||||
@@ -246,12 +287,16 @@ export function resolveProjectWriterAuthority(options: {
|
|||||||
isOwner: boolean;
|
isOwner: boolean;
|
||||||
knownOwnedByViewer: boolean;
|
knownOwnedByViewer: boolean;
|
||||||
createdByViewerThisSession: boolean;
|
createdByViewerThisSession: boolean;
|
||||||
|
daemonConfirmedPersonal?: boolean;
|
||||||
materializationPending?: boolean;
|
materializationPending?: boolean;
|
||||||
syncState: ProjectCollab['syncState'];
|
syncState: ProjectCollab['syncState'];
|
||||||
}): ProjectCollab['writerAuthority'] {
|
}): ProjectCollab['writerAuthority'] {
|
||||||
if (options.workspaceReadOnly || options.lostAccessAfterUnshare) return 'denied';
|
if (options.workspaceReadOnly || options.lostAccessAfterUnshare) return 'denied';
|
||||||
if (options.materializationPending) return 'pending';
|
if (options.materializationPending) return 'pending';
|
||||||
if (options.workspaceContextReadOnly) return 'pending';
|
if (options.workspaceContextReadOnly) return 'pending';
|
||||||
|
// The project's own workspace row already settled this: a private draft has
|
||||||
|
// exactly one writer and it is whoever is looking at it.
|
||||||
|
if (options.daemonConfirmedPersonal) return 'allowed';
|
||||||
// A settled daemon status outranks every provisional browser-side witness.
|
// A settled daemon status outranks every provisional browser-side witness.
|
||||||
// Catalog ownership and same-session creation exist only to bridge the
|
// Catalog ownership and same-session creation exist only to bridge the
|
||||||
// UNKNOWN window; once status names another writer they must not keep write
|
// UNKNOWN window; once status names another writer they must not keep write
|
||||||
@@ -305,6 +350,9 @@ export function useProjectCollab(
|
|||||||
...(options.statusPollMs !== undefined ? { statusPollMs: options.statusPollMs } : {}),
|
...(options.statusPollMs !== undefined ? { statusPollMs: options.statusPollMs } : {}),
|
||||||
});
|
});
|
||||||
const workspaceIdentity = workspaceIdentityCacheKey(context);
|
const workspaceIdentity = workspaceIdentityCacheKey(context);
|
||||||
|
const daemonConfirmedPersonal = projectIsDaemonConfirmedPersonal(
|
||||||
|
options.projectVisibility,
|
||||||
|
);
|
||||||
// Gate 1 (workspace-level): a non-writable workspace (locked/frozen billing or
|
// Gate 1 (workspace-level): a non-writable workspace (locked/frozen billing or
|
||||||
// a removed member) freezes everyone — consume B's `canWriteSyncedFiles` bit
|
// a removed member) freezes everyone — consume B's `canWriteSyncedFiles` bit
|
||||||
// rather than re-deriving from lifecycle so the two lanes cannot drift.
|
// rather than re-deriving from lifecycle so the two lanes cannot drift.
|
||||||
@@ -421,7 +469,8 @@ export function useProjectCollab(
|
|||||||
confirmedOwnedBySomeoneElseRef.current = relationshipScopeKey;
|
confirmedOwnedBySomeoneElseRef.current = relationshipScopeKey;
|
||||||
}
|
}
|
||||||
const lostAccessAfterUnshare =
|
const lostAccessAfterUnshare =
|
||||||
confirmedOwnedBySomeoneElseRef.current === relationshipScopeKey
|
!daemonConfirmedPersonal
|
||||||
|
&& confirmedOwnedBySomeoneElseRef.current === relationshipScopeKey
|
||||||
&& collab.syncState === 'local_only'
|
&& collab.syncState === 'local_only'
|
||||||
&& !isOwner;
|
&& !isOwner;
|
||||||
// The project-level (single-writer) gate. Catalog ownership and the
|
// The project-level (single-writer) gate. Catalog ownership and the
|
||||||
@@ -434,7 +483,8 @@ export function useProjectCollab(
|
|||||||
&& !knownOwnedByViewer
|
&& !knownOwnedByViewer
|
||||||
&& !createdByViewerThisSession;
|
&& !createdByViewerThisSession;
|
||||||
const sharedReadOnly =
|
const sharedReadOnly =
|
||||||
unknownStatusReadOnly || (shared && !isOwner) || lostAccessAfterUnshare;
|
!daemonConfirmedPersonal
|
||||||
|
&& (unknownStatusReadOnly || (shared && !isOwner) || lostAccessAfterUnshare);
|
||||||
const viewerOnly = workspaceContextReadOnly || workspaceReadOnly || sharedReadOnly;
|
const viewerOnly = workspaceContextReadOnly || workspaceReadOnly || sharedReadOnly;
|
||||||
const materializationPending =
|
const materializationPending =
|
||||||
collab.awaitingFirstMaterialization
|
collab.awaitingFirstMaterialization
|
||||||
@@ -450,6 +500,7 @@ export function useProjectCollab(
|
|||||||
isOwner,
|
isOwner,
|
||||||
knownOwnedByViewer,
|
knownOwnedByViewer,
|
||||||
createdByViewerThisSession,
|
createdByViewerThisSession,
|
||||||
|
daemonConfirmedPersonal,
|
||||||
materializationPending,
|
materializationPending,
|
||||||
syncState: collab.syncState,
|
syncState: collab.syncState,
|
||||||
});
|
});
|
||||||
@@ -457,7 +508,8 @@ export function useProjectCollab(
|
|||||||
// not latch on `shared && !isOwner` while ownerMemberId is still missing from
|
// not latch on `shared && !isOwner` while ownerMemberId is still missing from
|
||||||
// an otherwise-shared status payload for the real owner.
|
// an otherwise-shared status payload for the real owner.
|
||||||
const isSharedNonOwner =
|
const isSharedNonOwner =
|
||||||
!isEffectiveOwner
|
!daemonConfirmedPersonal
|
||||||
|
&& !isEffectiveOwner
|
||||||
&& (knownOwnedBySomeoneElse
|
&& (knownOwnedBySomeoneElse
|
||||||
|| (shared && statusNamedDifferentOwner)
|
|| (shared && statusNamedDifferentOwner)
|
||||||
|| lostAccessAfterUnshare);
|
|| lostAccessAfterUnshare);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import type {
|
import type {
|
||||||
|
ProjectVisibility,
|
||||||
ProjectWorkspaceScope,
|
ProjectWorkspaceScope,
|
||||||
ProjectWorkspaceScopeResponse,
|
ProjectWorkspaceScopeResponse,
|
||||||
WorkspaceCollabContext,
|
WorkspaceCollabContext,
|
||||||
@@ -90,6 +91,20 @@ export function projectWorkspaceScopeReady(
|
|||||||
return scope?.kind === 'unbound' || scope?.kind === 'personal' || scope?.kind === 'team';
|
return scope?.kind === 'unbound' || scope?.kind === 'personal' || scope?.kind === 'team';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The daemon's own visibility verdict for a resolved project scope.
|
||||||
|
*
|
||||||
|
* Deliberately independent of `kind`: a private draft can live in a team
|
||||||
|
* workspace and still be `personal`. Null when the scope has not resolved, or
|
||||||
|
* for a legacy unbound project that has no `workspace_projects` row to be
|
||||||
|
* visible in — neither case is evidence of anything.
|
||||||
|
*/
|
||||||
|
export function projectWorkspaceVisibility(
|
||||||
|
scope: ProjectWorkspaceScope | null | undefined,
|
||||||
|
): ProjectVisibility | null {
|
||||||
|
return scope && scope.kind !== 'unbound' ? scope.visibility : null;
|
||||||
|
}
|
||||||
|
|
||||||
function activePersonalAdoptionWitness(
|
function activePersonalAdoptionWitness(
|
||||||
caller: WorkspaceCollabContext | null | undefined,
|
caller: WorkspaceCollabContext | null | undefined,
|
||||||
): WorkspaceCollabContext | null {
|
): WorkspaceCollabContext | null {
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// OPEND-2624: a personal, local-only project the viewer created must never be
|
||||||
|
// presented as "This is a shared project — you can view and comment" with
|
||||||
|
// chat/upload/edit/export disabled.
|
||||||
|
//
|
||||||
|
// Every fixture below is copied from what the daemon at
|
||||||
|
// http://127.0.0.1:17466 actually answers (0.21.1-beta line). In particular:
|
||||||
|
//
|
||||||
|
// * `/api/workspace/context` reports the member's REAL directory role
|
||||||
|
// (`owner` for a workspace owner) and `planId: null` — the workspace
|
||||||
|
// directory rows Vela hands the daemon carry no plan field, so production
|
||||||
|
// never fills it.
|
||||||
|
// * `/api/projects/:id/workspace-scope` synthesises its context through
|
||||||
|
// `resolveLocalProjectWorkspaceScope`, which hardcodes `role: 'member'`.
|
||||||
|
// So in production the SAME workspace is described with two different roles
|
||||||
|
// depending on which endpoint answered. Tests that build one context object
|
||||||
|
// and hand it to both surfaces cannot see that, which is why this fixture
|
||||||
|
// keeps the two apart.
|
||||||
|
// * `/api/workspace/projects/team` answers `{"projects": []}` for a workspace
|
||||||
|
// with nothing shared.
|
||||||
|
|
||||||
|
import { cleanup, renderHook, waitFor } from '@testing-library/react';
|
||||||
|
import {
|
||||||
|
buildWorkspacePermissions,
|
||||||
|
buildWorkspaceSeatSummary,
|
||||||
|
type WorkspaceCollabContext,
|
||||||
|
} from '@open-design/contracts';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { useProjectCollab } from '../../src/collab/useProjectCollab';
|
||||||
|
import {
|
||||||
|
resetTeamProjectsCache,
|
||||||
|
resetWorkspaceContextCache,
|
||||||
|
useTeamProjects,
|
||||||
|
useWorkspaceContext,
|
||||||
|
} from '../../src/collab/useWorkspaceContext';
|
||||||
|
|
||||||
|
const WORKSPACE_ID = 'l5hy8nbnym3pi07aasqekiz0';
|
||||||
|
const WORKSPACE_MEMBER_ID = 'dn87ohicuyq4o839pgi37op4';
|
||||||
|
const PROJECT_ID = '3c73bd04-ed0c-4aca-9a7a-85600977d5d8';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/workspace/context` — the shell/navigation authority. `role` is the
|
||||||
|
* member's real directory role; a workspace owner reads back `owner`.
|
||||||
|
*/
|
||||||
|
function shellWorkspaceContext(): WorkspaceCollabContext {
|
||||||
|
const role = 'owner' as const;
|
||||||
|
const lifecycleState = 'active' as const;
|
||||||
|
return {
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceType: 'team',
|
||||||
|
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||||
|
role,
|
||||||
|
memberStatus: 'active',
|
||||||
|
lifecycleState,
|
||||||
|
billingState: 'active',
|
||||||
|
// Directory rows carry no plan; production always answers null here.
|
||||||
|
planId: null,
|
||||||
|
providerMode: 'platform_credits',
|
||||||
|
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }),
|
||||||
|
permissions: buildWorkspacePermissions({ role, lifecycleState }),
|
||||||
|
teamId: WORKSPACE_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `GET /api/projects/:id/workspace-scope` — the project-bound authority
|
||||||
|
* ProjectView actually hands to `useProjectCollab`. Its role is hardcoded to
|
||||||
|
* `member` by `resolveLocalProjectWorkspaceScope`, independent of the member's
|
||||||
|
* real workspace role.
|
||||||
|
*/
|
||||||
|
function projectScopeContext(): WorkspaceCollabContext {
|
||||||
|
const role = 'member' as const;
|
||||||
|
const lifecycleState = 'active' as const;
|
||||||
|
return {
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceType: 'team',
|
||||||
|
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||||
|
role,
|
||||||
|
memberStatus: 'active',
|
||||||
|
lifecycleState,
|
||||||
|
billingState: 'active',
|
||||||
|
planId: null,
|
||||||
|
providerMode: 'platform_credits',
|
||||||
|
seatSummary: buildWorkspaceSeatSummary({ seatLimit: 0, usedSeats: 0 }),
|
||||||
|
permissions: buildWorkspacePermissions({ role, lifecycleState }),
|
||||||
|
teamId: WORKSPACE_ID,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stable identity, like production: ProjectView memoises the project's
|
||||||
|
* resolved scope context. A fresh object every render would restart the
|
||||||
|
* hook's context effect on every commit.
|
||||||
|
*/
|
||||||
|
const PROJECT_SCOPE_CONTEXT = projectScopeContext();
|
||||||
|
|
||||||
|
function jsonResponse(body: unknown, status = 200): Response {
|
||||||
|
return {
|
||||||
|
ok: status >= 200 && status < 300,
|
||||||
|
status,
|
||||||
|
json: async () => body,
|
||||||
|
} as unknown as Response;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shell reads succeed exactly as production does, so the caches warm. */
|
||||||
|
function installShellFetch(): void {
|
||||||
|
globalThis.fetch = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const pathname = new URL(String(input), 'http://d.local').pathname;
|
||||||
|
if (pathname.endsWith('/workspace/directory')) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: 'OD E2E Team',
|
||||||
|
workspaceType: 'team',
|
||||||
|
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||||
|
role: 'owner',
|
||||||
|
memberStatus: 'active',
|
||||||
|
lifecycleState: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeWorkspaceId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/workspace/context')) {
|
||||||
|
return jsonResponse({ context: shellWorkspaceContext() });
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/workspace/projects/team')) {
|
||||||
|
return jsonResponse({ projects: [] });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
}) as typeof fetch;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function warmShellCaches(): Promise<void> {
|
||||||
|
installShellFetch();
|
||||||
|
const ctx = renderHook(() => useWorkspaceContext());
|
||||||
|
await waitFor(() => expect(ctx.result.current.loading).toBe(false));
|
||||||
|
ctx.unmount();
|
||||||
|
const team = renderHook(() => useTeamProjects());
|
||||||
|
await waitFor(() => expect(team.result.current.loading).toBe(false));
|
||||||
|
team.unmount();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shell reads keep working; only this ONE project's `/collab/status` refuses.
|
||||||
|
* That is the shape of the report: every other personal project in the same
|
||||||
|
* workspace stays editable.
|
||||||
|
*
|
||||||
|
* The body is the daemon's real refusal payload
|
||||||
|
* (`{"error":"WORKSPACE_ACCESS_DENIED", ...}` with HTTP 403).
|
||||||
|
*/
|
||||||
|
function installStatusDeniedFetch(): ReturnType<typeof vi.fn> {
|
||||||
|
const impl = vi.fn(async (input: RequestInfo | URL) => {
|
||||||
|
const pathname = new URL(String(input), 'http://d.local').pathname;
|
||||||
|
if (pathname.endsWith('/collab/status')) {
|
||||||
|
return jsonResponse(
|
||||||
|
{
|
||||||
|
error: 'WORKSPACE_ACCESS_DENIED',
|
||||||
|
message: 'the requested workspace does not own this project',
|
||||||
|
},
|
||||||
|
403,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/workspace/directory')) {
|
||||||
|
return jsonResponse({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
|
workspaceName: 'OD E2E Team',
|
||||||
|
workspaceType: 'team',
|
||||||
|
workspaceMemberId: WORKSPACE_MEMBER_ID,
|
||||||
|
role: 'owner',
|
||||||
|
memberStatus: 'active',
|
||||||
|
lifecycleState: 'active',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
activeWorkspaceId: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/workspace/context')) {
|
||||||
|
return jsonResponse({ context: shellWorkspaceContext() });
|
||||||
|
}
|
||||||
|
if (pathname.endsWith('/workspace/projects/team')) {
|
||||||
|
return jsonResponse({ projects: [] });
|
||||||
|
}
|
||||||
|
return jsonResponse({});
|
||||||
|
});
|
||||||
|
globalThis.fetch = impl as unknown as typeof fetch;
|
||||||
|
return impl;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalFetch = globalThis.fetch;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetWorkspaceContextCache();
|
||||||
|
resetTeamProjectsCache();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
globalThis.fetch = originalFetch;
|
||||||
|
resetWorkspaceContextCache();
|
||||||
|
resetTeamProjectsCache();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('OPEND-2624 personal local-only project stays writable', () => {
|
||||||
|
it('does not present a daemon-confirmed personal project as shared read-only when /collab/status refuses', async () => {
|
||||||
|
await warmShellCaches();
|
||||||
|
const fetchImpl = installStatusDeniedFetch();
|
||||||
|
|
||||||
|
const view = renderHook(() =>
|
||||||
|
useProjectCollab(PROJECT_ID, {
|
||||||
|
// Exactly what ProjectView passes: the project's own resolved scope.
|
||||||
|
workspaceContext: PROJECT_SCOPE_CONTEXT,
|
||||||
|
workspaceContextLoading: false,
|
||||||
|
// The daemon's authoritative answer for this project: a private draft.
|
||||||
|
projectVisibility: 'personal',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
fetchImpl.mock.calls.some(([input]) =>
|
||||||
|
String(input).endsWith('/collab/status'),
|
||||||
|
),
|
||||||
|
).toBe(true),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Give the failed poll a chance to settle into state.
|
||||||
|
await waitFor(() => expect(view.result.current.viewerOnly).toBe(false));
|
||||||
|
expect(view.result.current.isSharedNonOwner).toBe(false);
|
||||||
|
expect(view.result.current.writerAuthority).toBe('allowed');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user