From 75dc9f7d131c83f7fb987ead8bdb50c4773db50e Mon Sep 17 00:00:00 2001 From: Juha Paananen Date: Sat, 9 Mar 2024 14:57:54 +0200 Subject: [PATCH 1/5] Return ID from item create API --- README.md | 10 ++++++++-- backend/src/api/item-create.ts | 4 ++-- backend/src/api/utils.ts | 1 + 3 files changed, 11 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 21990da21..aa273cac5 100644 --- a/README.md +++ b/README.md @@ -83,8 +83,6 @@ All boards created accessible to anyone with the link by default. If you Sign In ## API -For a full list of API endpoints, see https://ourboard.io/api-docs. - All POST and PUT endpoints accept application/json content. API requests against boards with restricted access require you to supply an API_TOKEN header with a valid API token. @@ -147,6 +145,14 @@ Payload: } ``` +Response: + +```js +{ + "id": "ITEM_ID" +} +``` + ### PUT /api/v1/board/:boardId/item/:itemId Creates a new item on given board or updates an existing one. diff --git a/backend/src/api/item-create.ts b/backend/src/api/item-create.ts index 5f6c74914..9ece7ac3b 100644 --- a/backend/src/api/item-create.ts +++ b/backend/src/api/item-create.ts @@ -20,7 +20,7 @@ export const itemCreate = route checkBoardAPIAccess(request, async (board) => { const { type, text, color, container } = request.body console.log(`POST item for board ${board.board.id}: ${JSON.stringify(request.req.body)}`) - addItem(board, type, text, color, container) - return ok({ ok: true }) + const item = addItem(board, type, text, color, container) + return ok(item) }), ) diff --git a/backend/src/api/utils.ts b/backend/src/api/utils.ts index 9b5a75f24..9cecfaefe 100644 --- a/backend/src/api/utils.ts +++ b/backend/src/api/utils.ts @@ -108,6 +108,7 @@ export function addItem( const item: Note = { ...newNote(text, color || DEFAULT_NOTE_COLOR), ...itemAttributes } const appEvent: AppEvent = { action: "item.add", boardId: board.board.id, items: [item], connections: [] } dispatchSystemAppEvent(board, appEvent) + return item } export class InvalidRequest extends Error { From 91f7df3854c4ca42f03a597fa91caa11efd8ee7c Mon Sep 17 00:00:00 2001 From: Juha Paananen Date: Sat, 9 Mar 2024 14:58:42 +0200 Subject: [PATCH 2/5] Fix accessPolicy storage details, speed up board loading with better SQL query --- backend/src/api/board-update.ts | 10 +++++++--- backend/src/board-store.ts | 33 +++++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/backend/src/api/board-update.ts b/backend/src/api/board-update.ts index 56a3bf852..6d847ec77 100644 --- a/backend/src/api/board-update.ts +++ b/backend/src/api/board-update.ts @@ -4,7 +4,7 @@ import { ok } from "typera-common/response" import { body } from "typera-express/parser" import { BoardAccessPolicyCodec } from "../../../common/src/domain" import { updateBoard } from "../board-store" -import { apiTokenHeader, checkBoardAPIAccess, route } from "./utils" +import { apiTokenHeader, checkBoardAPIAccess, dispatchSystemAppEvent, route } from "./utils" /** * Changes board name and, optionally, access policy. @@ -15,10 +15,14 @@ export const boardUpdate = route .put("/api/v1/board/:boardId") .use(apiTokenHeader, body(t.type({ name: NonEmptyString, accessPolicy: BoardAccessPolicyCodec }))) .handler((request) => - checkBoardAPIAccess(request, async () => { + checkBoardAPIAccess(request, async (board) => { const { boardId } = request.routeParams const { name, accessPolicy } = request.body - await updateBoard({ boardId, name, accessPolicy }) + await updateBoard({ boardId, name, accessPolicy: accessPolicy ?? board.board.accessPolicy }) + dispatchSystemAppEvent(board, { action: "board.rename", boardId, name }) + if (accessPolicy) { + dispatchSystemAppEvent(board, { action: "board.setAccessPolicy", boardId, accessPolicy }) + } return ok({ ok: true }) }), ) diff --git a/backend/src/board-store.ts b/backend/src/board-store.ts index 97bf6acc7..3918800c3 100644 --- a/backend/src/board-store.ts +++ b/backend/src/board-store.ts @@ -24,17 +24,21 @@ export async function getBoardInfo(id: Id): Promise { } const selectBoardQuery = ` -with allow_lists as ( - select id, content, public_read, public_write, ( - select jsonb_agg(jsonb_build_object('domain', domain, 'access', access, 'email', email)) - from board_access a - where a.board_id = b.id - ) as allow_list - from board b for update -) select id, - jsonb_set (content - 'accessPolicy', '{accessPolicy}', cast(case when allow_list is null then 'null' else (json_build_object('allowList', allow_list, 'publicRead', public_read, 'publicWrite', public_write)) end as jsonb)) as content - from allow_lists + jsonb_set (content - 'accessPolicy', '{accessPolicy}', + cast(json_build_object( + 'allowList', ( + coalesce(( + select jsonb_agg(jsonb_strip_nulls(jsonb_build_object('domain', domain, 'access', access, 'email', email))) + from board_access + where board_access.board_id = board.id + ), '[]') + ), + 'publicRead', public_read, + 'publicWrite', public_write + ) as jsonb)) + as content +from board where id=$1 ` @@ -46,6 +50,15 @@ export async function fetchBoard(id: Id): Promise { return null } else { const snapshot = result.rows[0].content as Board + if ( + snapshot.accessPolicy && + snapshot.accessPolicy.allowList.length === 0 && + snapshot.accessPolicy.publicRead && + snapshot.accessPolicy.publicWrite + ) { + // Effectively no access policy + delete snapshot.accessPolicy + } let historyEventCount = 0 let lastSerial = 0 let board = snapshot From 91d70466777f72700e3f16c7d4dec52bd85ba3c4 Mon Sep 17 00:00:00 2001 From: Juha Paananen Date: Sat, 9 Mar 2024 14:59:01 +0200 Subject: [PATCH 3/5] Fix setting containerId in item update API --- backend/src/api/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/api/utils.ts b/backend/src/api/utils.ts index 9cecfaefe..27cc50fa1 100644 --- a/backend/src/api/utils.ts +++ b/backend/src/api/utils.ts @@ -73,7 +73,7 @@ export function getItemAttributesForContainer(container: string | undefined, boa const containerItem = findContainer(container, board) if (containerItem) { return { - containedId: containerItem.id, + containerId: containerItem.id, x: containerItem.x + 2, y: containerItem.y + 2, } From e19ea06b599bc4c6af96ad9f84b13cbe7f3a2ab0 Mon Sep 17 00:00:00 2001 From: Juha Paananen Date: Sat, 9 Mar 2024 14:59:24 +0200 Subject: [PATCH 4/5] Add Playwright tests for API and UI integration --- backend/src/oauth.ts | 7 + playwright.config.ts | 2 +- playwright/src/tests/api.spec.ts | 279 +++++++++++++++++++++++++++++++ 3 files changed, 287 insertions(+), 1 deletion(-) create mode 100644 playwright/src/tests/api.spec.ts diff --git a/backend/src/oauth.ts b/backend/src/oauth.ts index 2f0ad4493..c440858fb 100644 --- a/backend/src/oauth.ts +++ b/backend/src/oauth.ts @@ -48,6 +48,13 @@ export function setupAuth(app: Express, provider: AuthProvider) { } }) + app.get("/test-callback", async (req, res) => { + const cookies = new Cookies(req, res) + const returnTo = cookies.get("returnTo") || "/" + setAuthenticatedUser(req, res, { domain: null, email: "ourboardtester@test.com", name: "Ourboard tester" }) + res.redirect(returnTo) + }) + function parseReturnPath(req: Request) { return (req.query.returnTo as string) || "/" } diff --git a/playwright.config.ts b/playwright.config.ts index 28cf23173..1fd81efe8 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -11,7 +11,7 @@ const config: PlaywrightTestConfig = { timeout: 60000, // Timeout per test file (default 30000) retries: ci ? 2 : 0, use: { - baseURL: "http://localhost:8080", + baseURL: "http://localhost:1337", actionTimeout: 15000, trace: "retain-on-failure", }, diff --git a/playwright/src/tests/api.spec.ts b/playwright/src/tests/api.spec.ts new file mode 100644 index 000000000..e3b51f91a --- /dev/null +++ b/playwright/src/tests/api.spec.ts @@ -0,0 +1,279 @@ +import { Browser, Page, expect, test } from "@playwright/test" +import { sleep } from "../../../common/src/sleep" +import { BoardPage, navigateToBoard, navigateToNewBoard, semiUniqueId } from "../pages/BoardPage" + +async function loginAsTester(page: Page) { + await test.step("Login as tester", async () => { + await page.request.get("/test-callback") + }) +} + +async function logout(page: Page) { + await test.step("Logout", async () => { + await page.request.get("/logout") + }) +} + +test.describe("API endpoints", () => { + test("Create and update board", async ({ page, browser }) => { + const { id, accessToken } = await test.step("Create board", async () => { + const response = await page.request.post("/api/v1/board", { + data: { + name: "API test board", + }, + }) + return await response.json() + }) + + const board = await navigateToBoard(page, browser, id) + + await test.step("Check board name", async () => { + await board.assertBoardName("API test board") + const userPageNoteText = `note-${semiUniqueId()}` + await board.createNoteWithText(100, 200, userPageNoteText) + await board.createArea(100, 400, "API notes") + await board.createArea(550, 400, "More API notes") + }) + + await test.step("Set board name", async () => { + const response = await page.request.put(`/api/v1/board/${id}`, { + data: { + name: "Updated board name", + }, + headers: { + API_TOKEN: accessToken, + }, + }) + expect(response.status()).toEqual(200) + await board.assertBoardName("Updated board name") + }) + + const item = await test.step("Add item", async () => { + const response = await page.request.post(`/api/v1/board/${id}/item`, { + data: { + type: "note", + text: "API note", + container: "API notes", + color: "#000000", + }, + headers: { + API_TOKEN: accessToken, + }, + }) + expect(response.status()).toEqual(200) + await expect(board.getNote("API note")).toBeVisible() + return await response.json() + }) + + await test.step("Update item", async () => { + const response = await page.request.put(`/api/v1/board/${id}/item/${item.id}`, { + data: { + type: "note", + text: "Updated item", + color: "#000000", + }, + headers: { + API_TOKEN: accessToken, + }, + }) + expect(response.status()).toEqual(200) + await expect(board.getNote("Updated item")).toBeVisible() + }) + + await test.step("Change item container", async () => { + await board.assertItemPosition(board.getNote("Updated item"), 163, 460) + const response = await page.request.put(`/api/v1/board/${id}/item/${item.id}`, { + data: { + type: "note", + text: "Updated item", + color: "#000000", + container: "More API notes", + }, + headers: { + API_TOKEN: accessToken, + }, + }) + expect(response.status()).toEqual(200) + await sleep(1000) + await board.assertItemPosition(board.getNote("Updated item"), 613, 460) + }) + + await test.step("Get board state", async () => { + const response = await page.request.get(`/api/v1/board/${id}`, { + headers: { + API_TOKEN: accessToken, + }, + }) + const content = await response.json() + expect(content).toEqual({ + board: { + id, + name: "Updated board name", + width: 800, + height: 600, + serial: expect.anything(), + connections: [], + items: expect.anything(), + }, + }) + }) + + await test.step("Get board state hierarchy", async () => { + const response = await page.request.get(`/api/v1/board/${id}/hierarchy`, { + headers: { + API_TOKEN: accessToken, + }, + }) + const content = await response.json() + expect(content).toEqual({ + board: { + id, + name: "Updated board name", + width: 800, + height: 600, + serial: expect.anything(), + connections: [], + items: expect.anything(), + }, + }) + }) + + await test.step("Get board history", async () => { + const response = await page.request.get(`/api/v1/board/${id}/history`, { + headers: { + API_TOKEN: accessToken, + }, + }) + const content = await response.json() + expect(content).toEqual(expect.arrayContaining([])) + }) + + await test.step("Get board as CSV", async () => { + const response = await page.request.get(`/api/v1/board/${id}/csv`, { + headers: { + API_TOKEN: accessToken, + }, + }) + const content = await response.text() + expect(content).toEqual("More API notes,Updated item\n") + }) + + await test.step("Set accessPolicy", async () => { + const response = await page.request.put(`/api/v1/board/${id}`, { + data: { + name: "Updated board name", + accessPolicy: { + allowList: [], + publicRead: false, + publicWrite: false, + }, + }, + headers: { + API_TOKEN: accessToken, + }, + }) + expect(response.status()).toEqual(200) + await page.reload() + await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.") + + expect( + ( + await ( + await page.request.get(`/api/v1/board/${id}`, { + headers: { + API_TOKEN: accessToken, + }, + }) + ).json() + ).board.accessPolicy, + ).toEqual({ + allowList: [], + publicRead: false, + publicWrite: false, + }) + }) + + await test.step("Update accessPolicy", async () => { + const newAccessPolicy = { + allowList: [{ email: "ourboardtester@test.com" }], + publicRead: true, + publicWrite: true, + } + const response = await page.request.put(`/api/v1/board/${id}`, { + data: { + name: "Updated board name", + accessPolicy: newAccessPolicy, + }, + headers: { + API_TOKEN: accessToken, + }, + }) + expect(response.status()).toEqual(200) + await page.reload() + await expect(board.getNote("Updated item")).toBeVisible() + + expect( + ( + await ( + await page.request.get(`/api/v1/board/${id}`, { + headers: { + API_TOKEN: accessToken, + }, + }) + ).json() + ).board.accessPolicy, + ).toEqual(newAccessPolicy) + }) + }) + + test("Create board with accessPolicy", async ({ page, browser }) => { + await test.step("With empty accessPolicy", async () => { + const board = await test.step("Create board and navigate", async () => { + const response = await page.request.post("/api/v1/board", { + data: { + name: "API restricted board", + accessPolicy: { + allowList: [], + }, + }, + }) + const { id, accessToken } = await response.json() + return await navigateToBoard(page, browser, id) + }) + + await test.step("Verify no UI access", async () => { + await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.") + await loginAsTester(page) + await page.reload() + await board.assertStatusMessage("Sorry, access denied. Click here to sign in with another account.") + await logout(page) + }) + }) + + await test.step("With non-empty accessPolicy", async () => { + const board = await test.step("Create board and navigate", async () => { + const response = await page.request.post("/api/v1/board", { + data: { + name: "API restricted board", + accessPolicy: { + allowList: [{ email: "ourboardtester@test.com" }], + }, + }, + }) + const { id, accessToken } = await response.json() + return await navigateToBoard(page, browser, id) + }) + + await test.step("Verify restricted access", async () => { + await board.assertStatusMessage("This board is for authorized users only. Click here to sign in.") + + await loginAsTester(page) + + await page.reload() + await board.assertBoardName("API restricted board") + }) + }) + }) + + test("Get board contents with CRDT text", async ({ page, browser }) => {}) +}) From 3ae25d8933fd4274eb29407664c64339e08f73e8 Mon Sep 17 00:00:00 2001 From: Juha Paananen Date: Sat, 9 Mar 2024 15:31:46 +0200 Subject: [PATCH 5/5] Support test user authentication even without authentication provider --- backend/src/express-server.ts | 13 +++++++++++++ backend/src/oauth.ts | 7 ------- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/backend/src/express-server.ts b/backend/src/express-server.ts index dbb70a6be..17ebcae8e 100644 --- a/backend/src/express-server.ts +++ b/backend/src/express-server.ts @@ -17,6 +17,8 @@ import openapiDoc from "./openapi" import { possiblyRequireAuth } from "./require-auth" import { createGetSignedPutUrl } from "./storage" import { WsWrapper } from "./ws-wrapper" +import Cookies from "cookies" +import { removeAuthenticatedUser, setAuthenticatedUser } from "./http-session" dotenv.config() @@ -27,7 +29,18 @@ export const startExpressServer = (httpPort?: number, httpsPort?: number): (() = if (authProvider) { setupAuth(app, authProvider) + } else { + app.get("/logout", async (req, res) => { + removeAuthenticatedUser(req, res) + res.redirect("/") + }) } + app.get("/test-callback", async (req, res) => { + const cookies = new Cookies(req, res) + const returnTo = cookies.get("returnTo") || "/" + setAuthenticatedUser(req, res, { domain: null, email: "ourboardtester@test.com", name: "Ourboard tester" }) + res.redirect(returnTo) + }) possiblyRequireAuth(app) diff --git a/backend/src/oauth.ts b/backend/src/oauth.ts index c440858fb..2f0ad4493 100644 --- a/backend/src/oauth.ts +++ b/backend/src/oauth.ts @@ -48,13 +48,6 @@ export function setupAuth(app: Express, provider: AuthProvider) { } }) - app.get("/test-callback", async (req, res) => { - const cookies = new Cookies(req, res) - const returnTo = cookies.get("returnTo") || "/" - setAuthenticatedUser(req, res, { domain: null, email: "ourboardtester@test.com", name: "Ourboard tester" }) - res.redirect(returnTo) - }) - function parseReturnPath(req: Request) { return (req.query.returnTo as string) || "/" }