diff --git a/apps/e2e/scripts/run-all.mjs b/apps/e2e/scripts/run-all.mjs index 71cfbd652..f9c879d89 100644 --- a/apps/e2e/scripts/run-all.mjs +++ b/apps/e2e/scripts/run-all.mjs @@ -107,8 +107,8 @@ const ALWAYS = [ }, { key: 'workspaces', - file: 'src/tests/workspaces.test.ts', - label: 'Workspaces: create, switch, settings', + file: 'src/tests/workspaces.test.ts src/tests/workspaces-sharing-limits.test.ts', + label: 'Workspaces: create, switch, settings, sharing limits', }, { key: 'sql-editor', diff --git a/apps/e2e/src/generated/test-ids.ts b/apps/e2e/src/generated/test-ids.ts index 72075c972..8123afc03 100644 --- a/apps/e2e/src/generated/test-ids.ts +++ b/apps/e2e/src/generated/test-ids.ts @@ -639,6 +639,8 @@ export const TestIds = { adminWorkspaceMembers: (id: string | number) => `admin-workspace-members-${id}`, /** button · Open */ adminWorkspaceOpen: (id: string | number) => `admin-workspace-open-${id}`, + /** button · Restore */ + adminWorkspaceRestore: (id: string | number) => `admin-workspace-restore-${id}`, /** div */ adminWorkspaces: 'admin-workspaces', /** p */ @@ -3539,6 +3541,8 @@ export const TestIds = { WorkspaceSettingsDialog: { /** li */ workspaceInvite: (id: string | number) => `workspace-invite-${id}`, + /** p */ + workspaceInviteCeiling: 'workspace-invite-ceiling', /** input · teammate@company.com */ workspaceInviteEmail: 'workspace-invite-email', /** form */ @@ -3742,6 +3746,7 @@ export type TestId = | `admin-workspace-join-${string}` | `admin-workspace-members-${string}` | `admin-workspace-open-${string}` + | `admin-workspace-restore-${string}` | 'admin-workspaces' | 'admin-workspaces-error' | 'admin-workspaces-list' @@ -5203,6 +5208,7 @@ export type TestId = | `workflow-variables-environment-${string}` | 'workflow-view' | `workspace-invite-${string}` + | 'workspace-invite-ceiling' | 'workspace-invite-email' | 'workspace-invite-form' | 'workspace-invite-new-account' diff --git a/apps/e2e/src/helpers/accounts.ts b/apps/e2e/src/helpers/accounts.ts new file mode 100644 index 000000000..de84e15e0 --- /dev/null +++ b/apps/e2e/src/helpers/accounts.ts @@ -0,0 +1,52 @@ +/** + * Fox Schema (foxschema) + * Copyright 2024-2026 Huy Phan + * SPDX-License-Identifier: Apache-2.0 + * + * Calling the API directly, and acting as a second account in a browser. + * + * The calls go to the API port, not through the Vite proxy (see app-session.ts). + */ +import type { Page } from 'playwright'; +import { buildDriver, BASE_URL } from './driver.js'; +import { byTestId } from './test-ids.js'; + +export const API_URL = process.env.E2E_API_URL ?? 'http://127.0.0.1:3210'; + +export interface ApiResult { + status: number; + json: any; +} + +/** One request to `/api` as the session in `cookie`. A body that is not JSON reads as null. */ +export async function apiCall(method: string, path: string, body: unknown, cookie: string): Promise { + const res = await fetch(`${API_URL}/api${path}`, { + method, + headers: { ...(body !== undefined ? { 'content-type': 'application/json' } : {}), cookie }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + }); + return { status: res.status, json: await res.json().catch(() => null) }; +} + +/** Signs `email` in and returns its session cookie as `name=value`. */ +export async function signInAs(email: string, password: string): Promise { + const res = await fetch(`${API_URL}/api/auth/login`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + if (res.status !== 200) throw new Error(`Signing in as ${email} failed: HTTP ${res.status}`); + return (res.headers.get('set-cookie') ?? '').split(';')[0]!; +} + +/** A browser signed in with `cookie`, past onboarding. Close it with quitDriver. */ +export async function browserAs(cookie: string): Promise { + const page = await buildDriver({ signedIn: false }); + const [name, ...rest] = cookie.split('='); + await page.context().addCookies([{ name: name!, value: rest.join('='), url: BASE_URL }]); + await page.goto(BASE_URL); + await page.waitForSelector(`${byTestId('onboarding-skip')}, ${byTestId('profile-menu-trigger')}`, { timeout: 30_000 }); + const skip = page.locator(byTestId('onboarding-skip')); + if (await skip.count()) await skip.click(); + return page; +} diff --git a/apps/e2e/src/tests/workspaces-sharing-limits.test.ts b/apps/e2e/src/tests/workspaces-sharing-limits.test.ts new file mode 100644 index 000000000..d6f9ced54 --- /dev/null +++ b/apps/e2e/src/tests/workspaces-sharing-limits.test.ts @@ -0,0 +1,139 @@ +/** + * Sharing a workspace lends no more than the sharer holds, and an archived + * workspace can come back — in a real browser against the running server. + * + * A personal workspace's account runs who is in it whatever its own role, so + * before the ceiling a viewer account could invite a colleague as owner, and + * the colleague got DDL, DML and GRANT on the viewer's saved connections. + * + * Runs as the suites' admin plus two throwaway viewer accounts, and leaves the + * admin in its own workspace, where the other suites expect it. + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { randomBytes } from 'node:crypto'; +import type { Page } from 'playwright'; +import { buildDriver, quitDriver, clickWhen, waitFor, BASE_URL } from '../helpers/driver.js'; +import { apiCall, browserAs, signInAs } from '../helpers/accounts.js'; +import { sessionCookie } from '../helpers/app-session.js'; +import { saveScreenshot } from '../helpers/screenshot.js'; +import { byTestId } from '../helpers/test-ids.js'; + +const RUN = Date.now().toString(36); +const SHARER_EMAIL = `e2e-wssharer-${RUN}@foxschema.test`; +const PEER_EMAIL = `e2e-wspeer-${RUN}@foxschema.test`; + +let admin = ''; +let adminPersonalId = ''; +let sharer = { id: '', cookie: '' }; +let peer = { id: '', cookie: '' }; +const created: string[] = []; +const pages: Page[] = []; + +const api = (method: string, path: string, body?: unknown, cookie = admin) => apiCall(method, path, body, cookie); + +/** A viewer account, signed in: its id and session cookie. */ +async function viewerAccount(email: string): Promise<{ id: string; cookie: string }> { + const password = `ws-${randomBytes(8).toString('hex')}`; + const added = await api('POST', '/admin/users', { email, password, role: 'viewer' }); + expect(added.status, JSON.stringify(added.json)).toBe(200); + return { id: added.json.user.id, cookie: await signInAs(email, password) }; +} + +/** A browser as `cookie`, closed after the suite. */ +async function browserFor(cookie: string): Promise { + const page = await browserAs(cookie); + pages.push(page); + return page; +} + +beforeAll(async () => { + admin = await sessionCookie(); + const list = await api('GET', '/workspaces'); + expect(list.status, JSON.stringify(list.json)).toBe(200); + adminPersonalId = list.json.workspaces.find((w: { personal: boolean }) => w.personal).id; + await api('POST', `/workspaces/${adminPersonalId}/select`, {}); + [sharer, peer] = await Promise.all([viewerAccount(SHARER_EMAIL), viewerAccount(PEER_EMAIL)]); +}, 120_000); + +afterAll(async () => { + for (const page of pages) await quitDriver(page); + for (const id of [sharer.id, peer.id]) if (id) await api('PUT', `/admin/users/${id}/active`, { active: false }); + for (const id of created) await api('POST', `/workspaces/${id}/archive`, {}); + await api('POST', `/workspaces/${adminPersonalId}/select`, {}); +}); + +describe('workspace sharing limits', () => { + it('a viewer sharing its own workspace is offered viewer, and the server refuses more', async () => { + const own = (await api('GET', '/workspaces', undefined, sharer.cookie)).json.workspaces.find( + (w: { personal: boolean }) => w.personal + ); + expect(own.role).toBe('viewer'); + + // The settings dialog offers what the server will accept, and says why. + const page = await browserFor(sharer.cookie); + await clickWhen(page, byTestId('workspace-rail-btn')); + await clickWhen(page, byTestId('workspace-menu-settings')); + await waitFor(page, byTestId('workspace-invite-role'), 15_000); + const offered = await page.evaluate(() => + [...(document.querySelector('[data-testid="workspace-invite-role"]') as HTMLSelectElement).options].map((o) => [ + o.value, + !o.disabled, + ]) + ); + expect(offered).toEqual([ + ['viewer', true], + ['editor', false], + ['owner', false], + ]); + expect(await page.locator(byTestId('workspace-invite-ceiling')).innerText()).toMatch(/up to viewer/); + await saveScreenshot(page, 'workspaces-sharing-ceiling'); + + // The server refuses the same, whatever the client sends. + for (const role of ['owner', 'editor']) { + const refused = await api('POST', `/workspaces/${own.id}/invites`, { email: PEER_EMAIL, role }, sharer.cookie); + expect(refused.status, `${role}: ${JSON.stringify(refused.json)}`).toBe(403); + } + const sent = await api('POST', `/workspaces/${own.id}/invites`, { email: PEER_EMAIL, role: 'viewer' }, sharer.cookie); + expect(sent.status, JSON.stringify(sent.json)).toBe(200); + + // The colleague joins as a viewer and acts as one: it can read, not write. + const accepted = await api('POST', `/workspaces/invites/${sent.json.invite.id}/accept`, {}, peer.cookie); + expect(accepted.status, JSON.stringify(accepted.json)).toBe(200); + expect((await api('POST', `/workspaces/${own.id}/select`, {}, peer.cookie)).status).toBe(200); + const me = (await api('GET', '/auth/me', undefined, peer.cookie)).json; + expect(me.workspace).toMatchObject({ id: own.id, role: 'viewer' }); + for (const p of ['editor.dml', 'editor.ddl', 'editor.grant', 'schema.migrate']) { + expect(me.user.permissions, p).not.toContain(p); + } + + // Nor can the sharer raise it afterwards. + const raised = await api('PUT', `/workspaces/${own.id}/members/${peer.id}`, { role: 'owner' }, sharer.cookie); + expect(raised.status, JSON.stringify(raised.json)).toBe(403); + }, 120_000); + + it('an admin restores an archived workspace from Access control', async () => { + const made = await api('POST', '/workspaces', { name: `e2e-restore-${RUN}` }); + expect(made.status, JSON.stringify(made.json)).toBe(200); + const id = made.json.workspace.id as string; + created.push(id); + expect((await api('POST', `/workspaces/${id}/archive`, {})).status).toBe(200); + expect((await api('GET', '/workspaces')).json.workspaces.some((w: { id: string }) => w.id === id)).toBe(false); + + const page = await buildDriver(); + pages.push(page); + await page.goto(BASE_URL); + await clickWhen(page, byTestId('profile-menu-trigger')); + await clickWhen(page, byTestId('profile-access-control')); + await clickWhen(page, byTestId('admin-tab-workspaces')); + await clickWhen(page, byTestId('admin-workspaces-show-archived')); + await clickWhen(page, byTestId(`admin-workspace-restore-${id}`)); + // Back: the row offers Members again instead of Restore. + await waitFor(page, byTestId(`admin-workspace-members-${id}`), 15_000); + expect(await page.locator(byTestId(`admin-workspace-restore-${id}`)).count()).toBe(0); + await saveScreenshot(page, 'workspaces-admin-restored'); + + expect((await api('GET', '/workspaces')).json.workspaces.find((w: { id: string }) => w.id === id)).toMatchObject({ + role: 'owner', + }); + }, 120_000); +}); diff --git a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx index 74d25b558..fca3ad3cc 100644 --- a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.test.tsx @@ -12,6 +12,7 @@ const apiAdminListWorkspaces = vi.fn(); const apiArchiveWorkspace = vi.fn(); const apiSelectWorkspace = vi.fn(); const apiSetWorkspaceMember = vi.fn(); +const apiRestoreWorkspace = vi.fn(); vi.mock('@/features/workspaces', () => ({ loadWorkspaceSettingsDialog: async () => ({ default: () => null }), @@ -19,6 +20,7 @@ vi.mock('@/features/workspaces', () => ({ apiArchiveWorkspace: (...a: unknown[]) => apiArchiveWorkspace(...a), apiSelectWorkspace: (...a: unknown[]) => apiSelectWorkspace(...a), apiSetWorkspaceMember: (...a: unknown[]) => apiSetWorkspaceMember(...a), + apiRestoreWorkspace: (...a: unknown[]) => apiRestoreWorkspace(...a), })); import { AdminWorkspacesPanel } from './AdminWorkspacesPanel'; @@ -28,7 +30,7 @@ const personal = { ...shared, id: 'ben-own', name: "ben's workspace", personalOw const joined = { ...shared, id: 'ops', name: 'Ops', adminIsMember: true }; beforeEach(() => { - for (const f of [apiAdminListWorkspaces, apiArchiveWorkspace, apiSelectWorkspace, apiSetWorkspaceMember]) f.mockReset(); + for (const f of [apiAdminListWorkspaces, apiArchiveWorkspace, apiSelectWorkspace, apiSetWorkspaceMember, apiRestoreWorkspace]) f.mockReset(); apiAdminListWorkspaces.mockResolvedValue([shared, personal, joined]); useAuthStore.setState({ user: { id: 'admin', email: 'admin@x.com', onboardingCompleted: true, role: 'admin', permissions: [] }, @@ -81,4 +83,18 @@ describe('AdminWorkspacesPanel', () => { fireEvent.click(screen.getByTestId('admin-workspaces-show-archived')); await waitFor(() => expect(apiAdminListWorkspaces).toHaveBeenLastCalledWith(true)); }); + + it('restores an archived workspace, and offers nothing else on it', async () => { + apiRestoreWorkspace.mockResolvedValue(undefined); + apiAdminListWorkspaces.mockResolvedValue([{ ...shared, archived: true }]); + render(); + await screen.findByTestId('admin-workspace-restore-books'); + for (const gone of ['join', 'open', 'members', 'archive']) { + expect(screen.queryByTestId(`admin-workspace-${gone}-books`), gone).toBeNull(); + } + fireEvent.click(screen.getByTestId('admin-workspace-restore-books')); + await waitFor(() => expect(apiRestoreWorkspace).toHaveBeenCalledWith('books')); + // And reads the list again, so the row shows it is back. + await waitFor(() => expect(apiAdminListWorkspaces).toHaveBeenCalledTimes(2)); + }); }); diff --git a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx index b7dc753f0..7e34cb442 100644 --- a/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx +++ b/apps/web/src/frontend/features/admin/components/AdminWorkspacesPanel.tsx @@ -8,7 +8,8 @@ * * An admin opens one only by joining it, and joining puts the admin in its * member list, so nobody's workspace is looked into silently. From here an - * admin can also run its members (hand ownership on) and archive it. + * admin can also run its members (hand ownership on), archive it, and bring an + * archived one back. */ import React, { lazy, useCallback, useEffect, useState } from 'react'; import { Layers, Loader2 } from 'lucide-react'; @@ -17,6 +18,7 @@ import { MountWhenOpened } from '@/shared/components/MountWhenOpened'; import { apiAdminListWorkspaces, apiArchiveWorkspace, + apiRestoreWorkspace, apiSelectWorkspace, apiSetWorkspaceMember, loadWorkspaceSettingsDialog, @@ -101,10 +103,22 @@ export const AdminWorkspacesPanel: React.FC<{ reload?: () => void }> = ({ reload {w.memberCount} {w.memberCount === 1 ? 'member' : 'members'}
+ {ceilingHint} +