diff --git a/.github/actions/e2e-runtime/action.yml b/.github/actions/e2e-runtime/action.yml index b834b6e..83ff80c 100644 --- a/.github/actions/e2e-runtime/action.yml +++ b/.github/actions/e2e-runtime/action.yml @@ -63,21 +63,18 @@ runs: uses: actions/cache@v4 with: path: ${{ steps.pnpm-store.outputs.path }} - key: e2e-lab-pnpm-backend-ee-${{ runner.os }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', inputs.teable-ee-path)) }} + key: e2e-lab-pnpm-backend-app-ee-${{ runner.os }}-${{ hashFiles(format('{0}/pnpm-lock.yaml', inputs.teable-ee-path)) }} restore-keys: | - e2e-lab-pnpm-backend-ee-${{ runner.os }}- - - # Only what the run reaches. This job starts one nest app and drives it - # through backend-ee's vitest; the workspace's frontends and their trees - # are installed for nothing, and installing them is not free even on a warm - # store — every package still has to be linked into place. The filter - # covers 43 of the 64 workspace packages, both prisma packages among them, - # and was checked by running the whole suite against it. + e2e-lab-pnpm-backend-app-ee-${{ runner.os }}- + + # The backend hosts Vitest and app-ee serves browser-driven cases, so both + # dependency closures are required. The filters retain both Prisma packages + # without linking unrelated workspace applications. - name: Install teable-ee dependencies if: inputs.operation == 'bootstrap' shell: bash working-directory: ${{ inputs.teable-ee-path }} - run: pnpm install --frozen-lockfile --filter @teable/backend-ee... + run: pnpm install --frozen-lockfile --filter @teable/backend-ee... --filter @teable/app-ee... - name: Generate Prisma clients if: inputs.operation == 'bootstrap' diff --git a/cases/authority/y338-unreadable-group-still-loads.case.ts b/cases/authority/y338-unreadable-group-still-loads.case.ts new file mode 100644 index 0000000..074af30 --- /dev/null +++ b/cases/authority/y338-unreadable-group-still-loads.case.ts @@ -0,0 +1,25 @@ +import { defineBugCase } from "../../framework/types"; + +export default defineBugCase({ + id: "authority/y338-unreadable-group-still-loads", + title: + "Y338: Restricted grouped grids load and keep their permitted behavior", + runner: "authority-unreadable-group", + timeoutMs: 600_000, + bug: { + issue: "T6944", + status: "fixed", + sourceCommits: ["04af0858e"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-y338", + rows: [ + { title: "alpha", group: "kind-a", status: "open" }, + { title: "beta", group: "kind-b", status: "closed" }, + { title: "gamma", group: "kind-a", status: "open" }, + ], + subscribeTimeoutMs: 30_000, + settleTimeoutMs: 60_000, + }, +}); diff --git a/cases/authority/y338-unreadable-group-still-loads.md b/cases/authority/y338-unreadable-group-still-loads.md new file mode 100644 index 0000000..1024f35 --- /dev/null +++ b/cases/authority/y338-unreadable-group-still-loads.md @@ -0,0 +1,39 @@ +# Y338: Restricted grouped grids load and keep their permitted behavior + +**T6944 / Y338** — fixed by `04af0858e`. + +## What the user sees + +A member opens a grid whose persisted group points at a field they cannot read. +The page must load the permitted records, omit that field, and keep its record +subscription healthy instead of surfacing a validation or socket error. + +The same degradation applies when the grouping field is conditionally masked. +Existing permissive behavior for a view filter and sort on an unreadable field +must also stay intact. + +## What the checkpoint asserts + +The browser opens the actual restricted grid. The navigation succeeds, the +grid renders, and the browser's live record subscription omits the unreadable +persisted group, receives every permitted row, and raises no page or socket +error. A separate public v2 request echoes the persisted group and verifies +the same complete, field-filtered record set. + +Two API checkpoints then assert that: + +1. A persisted group on a conditionally masked field returns only permitted + records instead of rejecting the request. +2. A persisted filter and sort on a statically unreadable field are ignored, + preserving the complete readable record set. + +## What the fixture has to hold + +The runner creates three deterministic records with title, category, and +status fields; three purpose-built grid views; a real second member; and one +permission role. Owner reads prove all views and rows exist before permissions +are applied. Permission changes between checkpoints happen outside each +checkpoint so a broken fixture cannot be mistaken for T6944. + +Extra client-supplied group keys remain strict by design. This case sends only +the persisted group echoed by the grid, which is the input T6944 changed. diff --git a/cases/filter/y339-multi-user-lookup-filter-contract.case.ts b/cases/filter/y339-multi-user-lookup-filter-contract.case.ts new file mode 100644 index 0000000..89a3aef --- /dev/null +++ b/cases/filter/y339-multi-user-lookup-filter-contract.case.ts @@ -0,0 +1,19 @@ +import { defineBugCase } from "../../framework/types"; + +export default defineBugCase({ + id: "filter/y339-multi-user-lookup-filter-contract", + title: "Y339: A multi-user lookup accepts its multi-value filter", + runner: "lookup-user-filter-contract", + timeoutMs: 180_000, + bug: { + issue: "T6943", + status: "fixed", + sourceCommits: ["358787f97"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-y339", + matchedTitle: "linked-to-current-user", + unmatchedTitle: "not-linked", + }, +}); diff --git a/cases/filter/y339-multi-user-lookup-filter-contract.md b/cases/filter/y339-multi-user-lookup-filter-contract.md new file mode 100644 index 0000000..7344b49 --- /dev/null +++ b/cases/filter/y339-multi-user-lookup-filter-contract.md @@ -0,0 +1,25 @@ +# filter/y339-multi-user-lookup-filter-contract + +**T6943 / Y339** — fixed by `358787f97`. + +## What the user sees + +A one-to-many link looks up a single-value User field. The resulting lookup is +multi-value, so a multi-value user filter must be accepted and return the +matching row. + +## What the checkpoint asserts + +The public field description marks the lookup as multi-value. Saving a +`hasAnyOf` filter succeeds, and reading the filtered view returns only the row +linked to the selected user. + +## What the fixture has to hold + +The runner creates a source User field, a host table with matching and +nonmatching rows, a one-to-many link, and the User lookup. It verifies the link +and both unfiltered host rows before the checkpoint. + +One representative valid operator is enough to prove the client/server +contract. Exhaustively repeating every multi-value operator is outside this +atomic regression case. diff --git a/cases/lookup/y249-conditional-lookup-keeps-all-matches.case.ts b/cases/lookup/y249-conditional-lookup-keeps-all-matches.case.ts new file mode 100644 index 0000000..670bf46 --- /dev/null +++ b/cases/lookup/y249-conditional-lookup-keeps-all-matches.case.ts @@ -0,0 +1,20 @@ +import { defineBugCase } from "../../framework/types"; + +export default defineBugCase({ + id: "lookup/y249-conditional-lookup-keeps-all-matches", + title: "Y249: A conditional lookup keeps every matching row", + runner: "conditional-lookup-all-matches", + timeoutMs: 180_000, + bug: { + issue: "sentinel/conditional-lookup-all-matches", + status: "fixed", + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-y249", + matchKey: 249, + sourceValues: ["match-alpha", "match-beta"], + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/y249-conditional-lookup-keeps-all-matches.md b/cases/lookup/y249-conditional-lookup-keeps-all-matches.md new file mode 100644 index 0000000..8140d99 --- /dev/null +++ b/cases/lookup/y249-conditional-lookup-keeps-all-matches.md @@ -0,0 +1,24 @@ +# lookup/y249-conditional-lookup-keeps-all-matches + +**Y249 sentinel** — fixed. + +## What the user sees + +A conditional lookup must not omit records that satisfy its condition. This +case keeps the broad report executable by covering one deterministic contract: +two source rows with the same numeric key both appear in the host row. + +## What the checkpoint asserts + +The lookup returns exactly both matching source values after computation +settles. It does not use the previously recorded manual result as an oracle. + +## What the fixture has to hold + +The source table contains two rows with the same numeric key and distinct +values. The host table contains one row with that key. Both tables and the +field-reference condition are created through public APIs before the +checkpoint. + +The original report did not identify a confirmed cause or a fix commit, so +this is deliberately a narrow sentinel rather than a historical fix claim. diff --git a/cases/lookup/y340-user-lookup-survives-reread.case.ts b/cases/lookup/y340-user-lookup-survives-reread.case.ts new file mode 100644 index 0000000..f22821b --- /dev/null +++ b/cases/lookup/y340-user-lookup-survives-reread.case.ts @@ -0,0 +1,21 @@ +import { defineBugCase } from "../../framework/types"; + +export default defineBugCase({ + id: "lookup/y340-user-lookup-survives-reread", + title: "Y340: A recomputed user lookup survives a fresh read", + runner: "lookup-user-recompute-reread", + timeoutMs: 180_000, + bug: { + issue: "T6941", + status: "fixed", + sourceCommits: ["927f79fa2", "147f587d8"], + }, + config: { + baseId: "seed-base", + tableNamePrefix: "e2e-lab-y340", + sourceTitle: "source-owner", + hostTitle: "work-item", + settleTimeoutMs: 60_000, + pollIntervalMs: 500, + }, +}); diff --git a/cases/lookup/y340-user-lookup-survives-reread.md b/cases/lookup/y340-user-lookup-survives-reread.md new file mode 100644 index 0000000..05e7697 --- /dev/null +++ b/cases/lookup/y340-user-lookup-survives-reread.md @@ -0,0 +1,22 @@ +# lookup/y340-user-lookup-survives-reread + +**T6941 / Y340** — fixed by `927f79fa2` and `147f587d8`. + +## What the user sees + +A User lookup over a one-to-many link shows its computed person. After the +source user changes and the lookup recomputes, refreshing must not make the +value disappear. + +## What the checkpoint asserts + +Two independent reads first return the original one-item user array. The +runner then changes the source to a different real collaborator and waits for +recomputation. A further independent read returns the replacement user, and +the public field description still marks the lookup as multi-value. + +## What the fixture has to hold + +The runner creates and invites a second user, then creates the source row, host +row, one-to-many link, and User lookup entirely through public APIs. Before the +checkpoint it verifies that the host row is linked to exactly one source row. diff --git a/e2e-lab.e2e-spec.ts b/e2e-lab.e2e-spec.ts index ac89d15..92a0a01 100644 --- a/e2e-lab.e2e-spec.ts +++ b/e2e-lab.e2e-spec.ts @@ -2,6 +2,7 @@ import type { INestApplication } from "@nestjs/common"; import { performance } from "node:perf_hooks"; import { initApp } from "../utils/init-app"; import { getBugCase, resolveBugCaseIds } from "./registry"; +import { closeBrowserRuntime } from "./framework/browser-runtime"; import { applyEngineRuntimeEnv, LAB_ENGINE } from "./framework/engine"; import { runBugCase } from "./framework/run-bug-case"; @@ -71,6 +72,7 @@ describe("e2e-lab bug regression runner (e2e)", () => { afterAll(async () => { const closeStarted = performance.now(); + await closeBrowserRuntime(); await app?.close(); logPhase("app-closed", { closeMs: Math.round(performance.now() - closeStarted), diff --git a/framework/browser-runtime.ts b/framework/browser-runtime.ts new file mode 100644 index 0000000..4e56349 --- /dev/null +++ b/framework/browser-runtime.ts @@ -0,0 +1,302 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { createRequire } from "node:module"; +import { createServer } from "node:net"; +import { join, resolve } from "node:path"; + +import type { BugRunContext } from "./types"; + +export type BrowserResponse = { + url(): string; + status(): number; + headers(): Record; + json(): Promise; + request(): { method(): string }; +}; + +export type BrowserWebSocketFrame = { + payload: string | Buffer; +}; + +export type BrowserWebSocket = { + url(): string; + on( + event: "framesent" | "framereceived", + handler: (frame: BrowserWebSocketFrame) => void, + ): void; + on(event: "socketerror", handler: (error: string) => void): void; +}; + +export type BrowserLocator = { + count(): Promise; + isVisible(): Promise; + textContent(): Promise; + boundingBox(): Promise<{ + x: number; + y: number; + width: number; + height: number; + } | null>; + nth(index: number): BrowserLocator; +}; + +export type BrowserPage = { + goto( + url: string, + options?: { waitUntil?: string; timeout?: number }, + ): Promise; + locator(selector: string): BrowserLocator; + on(event: "pageerror", handler: (error: Error) => void): void; + on(event: "websocket", handler: (socket: BrowserWebSocket) => void): void; + waitForResponse( + predicate: (response: BrowserResponse) => boolean, + options?: { timeout?: number }, + ): Promise; + waitForTimeout(ms: number): Promise; +}; + +type BrowserContext = { + addCookies( + cookies: { + name: string; + value: string; + url: string; + httpOnly: boolean; + sameSite: "Lax"; + }[], + ): Promise; + newPage(): Promise; + close(): Promise; +}; + +type Browser = { + newContext(options: { + viewport: { width: number; height: number }; + }): Promise; + close(): Promise; +}; + +type PlaywrightModule = { + chromium: { + launch(options: { + headless: boolean; + channel?: "chrome"; + }): Promise; + }; +}; + +type RuntimeState = { + frontendUrl: string; + frontend: ChildProcess; + browser: Browser; + output: string[]; +}; + +let runtime: RuntimeState | undefined; +let runtimePromise: Promise | undefined; + +const findTeableRoot = () => { + const candidates = [ + process.cwd(), + resolve(process.cwd(), "../.."), + resolve(process.cwd(), "../../.."), + ]; + const requireFromHere = createRequire(import.meta.url); + const fs = requireFromHere("node:fs") as { + existsSync(path: string): boolean; + }; + const root = candidates.find((candidate) => + fs.existsSync(join(candidate, "enterprise/app-ee/package.json")), + ); + if (!root) { + throw new Error( + `Cannot locate the teable-ee root from ${process.cwd()} (expected enterprise/app-ee/package.json)`, + ); + } + return root; +}; + +const reservePort = async () => { + const server = createServer(); + server.unref(); + server.listen(0, "127.0.0.1"); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + server.close(); + throw new Error("Could not reserve a local port for the Next.js server"); + } + const port = address.port; + await new Promise((resolveClose, rejectClose) => { + server.close((error) => (error ? rejectClose(error) : resolveClose())); + }); + return port; +}; + +const stopChild = (child: ChildProcess) => { + if (!child.pid || child.exitCode != null) return; + try { + if (process.platform === "win32") { + child.kill("SIGTERM"); + } else { + process.kill(-child.pid, "SIGTERM"); + } + } catch { + child.kill("SIGTERM"); + } +}; + +const waitForFrontend = async ( + frontendUrl: string, + child: ChildProcess, + output: string[], +) => { + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + if (child.exitCode != null) { + throw new Error( + `Next.js exited with ${child.exitCode} before becoming ready:\n${output.slice(-40).join("")}`, + ); + } + try { + const response = await fetch(`${frontendUrl}/auth/login`); + if (response.status < 500) return; + } catch { + // The frontend is still compiling or has not bound its port yet. + } + await new Promise((resolveWait) => setTimeout(resolveWait, 500)); + } + throw new Error( + `Next.js did not become ready at ${frontendUrl}:\n${output.slice(-40).join("")}`, + ); +}; + +const startRuntime = async (context: BugRunContext): Promise => { + const root = findTeableRoot(); + const backendPort = new URL(context.appUrl).port; + const frontendPort = await reservePort(); + const frontendUrl = `http://127.0.0.1:${frontendPort}`; + const output: string[] = []; + const frontend = spawn( + "corepack", + [ + "pnpm", + "-F", + "@teable/app-ee", + "exec", + "next", + "dev", + "-p", + String(frontendPort), + ], + { + cwd: root, + detached: process.platform !== "win32", + env: { + ...process.env, + NODE_ENV: "development", + PORT: String(frontendPort), + PUBLIC_ORIGIN: frontendUrl, + SERVER_PORT: backendPort, + SOCKET_PORT: backendPort, + NEXT_BUILD_ENV_CSP: "false", + NEXT_BUILD_ENV_SENTRY_ENABLED: "false", + NEXT_BUILD_ENV_TYPECHECK: "false", + }, + stdio: ["ignore", "pipe", "pipe"], + }, + ); + + const collect = (chunk: unknown) => { + output.push(String(chunk)); + if (output.length > 200) output.shift(); + }; + frontend.stdout?.on("data", collect); + frontend.stderr?.on("data", collect); + + try { + await waitForFrontend(frontendUrl, frontend, output); + const requireFromApp = createRequire( + join(root, "enterprise/app-ee/package.json"), + ); + const { chromium } = requireFromApp("@playwright/test") as PlaywrightModule; + const browser = await chromium.launch( + process.env.CI + ? { headless: true, channel: "chrome" } + : { headless: true }, + ); + return { frontendUrl, frontend, browser, output }; + } catch (error) { + stopChild(frontend); + throw error; + } +}; + +const getRuntime = async (context: BugRunContext) => { + if (runtime) return runtime; + runtimePromise ??= startRuntime(context); + runtime = await runtimePromise; + return runtime; +}; + +const sessionCookie = (value: string | string[] | undefined) => { + const cookie = Array.isArray(value) ? value.join("; ") : value; + const part = cookie + ?.split(";") + .map((item) => item.trim()) + .find((item) => item.startsWith("auth_session=")); + if (!part) { + throw new Error( + "The browser session did not provide an auth_session cookie", + ); + } + return { + name: "auth_session", + value: part.slice("auth_session=".length), + }; +}; + +export const openBrowserPage = async ( + context: BugRunContext, + options: { cookie?: string | string[]; locale?: "zh" } = {}, +) => { + const state = await getRuntime(context); + const browserContext = await state.browser.newContext({ + viewport: { width: 1440, height: 960 }, + }); + const cookie = sessionCookie(options.cookie ?? context.cookie); + await browserContext.addCookies([ + { + ...cookie, + url: state.frontendUrl, + httpOnly: true, + sameSite: "Lax", + }, + ...(options.locale + ? [ + { + name: "NEXT_LOCALE", + value: options.locale, + url: state.frontendUrl, + httpOnly: false, + sameSite: "Lax" as const, + }, + ] + : []), + ]); + const page = await browserContext.newPage(); + return { + page, + frontendUrl: state.frontendUrl, + close: () => browserContext.close(), + }; +}; + +export const closeBrowserRuntime = async () => { + const state = runtime; + runtime = undefined; + runtimePromise = undefined; + if (!state) return; + await state.browser.close().catch(() => undefined); + stopChild(state.frontend); +}; diff --git a/framework/runner-registry.ts b/framework/runner-registry.ts index 3bd9c54..94207a3 100644 --- a/framework/runner-registry.ts +++ b/framework/runner-registry.ts @@ -111,6 +111,10 @@ import { runLookupOfLinkContainsCase } from "./runners/lookup-of-link-contains.r import { runDeleteWithoutUndoCaptureCase } from "./runners/delete-without-undo-capture.runner"; import { runSingleFieldPendingStateCase } from "./runners/single-field-pending-state.runner"; import { runLookupOfRollupCreateCase } from "./runners/lookup-of-rollup-create.runner"; +import { runConditionalLookupAllMatchesCase } from "./runners/conditional-lookup-all-matches.runner"; +import { runAuthorityUnreadableGroupCase } from "./runners/authority-unreadable-group.runner"; +import { runLookupUserFilterContractCase } from "./runners/lookup-user-filter-contract.runner"; +import { runLookupUserRecomputeRereadCase } from "./runners/lookup-user-recompute-reread.runner"; import type { BugCase, BugCaseFor, @@ -241,6 +245,10 @@ const runners: { [K in BugRunnerKind]: RunnerFn } = { "lookup-of-link-contains": runLookupOfLinkContainsCase, "delete-without-undo-capture": runDeleteWithoutUndoCaptureCase, "single-field-pending-state": runSingleFieldPendingStateCase, + "conditional-lookup-all-matches": runConditionalLookupAllMatchesCase, + "authority-unreadable-group": runAuthorityUnreadableGroupCase, + "lookup-user-filter-contract": runLookupUserFilterContractCase, + "lookup-user-recompute-reread": runLookupUserRecomputeRereadCase, }; export const executeRegisteredRunner = ( diff --git a/framework/runners/authority-unreadable-group.runner.ts b/framework/runners/authority-unreadable-group.runner.ts new file mode 100644 index 0000000..6cc073e --- /dev/null +++ b/framework/runners/authority-unreadable-group.runner.ts @@ -0,0 +1,636 @@ +import { + FieldKeyType, + FieldType, + IdPrefix, + Role, + SortFunc, + ViewType, +} from "@teable/core"; +import type { IRecordsVo, IUserMeVo } from "@teable/openapi"; +import { + axios, + createView, + emailSpaceInvitation, + getBaseById, + getRecords as apiGetRecords, + USER_ME, +} from "@teable/openapi"; +import { createNewUserAxios } from "../../../utils/axios-instance/new-user"; +import { createTable, permanentDeleteTable } from "../../../utils/init-app"; +import { + openBrowserPage, + type BrowserLocator, + type BrowserWebSocketFrame, +} from "../browser-runtime"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import { realtimeClient } from "../realtime"; +import type { RealtimeQuerySubscription } from "../realtime"; +import type { + AuthorityUnreadableGroupCaseConfig, + BugCaseFor, + BugProbeResult, + BugRunContext, +} from "../types"; + +const TITLE_FIELD = "Title"; +const GROUP_FIELD = "Category"; +const STATUS_FIELD = "Status"; + +interface AuthorityRoleResponse { + id: string; +} + +type ReaderAxios = Awaited>; + +type RecordsResponse = { + status: number; + data: IRecordsVo; + headers: Record; +}; + +type RoleTable = { + authorityMatrixRoleId?: string; + enabled: true; + tableId: string; + disabledActions: string[]; + recordFilter: Record | null; + fieldRecordPermission: { + fieldId: string; + disabledActions?: string[]; + }[]; +}; + +const cookieText = (value: unknown): string => { + if (Array.isArray(value)) return value.map(String).join("; "); + if (typeof value === "string") return value; + throw new Error("the restricted member session has no cookie"); +}; + +const sortedIds = (records: { id: string }[]) => + records.map((record) => record.id).sort(); + +const assertRecordIds = ( + records: { id: string }[], + expectedIds: string[], + label: string, +) => { + const actual = sortedIds(records); + const expected = [...expectedIds].sort(); + if (actual.join(",") !== expected.join(",")) { + throw new Error( + `${label} returned ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ); + } + return actual; +}; + +const assertReadableRecordShape = ( + response: IRecordsVo, + titleFieldId: string, + unreadableFieldId: string, + label: string, +) => { + for (const record of response.records) { + if (!(titleFieldId in record.fields)) { + throw new Error(`${label} omitted the readable title on ${record.id}`); + } + if (unreadableFieldId in record.fields) { + throw new Error( + `${label} exposed unreadable field ${unreadableFieldId} on ${record.id}: ${JSON.stringify(record.fields)}`, + ); + } + } +}; + +const waitForGridStage = async (locator: BrowserLocator, timeoutMs: number) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + const count = await locator.count(); + if (count > 0) { + return { mountedStageCount: count }; + } + await new Promise((resolve) => setTimeout(resolve, 100)); + } + throw new Error("the restricted grid did not mount a canvas stage"); +}; + +const waitUntil = async ( + predicate: () => boolean, + timeoutMs: number, + message: () => string | Promise, +) => { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (predicate()) return; + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } + throw new Error(await message()); +}; + +const frameText = (frame: BrowserWebSocketFrame) => + typeof frame.payload === "string" + ? frame.payload + : frame.payload.toString("utf8"); + +const restrictedRead = async ( + readerAxios: ReaderAxios, + tableId: string, + params: Record, +) => + readerAxios.get(`/table/${tableId}/record`, { + params, + validateStatus: () => true, + }); + +const assertSuccessfulRecordResponse = ( + response: RecordsResponse, + label: string, +) => { + if (response.status !== 200 || !Array.isArray(response.data?.records)) { + throw new Error( + `${label} answered ${response.status}: ${JSON.stringify(response.data)}`, + ); + } +}; + +export const runAuthorityUnreadableGroupCase = async ( + bugCase: BugCaseFor<"authority-unreadable-group">, + context: BugRunContext, +): Promise => { + const config: AuthorityUnreadableGroupCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const tableName = `${config.tableNamePrefix}-${context.runId}`; + let tableId = ""; + let matrixEnabled = false; + let browser: Awaited> | undefined; + let realtime: ReturnType | undefined; + let rowSubscription: RealtimeQuerySubscription | undefined; + + try { + const readerEmail = `${tableName}@example.com`; + const readerAxios = await createNewUserAxios({ + email: readerEmail, + password: "12345678a", + }); + const reader = (await readerAxios.get(USER_ME)).data; + const readerCookie = cookieText(readerAxios.defaults.headers.Cookie); + const spaceId = (await getBaseById(baseId)).data.spaceId; + await emailSpaceInvitation({ + spaceId, + emailSpaceInvitationRo: { emails: [readerEmail], role: Role.Editor }, + }); + + const table = await createTable(baseId, { + name: tableName, + fields: [ + { name: TITLE_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: GROUP_FIELD, + type: FieldType.SingleSelect, + options: { + choices: [...new Set(config.rows.map((row) => row.group))].map( + (name) => ({ name }), + ), + }, + }, + { + name: STATUS_FIELD, + type: FieldType.SingleSelect, + options: { + choices: [...new Set(config.rows.map((row) => row.status))].map( + (name) => ({ name }), + ), + }, + }, + ], + records: config.rows.map((row) => ({ + fields: { + [TITLE_FIELD]: row.title, + [GROUP_FIELD]: row.group, + [STATUS_FIELD]: row.status, + }, + })), + }); + tableId = table.id; + const titleField = table.fields.find((field) => field.name === TITLE_FIELD); + const groupField = table.fields.find((field) => field.name === GROUP_FIELD); + const statusField = table.fields.find( + (field) => field.name === STATUS_FIELD, + ); + if ( + !titleField || + !groupField || + !statusField || + table.records.length !== config.rows.length + ) { + throw new Error("the grouped permission fixture is incomplete"); + } + const everyRecordId = table.records.map((record) => record.id); + const conditionallyVisibleIds = table.records + .filter((_, index) => config.rows[index]?.title !== "beta") + .map((record) => record.id); + + const unreadableGroupView = ( + await createView(tableId, { + name: "Unreadable grouped grid", + type: ViewType.Grid, + group: [{ fieldId: groupField.id, order: SortFunc.Asc }], + }) + ).data; + const conditionalGroupView = ( + await createView(tableId, { + name: "Conditionally masked grouped grid", + type: ViewType.Grid, + group: [{ fieldId: statusField.id, order: SortFunc.Asc }], + }) + ).data; + const filteredSortedView = ( + await createView(tableId, { + name: "Unreadable filtered and sorted grid", + type: ViewType.Grid, + filter: { + conjunction: "and", + filterSet: [ + { + fieldId: groupField.id, + operator: "is", + value: config.rows[0]?.group, + }, + ], + }, + sort: { + sortObjs: [{ fieldId: groupField.id, order: SortFunc.Asc }], + }, + }) + ).data; + + const ownerUnreadableView = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + viewId: unreadableGroupView.id, + groupBy: unreadableGroupView.group ?? [], + includeQueryExtra: true, + take: config.rows.length, + }); + const ownerRouting = assertServedByV2(ownerUnreadableView.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + assertRecordIds( + ownerUnreadableView.data.records, + everyRecordId, + "the owner's grouped fixture read", + ); + const ownerConditionalView = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + viewId: conditionalGroupView.id, + groupBy: conditionalGroupView.group ?? [], + includeQueryExtra: true, + take: config.rows.length, + }); + assertRecordIds( + ownerConditionalView.data.records, + everyRecordId, + "the owner's conditional-group fixture read", + ); + const ownerFilteredView = await apiGetRecords(tableId, { + fieldKeyType: FieldKeyType.Id, + viewId: filteredSortedView.id, + take: config.rows.length, + }); + if (ownerFilteredView.data.records.length === everyRecordId.length) { + throw new Error( + "the owner's filtered view did not narrow the fixture before permissions were applied", + ); + } + + await axios.patch(`/base/${baseId}/authority-matrix/status`, { + enabled: true, + }); + matrixEnabled = true; + + const role = await axios + .post(`/base/${baseId}/authority-matrix-role`, { + name: `group-reader-${context.runId}`, + enabled: true, + tables: [ + { + enabled: true, + tableId, + disabledActions: [], + recordFilter: null, + fieldRecordPermission: table.fields.map((field) => ({ + fieldId: field.id, + })), + }, + ], + }) + .then((response) => response.data); + + const updateRole = async (tablePermission: RoleTable) => { + await axios.put(`/base/${baseId}/authority-matrix-role/${role.id}`, { + name: `group-reader-${context.runId}`, + enabled: true, + tables: [{ ...tablePermission, authorityMatrixRoleId: role.id }], + }); + }; + const unreadableGroupPermission: RoleTable = { + enabled: true, + tableId, + disabledActions: [], + recordFilter: null, + fieldRecordPermission: [ + { fieldId: titleField.id }, + { + fieldId: groupField.id, + disabledActions: ["record|read", "record|update", "record|create"], + }, + { fieldId: statusField.id }, + ], + }; + await updateRole(unreadableGroupPermission); + await axios.patch(`/base/${baseId}/authority-matrix-role/${role.id}/user`, { + userIds: [reader.id], + }); + await axios.put(`/base/${baseId}/authority-matrix`, { + defaultRole: role.id, + }); + + browser = await openBrowserPage(context, { + cookie: readerCookie, + locale: "zh", + }); + const pageErrors: string[] = []; + const socketErrors: string[] = []; + const sentSocketFrames: string[] = []; + const receivedSocketFrames: string[] = []; + browser.page.on("pageerror", (error) => pageErrors.push(error.message)); + browser.page.on("websocket", (socket) => { + socket.on("framesent", (frame) => + sentSocketFrames.push(frameText(frame)), + ); + socket.on("framereceived", (frame) => + receivedSocketFrames.push(frameText(frame)), + ); + socket.on("socketerror", (error) => socketErrors.push(error)); + }); + + let explicitUnreadableHeaders: Record = {}; + const browserProbe = await bugCheckpoint( + "restricted-grid-loads-and-subscribes-without-its-unreadable-group", + async () => { + const navigation = await browser!.page.goto( + `${browser!.frontendUrl}/base/${baseId}/table/${tableId}/${unreadableGroupView.id}`, + { waitUntil: "domcontentloaded", timeout: 300_000 }, + ); + if (!navigation || navigation.status() !== 200) { + throw new Error( + `the restricted grid navigation answered ${navigation?.status() ?? "without a response"}`, + ); + } + const recordCollection = `${IdPrefix.Record}_${tableId}`; + await waitUntil( + () => + sentSocketFrames.some((frame) => frame.includes(recordCollection)), + config.subscribeTimeoutMs, + async () => + `the restricted grid did not subscribe to ${recordCollection}; page errors: ${JSON.stringify(pageErrors)}; socket errors: ${JSON.stringify(socketErrors)}; sent frames: ${JSON.stringify(sentSocketFrames)}; page text: ${JSON.stringify(await browser!.page.locator("body").textContent())}`, + ); + const recordSubscriptionFrames = sentSocketFrames.filter((frame) => + frame.includes(recordCollection), + ); + if ( + recordSubscriptionFrames.some((frame) => + frame.includes(groupField.id), + ) + ) { + throw new Error( + `the restricted grid subscribed with unreadable group field ${groupField.id}: ${JSON.stringify(recordSubscriptionFrames)}`, + ); + } + await waitUntil( + () => + everyRecordId.every((recordId) => + receivedSocketFrames.some((frame) => frame.includes(recordId)), + ), + config.subscribeTimeoutMs, + () => + `the restricted grid subscription did not receive every permitted row ${JSON.stringify(everyRecordId)}; received frames: ${JSON.stringify(receivedSocketFrames)}`, + ); + const gridStage = await waitForGridStage( + browser!.page.locator('[data-t-grid-stage="true"]'), + config.settleTimeoutMs, + ); + await browser!.page.waitForTimeout(250); + if (pageErrors.length > 0) { + throw new Error( + `the restricted grid raised page errors: ${JSON.stringify(pageErrors)}`, + ); + } + if (socketErrors.length > 0) { + throw new Error( + `the restricted grid raised socket errors: ${JSON.stringify(socketErrors)}`, + ); + } + + const explicitRead = await restrictedRead(readerAxios, tableId, { + fieldKeyType: FieldKeyType.Id, + viewId: unreadableGroupView.id, + groupBy: JSON.stringify(unreadableGroupView.group ?? []), + includeQueryExtra: true, + take: config.rows.length, + }); + explicitUnreadableHeaders = explicitRead.headers; + assertSuccessfulRecordResponse( + explicitRead, + "the explicit unreadable-group API read", + ); + const explicitRecordIds = assertRecordIds( + explicitRead.data.records, + everyRecordId, + "the explicit unreadable-group API read", + ); + assertReadableRecordShape( + explicitRead.data, + titleField.id, + groupField.id, + "the explicit unreadable-group API read", + ); + + realtime = realtimeClient(context.appUrl, readerCookie); + rowSubscription = await realtime.subscribeQuery( + `${IdPrefix.Record}_${tableId}`, + { viewId: unreadableGroupView.id, type: IdPrefix.Record }, + { timeoutMs: config.subscribeTimeoutMs }, + ); + await rowSubscription.waitFor( + (ids) => + [...ids].sort().join(",") === [...everyRecordId].sort().join(","), + { + timeoutMs: config.subscribeTimeoutMs, + describe: "every permitted row in the restricted grouped grid", + }, + ); + if (rowSubscription.errors().length > 0) { + throw new Error( + `the restricted grid subscription errored: ${JSON.stringify(rowSubscription.errors())}`, + ); + } + + return { + explicitRecordIds, + browserSubscriptionFrameCount: recordSubscriptionFrames.length, + browserReceivedRecordIds: everyRecordId, + apiSubscribedRecordIds: [...rowSubscription.ids()].sort(), + gridStage, + }; + }, + ); + const unreadableApiRouting = assertServedByV2(explicitUnreadableHeaders, { + operation: "GET /table/{tableId}/record with an unreadable view group", + feature: "getRecords", + }); + + rowSubscription.close(); + rowSubscription = undefined; + realtime.close(); + realtime = undefined; + await browser.close(); + browser = undefined; + + await updateRole({ + enabled: true, + tableId, + disabledActions: [], + recordFilter: { + conjunction: "and", + filterSet: [ + { + fieldId: titleField.id, + operator: "isNot", + value: "beta", + }, + ], + }, + fieldRecordPermission: table.fields.map((field) => ({ + fieldId: field.id, + })), + }); + + let conditionalHeaders: Record = {}; + const conditionalProbe = await bugCheckpoint( + "conditionally-masked-group-degrades-with-permitted-records", + async () => { + const response = await restrictedRead(readerAxios, tableId, { + fieldKeyType: FieldKeyType.Id, + viewId: conditionalGroupView.id, + groupBy: JSON.stringify(conditionalGroupView.group ?? []), + includeQueryExtra: true, + take: config.rows.length, + }); + conditionalHeaders = response.headers; + assertSuccessfulRecordResponse( + response, + "the conditionally masked group API read", + ); + return { + recordIds: assertRecordIds( + response.data.records, + conditionallyVisibleIds, + "the conditionally masked group API read", + ), + }; + }, + ); + const conditionalRouting = assertServedByV2(conditionalHeaders, { + operation: + "GET /table/{tableId}/record with a conditionally masked view group", + feature: "getRecords", + }); + + await updateRole(unreadableGroupPermission); + + let filterSortHeaders: Record = {}; + const filterSortProbe = await bugCheckpoint( + "unreadable-view-filter-and-sort-preserve-readable-records", + async () => { + const response = await restrictedRead(readerAxios, tableId, { + fieldKeyType: FieldKeyType.Id, + viewId: filteredSortedView.id, + take: config.rows.length, + }); + filterSortHeaders = response.headers; + assertSuccessfulRecordResponse( + response, + "the unreadable filter-and-sort API read", + ); + const recordIds = assertRecordIds( + response.data.records, + everyRecordId, + "the unreadable filter-and-sort API read", + ); + assertReadableRecordShape( + response.data, + titleField.id, + groupField.id, + "the unreadable filter-and-sort API read", + ); + return { recordIds }; + }, + ); + const filterSortRouting = assertServedByV2(filterSortHeaders, { + operation: + "GET /table/{tableId}/record with an unreadable view filter and sort", + feature: "getRecords", + }); + + return { + details: { + tableId, + readerId: reader.id, + unreadableGroupViewId: unreadableGroupView.id, + conditionalGroupViewId: conditionalGroupView.id, + filteredSortedViewId: filteredSortedView.id, + browserSubscriptionFrameCount: + browserProbe.browserSubscriptionFrameCount, + browserReceivedRecordIds: browserProbe.browserReceivedRecordIds, + explicitUnreadableRecordIds: browserProbe.explicitRecordIds, + apiSubscribedRecordIds: browserProbe.apiSubscribedRecordIds, + browserGridStage: browserProbe.gridStage, + conditionalRecordIds: conditionalProbe.recordIds, + filterSortRecordIds: filterSortProbe.recordIds, + ownerRouting, + unreadableApiRouting, + conditionalRouting, + filterSortRouting, + }, + }; + } finally { + rowSubscription?.close(); + realtime?.close(); + await browser?.close().catch(() => undefined); + if (matrixEnabled) { + try { + await axios.put(`/base/${baseId}/authority-matrix`, { + defaultRole: null, + }); + await axios.patch(`/base/${baseId}/authority-matrix/status`, { + enabled: false, + }); + } catch (error) { + console.warn( + `[e2e-lab] authority cleanup failed for ${bugCase.id}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + if (tableId) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +}; diff --git a/framework/runners/conditional-lookup-all-matches.runner.ts b/framework/runners/conditional-lookup-all-matches.runner.ts new file mode 100644 index 0000000..d4e14e0 --- /dev/null +++ b/framework/runners/conditional-lookup-all-matches.runner.ts @@ -0,0 +1,178 @@ +import { FieldKeyType, FieldType } from "@teable/core"; +import { getRecords as apiGetRecords } from "@teable/openapi"; +import { + createField, + createTable, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { + BugCaseFor, + BugProbeResult, + BugRunContext, + ConditionalLookupAllMatchesCaseConfig, +} from "../types"; + +const NAME_FIELD = "Name"; +const KEY_FIELD = "Match Key"; +const VALUE_FIELD = "Value"; +const LOOKUP_FIELD = "Matching Values"; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +export const runConditionalLookupAllMatchesCase = async ( + bugCase: BugCaseFor<"conditional-lookup-all-matches">, + context: BugRunContext, +): Promise => { + const config: ConditionalLookupAllMatchesCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const tableIds: string[] = []; + + if (config.sourceValues.length < 2) { + throw new Error("at least two matching source rows are required"); + } + + try { + const source = await createTable(baseId, { + name: `${suffix}-source`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: KEY_FIELD, type: FieldType.Number }, + { name: VALUE_FIELD, type: FieldType.SingleLineText }, + ], + records: config.sourceValues.map((value, index) => ({ + fields: { + [NAME_FIELD]: `source-${index + 1}`, + [KEY_FIELD]: config.matchKey, + [VALUE_FIELD]: value, + }, + })), + }); + tableIds.unshift(source.id); + + const host = await createTable(baseId, { + name: `${suffix}-host`, + fields: [ + { name: NAME_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { name: KEY_FIELD, type: FieldType.Number }, + ], + records: [ + { + fields: { [NAME_FIELD]: "host-row", [KEY_FIELD]: config.matchKey }, + }, + ], + }); + tableIds.unshift(host.id); + + const sourceKey = source.fields.find((field) => field.name === KEY_FIELD); + const sourceValue = source.fields.find( + (field) => field.name === VALUE_FIELD, + ); + const hostKey = host.fields.find((field) => field.name === KEY_FIELD); + const hostRecordId = host.records?.[0]?.id; + if (!sourceKey || !sourceValue || !hostKey || !hostRecordId) { + throw new Error("the conditional lookup fixture is incomplete"); + } + + const lookup = await createField(host.id, { + name: LOOKUP_FIELD, + type: FieldType.SingleLineText, + isLookup: true, + isConditionalLookup: true, + lookupOptions: { + foreignTableId: source.id, + lookupFieldId: sourceValue.id, + filter: { + conjunction: "and", + filterSet: [ + { + fieldId: sourceKey.id, + operator: "is", + value: { type: "field", fieldId: hostKey.id }, + }, + ], + }, + }, + }); + + const sourceRead = await apiGetRecords(source.id, { + fieldKeyType: FieldKeyType.Id, + take: config.sourceValues.length, + }); + const seededValues = sourceRead.data.records + .map((record) => String(record.fields[sourceValue.id] ?? "")) + .sort(); + const expectedValues = [...config.sourceValues].sort(); + if (JSON.stringify(seededValues) !== JSON.stringify(expectedValues)) { + throw new Error( + `the source rows contain ${JSON.stringify(seededValues)}, expected ${JSON.stringify(expectedValues)}`, + ); + } + + const initialHostRead = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + const routing = assertServedByV2(initialHostRead.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + const seededHost = initialHostRead.data.records.find( + (record) => record.id === hostRecordId, + ); + if (seededHost?.fields[hostKey.id] !== config.matchKey) { + throw new Error("the host match key did not land"); + } + + const probe = await bugCheckpoint( + "conditional-lookup-returns-every-matching-value", + async () => { + const deadline = Date.now() + config.settleTimeoutMs; + let observed: string[] = []; + for (;;) { + const response = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + const cell = response.data.records.find( + (record) => record.id === hostRecordId, + )?.fields[lookup.id]; + observed = (Array.isArray(cell) ? cell : cell == null ? [] : [cell]) + .map(String) + .sort(); + if (JSON.stringify(observed) === JSON.stringify(expectedValues)) { + return { observed }; + } + if (Date.now() >= deadline) { + throw new Error( + `the conditional lookup returned ${JSON.stringify(observed)}, expected every matching value ${JSON.stringify(expectedValues)}`, + ); + } + await sleep(config.pollIntervalMs); + } + }, + ); + + return { + details: { + sourceTableId: source.id, + hostTableId: host.id, + values: probe.observed, + routing, + }, + }; + } finally { + for (const tableId of tableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +}; diff --git a/framework/runners/lookup-user-filter-contract.runner.ts b/framework/runners/lookup-user-filter-contract.runner.ts new file mode 100644 index 0000000..cc92620 --- /dev/null +++ b/framework/runners/lookup-user-filter-contract.runner.ts @@ -0,0 +1,199 @@ +import { FieldKeyType, FieldType, hasAnyOf, Relationship } from "@teable/core"; +import { + createRecords as apiCreateRecords, + getRecords as apiGetRecords, + updateRecord as apiUpdateRecord, + updateViewFilter as apiUpdateViewFilter, +} from "@teable/openapi"; +import { + createField, + createTable, + getField, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { + BugCaseFor, + BugProbeResult, + BugRunContext, + LookupUserFilterContractCaseConfig, +} from "../types"; + +const TITLE_FIELD = "Title"; +const USER_FIELD = "Owner"; + +export const runLookupUserFilterContractCase = async ( + bugCase: BugCaseFor<"lookup-user-filter-contract">, + context: BugRunContext, +): Promise => { + const config: LookupUserFilterContractCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const tableIds: string[] = []; + + try { + const source = await createTable(baseId, { + name: `${suffix}-owners`, + fields: [ + { name: TITLE_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: USER_FIELD, + type: FieldType.User, + options: { isMultiple: false, shouldNotify: false }, + }, + ], + records: [], + }); + tableIds.unshift(source.id); + const ownerField = source.fields.find((field) => field.name === USER_FIELD); + if (!ownerField) { + throw new Error("the source user field is missing"); + } + const sourceRows = await apiCreateRecords(source.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [source.fields[0].id]: "current-owner", + [ownerField.id]: { + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }, + }, + }, + ], + }); + const sourceRecordId = sourceRows.data.records[0]?.id; + if (!sourceRecordId) { + throw new Error("the source owner row did not land"); + } + + const host = await createTable(baseId, { + name: `${suffix}-work`, + fields: [ + { name: TITLE_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [ + { fields: { [TITLE_FIELD]: config.matchedTitle } }, + { fields: { [TITLE_FIELD]: config.unmatchedTitle } }, + ], + }); + tableIds.unshift(host.id); + const matchedRecordId = host.records?.[0]?.id; + const unmatchedRecordId = host.records?.[1]?.id; + const viewId = host.views?.[0]?.id; + if (!matchedRecordId || !unmatchedRecordId || !viewId) { + throw new Error("the host rows or default view are missing"); + } + + const link = await createField(host.id, { + name: "Owners", + type: FieldType.Link, + options: { + relationship: Relationship.OneMany, + foreignTableId: source.id, + }, + }); + await apiUpdateRecord(host.id, matchedRecordId, { + fieldKeyType: FieldKeyType.Id, + record: { fields: { [link.id]: [{ id: sourceRecordId }] } }, + }); + const lookup = await createField(host.id, { + name: "Owner Lookup", + type: FieldType.User, + isLookup: true, + lookupOptions: { + foreignTableId: source.id, + linkFieldId: link.id, + lookupFieldId: ownerField.id, + }, + }); + + const unfiltered = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + viewId, + take: 2, + }); + const routing = assertServedByV2(unfiltered.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + if (unfiltered.data.records.length !== 2) { + throw new Error("the unfiltered view does not contain both host rows"); + } + const linkedCell = unfiltered.data.records.find( + (record) => record.id === matchedRecordId, + )?.fields[link.id]; + if (!Array.isArray(linkedCell) || linkedCell.length !== 1) { + throw new Error("the matching host row is not linked to its owner row"); + } + + const filter = { + conjunction: "and" as const, + filterSet: [ + { + fieldId: lookup.id, + operator: hasAnyOf.value, + value: [globalThis.testConfig.userId], + }, + ], + }; + const probe = await bugCheckpoint( + "multi-user-lookup-accepts-and-applies-a-multi-value-filter", + async () => { + const described = await getField(host.id, lookup.id); + if (described.isMultipleCellValue !== true) { + throw new Error( + `the one-to-many user lookup is described as isMultipleCellValue=${JSON.stringify(described.isMultipleCellValue)}, expected true`, + ); + } + + const saved = await apiUpdateViewFilter(host.id, viewId, { filter }); + const saveRouting = assertServedByV2(saved.headers, { + operation: "PUT /table/{tableId}/view/{viewId}/filter", + feature: "updateViewFilter", + }); + const filtered = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + viewId, + take: 2, + }); + const readRouting = assertServedByV2(filtered.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + const ids = filtered.data.records.map((record) => record.id); + if (ids.length !== 1 || ids[0] !== matchedRecordId) { + throw new Error( + `the multi-value user filter returned ${JSON.stringify(ids)}, expected only ${matchedRecordId}`, + ); + } + return { saveRouting, readRouting, ids }; + }, + ); + + return { + details: { + sourceTableId: source.id, + hostTableId: host.id, + matchedRecordId, + unmatchedRecordId, + returnedRecordIds: probe.ids, + routing, + saveRouting: probe.saveRouting, + readRouting: probe.readRouting, + }, + }; + } finally { + for (const tableId of tableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +}; diff --git a/framework/runners/lookup-user-recompute-reread.runner.ts b/framework/runners/lookup-user-recompute-reread.runner.ts new file mode 100644 index 0000000..c37afb3 --- /dev/null +++ b/framework/runners/lookup-user-recompute-reread.runner.ts @@ -0,0 +1,253 @@ +import { FieldKeyType, FieldType, Relationship, Role } from "@teable/core"; +import type { IUserMeVo } from "@teable/openapi"; +import { + createRecords as apiCreateRecords, + emailBaseInvitation, + getRecords as apiGetRecords, + updateRecord as apiUpdateRecord, + USER_ME, +} from "@teable/openapi"; +import { createNewUserAxios } from "../../../utils/axios-instance/new-user"; +import { + createField, + createTable, + getField, + permanentDeleteTable, +} from "../../../utils/init-app"; +import { bugCheckpoint } from "../checkpoint"; +import { assertServedByV2 } from "../engine"; +import type { + BugCaseFor, + BugProbeResult, + BugRunContext, + LookupUserRecomputeRereadCaseConfig, +} from "../types"; + +const TITLE_FIELD = "Title"; +const USER_FIELD = "Owner"; + +const sleep = (ms: number) => + new Promise((resolve) => setTimeout(resolve, ms)); + +const userIds = (cell: unknown): string[] => { + const values = Array.isArray(cell) ? cell : cell == null ? [] : [cell]; + return values + .map((value) => + typeof value === "object" && value && "id" in value + ? String((value as { id: unknown }).id) + : "", + ) + .filter(Boolean) + .sort(); +}; + +export const runLookupUserRecomputeRereadCase = async ( + bugCase: BugCaseFor<"lookup-user-recompute-reread">, + context: BugRunContext, +): Promise => { + const config: LookupUserRecomputeRereadCaseConfig = bugCase.config; + const baseId = globalThis.testConfig.baseId; + const suffix = `${config.tableNamePrefix}-${context.runId}`; + const tableIds: string[] = []; + + try { + const replacementEmail = `${suffix}@example.com`; + const replacementAxios = await createNewUserAxios({ + email: replacementEmail, + password: "12345678a", + }); + const replacement = (await replacementAxios.get(USER_ME)).data; + await emailBaseInvitation({ + baseId, + emailBaseInvitationRo: { + emails: [replacementEmail], + role: Role.Editor, + }, + }); + + const source = await createTable(baseId, { + name: `${suffix}-owners`, + fields: [ + { name: TITLE_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + { + name: USER_FIELD, + type: FieldType.User, + options: { isMultiple: false, shouldNotify: false }, + }, + ], + records: [], + }); + tableIds.unshift(source.id); + const ownerField = source.fields.find((field) => field.name === USER_FIELD); + if (!ownerField) { + throw new Error("the source user field is missing"); + } + const sourceRows = await apiCreateRecords(source.id, { + fieldKeyType: FieldKeyType.Id, + records: [ + { + fields: { + [source.fields[0].id]: config.sourceTitle, + [ownerField.id]: { + id: globalThis.testConfig.userId, + title: globalThis.testConfig.userName, + }, + }, + }, + ], + }); + const sourceRecordId = sourceRows.data.records[0]?.id; + if (!sourceRecordId) { + throw new Error("the source owner row did not land"); + } + + const host = await createTable(baseId, { + name: `${suffix}-work`, + fields: [ + { name: TITLE_FIELD, type: FieldType.SingleLineText, isPrimary: true }, + ], + records: [{ fields: { [TITLE_FIELD]: config.hostTitle } }], + }); + tableIds.unshift(host.id); + const hostRecordId = host.records?.[0]?.id; + if (!hostRecordId) { + throw new Error("the host row did not land"); + } + + const link = await createField(host.id, { + name: "Owners", + type: FieldType.Link, + options: { + relationship: Relationship.OneMany, + foreignTableId: source.id, + }, + }); + await apiUpdateRecord(host.id, hostRecordId, { + fieldKeyType: FieldKeyType.Id, + record: { fields: { [link.id]: [{ id: sourceRecordId }] } }, + }); + const lookup = await createField(host.id, { + name: "Owner Lookup", + type: FieldType.User, + isLookup: true, + lookupOptions: { + foreignTableId: source.id, + linkFieldId: link.id, + lookupFieldId: ownerField.id, + }, + }); + + const fixtureRead = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + const routing = assertServedByV2(fixtureRead.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + const linkCell = fixtureRead.data.records.find( + (record) => record.id === hostRecordId, + )?.fields[link.id]; + if (!Array.isArray(linkCell) || linkCell.length !== 1) { + throw new Error("the host row is not linked to its source owner row"); + } + + const readLookup = async () => { + const response = await apiGetRecords(host.id, { + fieldKeyType: FieldKeyType.Id, + take: 1, + }); + assertServedByV2(response.headers, { + operation: "GET /table/{tableId}/record", + feature: "getRecords", + }); + const cell = response.data.records.find( + (record) => record.id === hostRecordId, + )?.fields[lookup.id]; + return userIds(cell); + }; + + const probe = await bugCheckpoint( + "recomputed-user-lookup-survives-independent-rereads", + async () => { + const expectedInitial = [globalThis.testConfig.userId]; + const first = await readLookup(); + const second = await readLookup(); + if ( + JSON.stringify(first) !== JSON.stringify(expectedInitial) || + JSON.stringify(second) !== JSON.stringify(expectedInitial) + ) { + throw new Error( + `independent reads returned ${JSON.stringify(first)} and ${JSON.stringify(second)}, expected ${JSON.stringify(expectedInitial)}`, + ); + } + + await apiUpdateRecord(source.id, sourceRecordId, { + fieldKeyType: FieldKeyType.Id, + record: { + fields: { + [ownerField.id]: { + id: replacement.id, + title: replacement.name, + email: replacement.email, + }, + }, + }, + }); + + const expectedReplacement = [replacement.id]; + const deadline = Date.now() + config.settleTimeoutMs; + let after: string[] = []; + for (;;) { + after = await readLookup(); + if (JSON.stringify(after) === JSON.stringify(expectedReplacement)) { + break; + } + if (Date.now() >= deadline) { + throw new Error( + `after recomputation the lookup returned ${JSON.stringify(after)}, expected ${JSON.stringify(expectedReplacement)}`, + ); + } + await sleep(config.pollIntervalMs); + } + + const reread = await readLookup(); + if (JSON.stringify(reread) !== JSON.stringify(expectedReplacement)) { + throw new Error( + `the recomputed lookup reread as ${JSON.stringify(reread)}, expected ${JSON.stringify(expectedReplacement)}`, + ); + } + const described = await getField(host.id, lookup.id); + if (described.isMultipleCellValue !== true) { + throw new Error( + `the recomputed one-to-many user lookup is described as isMultipleCellValue=${JSON.stringify(described.isMultipleCellValue)}, expected true`, + ); + } + return { first, second, after, reread }; + }, + ); + + return { + details: { + sourceTableId: source.id, + hostTableId: host.id, + replacementUserId: replacement.id, + firstRead: probe.first, + secondRead: probe.second, + recomputedRead: probe.after, + refreshedRead: probe.reread, + routing, + }, + }; + } finally { + for (const tableId of tableIds) { + try { + await permanentDeleteTable(baseId, tableId); + } catch (error) { + console.warn( + `[e2e-lab] cleanup failed for ${bugCase.id} (table ${tableId}): ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } +}; diff --git a/framework/types.ts b/framework/types.ts index 972755e..ce9e05e 100644 --- a/framework/types.ts +++ b/framework/types.ts @@ -120,6 +120,10 @@ export interface BugCaseConfigByRunner { "lookup-of-link-contains": LookupOfLinkContainsCaseConfig; "delete-without-undo-capture": DeleteWithoutUndoCaptureCaseConfig; "single-field-pending-state": SingleFieldPendingStateCaseConfig; + "conditional-lookup-all-matches": ConditionalLookupAllMatchesCaseConfig; + "authority-unreadable-group": AuthorityUnreadableGroupCaseConfig; + "lookup-user-filter-contract": LookupUserFilterContractCaseConfig; + "lookup-user-recompute-reread": LookupUserRecomputeRereadCaseConfig; } export type BugRunnerKind = keyof BugCaseConfigByRunner; @@ -1872,6 +1876,39 @@ export interface LookupMultiplicityVoCaseConfig { linkedRowNames: string[]; } +export interface ConditionalLookupAllMatchesCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + matchKey: number; + sourceValues: string[]; + settleTimeoutMs: number; + pollIntervalMs: number; +} + +export interface AuthorityUnreadableGroupCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + rows: { title: string; group: string; status: string }[]; + subscribeTimeoutMs: number; + settleTimeoutMs: number; +} + +export interface LookupUserFilterContractCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + matchedTitle: string; + unmatchedTitle: string; +} + +export interface LookupUserRecomputeRereadCaseConfig { + baseId: "seed-base"; + tableNamePrefix: string; + sourceTitle: string; + hostTitle: string; + settleTimeoutMs: number; + pollIntervalMs: number; +} + export interface ProjectedGroupHeadersCaseConfig { baseId: "seed-base"; tableNamePrefix: string; diff --git a/registry.ts b/registry.ts index ef22839..aecd3f5 100644 --- a/registry.ts +++ b/registry.ts @@ -26,6 +26,10 @@ import trackedModifiedSortCase from "./cases/view/sort-by-a-narrowed-last-change import lookupOfLinkContainsCase from "./cases/filter/y173-search-a-borrowed-link-column.case"; import deleteWithoutUndoCaptureCase from "./cases/record/delete-a-row-whose-undo-bookkeeping-is-missing.case"; import singleFieldPendingStateCase from "./cases/field/y337-a-settled-column-read-on-its-own.case"; +import y249ConditionalLookupKeepsAllMatchesCase from "./cases/lookup/y249-conditional-lookup-keeps-all-matches.case"; +import y338UnreadableGroupStillLoadsCase from "./cases/authority/y338-unreadable-group-still-loads.case"; +import y339MultiUserLookupFilterContractCase from "./cases/filter/y339-multi-user-lookup-filter-contract.case"; +import y340UserLookupSurvivesRereadCase from "./cases/lookup/y340-user-lookup-survives-reread.case"; import sparseBatchUpdateCase from "./cases/record/y331-a-batch-write-leaves-what-it-did-not-mention.case"; import generatedFormulaColumnCase from "./cases/record/y244-edit-a-cell-behind-a-generated-formula.case"; import legacyGeneratedAuditColumnCase from "./cases/record/y241-add-a-row-to-a-legacy-table.case"; @@ -134,6 +138,10 @@ import type { BugCase } from "./framework/types"; // this file statically (imports + the array below), so the planner and the // checks can enumerate cases without resolving @teable/* packages. const cases = [ + y249ConditionalLookupKeepsAllMatchesCase, + y338UnreadableGroupStillLoadsCase, + y339MultiUserLookupFilterContractCase, + y340UserLookupSurvivesRereadCase, smokeAuthUserCase, recordBulkUpdate100MixedLandsCase, lookupOfRollupCreateCase,