Przeglądaj źródła

End-to-end coverage for admin-only actions, file paths, payment attribution and inbound SMS (#381)

Bernt Christian Egeland 2 tygodni temu
rodzic
commit
4525f9a09a

+ 2 - 0
e2e/README.md

@@ -31,6 +31,8 @@ e2e/
     reminders/         a due time survives being displayed and re-saved
     email/             the email template designer, and the mail it sends
     payments/          a customer pays online, and the payment is booked once
+    security/          the doors the September 2026 audit found open: admin-only
+                       actions, file paths, payment attribution, webhook signatures
     cloud/             run with E2E_MODE=cloud: plan limits, Google sign-in, the sign-up pitch
     tech/              the technician app's API contract
     smoke/             the build is alive

+ 225 - 0
e2e/specs/security/admin-only.spec.ts

@@ -0,0 +1,225 @@
+import { expect, type Browser, type Page, test } from '@playwright/test'
+import {
+  contentCounts,
+  createRoleWithEveryPermission,
+  invitationTokenFor,
+  ownerOrganizationId,
+  setMembership,
+} from '../../support/db'
+import { settle } from '../../support/hydration'
+import { linkIn, waitForMail } from '../../support/mail'
+
+/**
+ * Logged in is not allowed.
+ *
+ * The September 2026 audit found a class of actions and routes that checked
+ * for a session and a permission, and nothing more: any member could wipe the
+ * workshop's records, export the whole organisation or replace it with an
+ * empty backup, change the plan and charge the card, and a settings manager
+ * could invite a second address of their own as admin. All of them are
+ * owner-or-admin decisions now, whatever permissions a custom role carries.
+ *
+ * So a colleague is given a role with every permission the app knows and no
+ * admin standing, which is the member every permission check waves through,
+ * and is then pointed at each of those doors.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+// The colleague starts as a stranger with no session.
+test.use({ storageState: { cookies: [], origins: [] } })
+
+const stamp = Date.now()
+const MANAGER = `e2e-manager-${stamp}@example.com`
+const PASSWORD = `E2e-pass-${stamp}`
+const SECRET_INVITEE = `e2e-secret-${stamp}@example.com`
+const ADMIN_INVITEE = `e2e-admin-${stamp}@example.com`
+const MEMBER_INVITEE = `e2e-member-${stamp}@example.com`
+
+let organizationId = ''
+let roleId = ''
+
+async function signIn(page: Page, email: string, password: string) {
+  await page.goto('/auth/sign-in')
+  await page.locator('#email').fill(email)
+  await page.locator('#password').fill(password)
+  await page.getByRole('button', { name: 'Sign In', exact: true }).click()
+  await page.waitForURL((url) => !url.pathname.startsWith('/auth'), { timeout: 30_000 })
+}
+
+/**
+ * Opens the team page's Add dialog and sends an invitation to `email` as
+ * "someone in the office", optionally as an Admin. The dialog is left open so
+ * the caller can read what it said.
+ */
+async function invite(page: Page, email: string, role?: 'Admin') {
+  await page.goto('/settings/team')
+  await settle(page)
+  await expect(async () => {
+    await page.getByRole('button', { name: 'Add', exact: true }).first().click()
+    await expect(page.getByText('Someone in the office')).toBeVisible({ timeout: 2_000 })
+  }).toPass({ timeout: 30_000 })
+  await page.getByText('Someone in the office').click()
+  await page.locator('#member-email').fill(email)
+  if (role) {
+    const dialog = page.getByRole('dialog').filter({ has: page.locator('#member-email') })
+    await dialog.getByRole('combobox').click()
+    await page.getByRole('option', { name: role, exact: true }).click()
+  }
+  await page.getByRole('button', { name: 'Invite', exact: true }).click()
+}
+
+/** The owner invites an address and sees it listed as pending. */
+async function ownerInvites(browser: Browser, email: string) {
+  const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+  const page = await owner.newPage()
+  await invite(page, email)
+  await expect(page.getByText(email).first()).toBeVisible({ timeout: 30_000 })
+  await owner.close()
+}
+
+/** The API routes are written to, so the request carries the app's own origin. */
+const sameOrigin = { origin: process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100' }
+
+test.afterAll(async () => {
+  // The last test makes the manager an admin; the workshop is left with one
+  // more ordinary member, not one more admin.
+  if (organizationId && roleId) {
+    await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
+  }
+})
+
+test.describe('a member with every permission and no admin standing', () => {
+  test('is invited by the owner, signs up, and is given the role', async ({ page, browser }) => {
+    await ownerInvites(browser, MANAGER)
+
+    const invitation = await waitForMail(MANAGER)
+    await page.goto(linkIn(invitation, /\/auth\/sign-up\?invite=/))
+    await page.locator('#name').fill('E2E Settings Manager')
+    await page.locator('#email').fill(MANAGER)
+    await page.locator('#password').fill(PASSWORD)
+    await page.locator('#terms').click()
+    await page.getByRole('button', { name: /create account/i }).click()
+    await page.waitForURL((url) => !/^\/(auth|onboarding)/.test(url.pathname), { timeout: 30_000 })
+
+    organizationId = await ownerOrganizationId()
+    roleId = await createRoleWithEveryPermission(organizationId, `E2E Everything ${stamp}`)
+    await setMembership(MANAGER, organizationId, { roleId, role: 'member' })
+
+    // The role opens the whole application to them, which is what makes the
+    // refusals below worth anything: they are not a roleless member being
+    // turned away at the door.
+    await signIn(page, MANAGER, PASSWORD)
+    await page.goto('/settings/team')
+    await expect(page.getByRole('heading', { name: 'No access yet' })).toHaveCount(0)
+    await expect(page.getByText(MANAGER).first()).toBeVisible()
+  })
+
+  test('cannot export the workshop, or replace it from a backup', async ({ page }) => {
+    await signIn(page, MANAGER, PASSWORD)
+
+    for (const route of [
+      'backup/export',
+      'backup/import',
+      'backup/import-lubelog',
+      'backup/import-invoice-ninja',
+    ]) {
+      // Refused before the body is looked at: an import that got as far as
+      // parsing would already be past the check that matters.
+      const response = await page.request.post(`/api/protected/${route}`, {
+        data: { version: 2, data: {} },
+        headers: sameOrigin,
+      })
+      expect(response.status(), `${route} is refused`).toBe(403)
+      expect(await response.json()).toEqual({ error: 'Forbidden' })
+    }
+  })
+
+  test('cannot wipe the workshop’s records from the data page', async ({ page }) => {
+    await signIn(page, MANAGER, PASSWORD)
+    const before = await contentCounts(organizationId)
+
+    // The page offers the button to anyone who can open it; the action is
+    // what has to say no.
+    await page.goto('/settings/data')
+    await settle(page)
+    const dialog = page.getByRole('dialog', { name: 'Delete Content' })
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Delete Content', exact: true }).first().click()
+      await expect(dialog).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+
+    await dialog.getByRole('checkbox', { disabled: false }).first().click()
+    await dialog.getByPlaceholder('delete my data').fill('delete my data')
+    // The confirm button counts what it would delete: "Delete 1 selected".
+    await dialog.getByRole('button', { name: /^Delete \d+ selected$/ }).click()
+
+    await expect(page.getByText('Only an owner or admin can delete workshop content')).toBeVisible({
+      timeout: 30_000,
+    })
+    expect(await contentCounts(organizationId), 'nothing was deleted').toEqual(before)
+  })
+
+  test('cannot change the plan or reach the card', async ({ page }) => {
+    await signIn(page, MANAGER, PASSWORD)
+
+    for (const route of ['upgrade', 'checkout', 'upgrade-preview', 'billing-portal']) {
+      const response = await page.request.post(`/api/protected/subscription/${route}`, {
+        data: { plan: 'enterprise' },
+        headers: sameOrigin,
+      })
+      expect(response.status(), `${route} is refused`).toBe(403)
+      expect(await response.json()).toEqual({ error: 'Forbidden' })
+    }
+  })
+
+  test('is not offered a way to bring people in', async ({ page }) => {
+    // Inviting is an admin's call, and the rule sits in the action
+    // (`canInvite`, with its own unit tests). The page agrees with it: the
+    // button is not there for a member, however wide their role.
+    await signIn(page, MANAGER, PASSWORD)
+    await page.goto('/settings/team')
+    await settle(page)
+    await expect(page.getByText(MANAGER).first()).toBeVisible()
+    await expect(page.getByRole('button', { name: 'Add', exact: true })).toHaveCount(0)
+  })
+})
+
+test.describe('an invitation', () => {
+  test('keeps its token in the invitee’s inbox and off the team page', async ({ browser }) => {
+    // The token is the credential that lets whoever holds it join as the
+    // invitee. It used to be returned to everyone who could read the team
+    // page, which let a member read the token for the invited boss's address
+    // and sign up with it.
+    await ownerInvites(browser, SECRET_INVITEE)
+    const token = await invitationTokenFor(SECRET_INVITEE, organizationId)
+    expect(token, 'the invitation exists').toBeTruthy()
+
+    const mail = await waitForMail(SECRET_INVITEE)
+    expect(`${mail.html}\n${mail.text}`, 'the invitee is sent the token').toContain(token)
+
+    const owner = await browser.newContext({ storageState: 'e2e/.auth/owner.json' })
+    const html = await (await owner.request.get('/settings/team')).text()
+    await owner.close()
+    expect(html, 'the team page lists the invitation').toContain(SECRET_INVITEE)
+    expect(html, 'without its token').not.toContain(token as string)
+  })
+
+  test('as admin can only come from the owner', async ({ page }) => {
+    // The manager is made an admin: they may bring people in now, and may
+    // still not hand out admin, which is how a settings manager once walked
+    // in as one.
+    await setMembership(MANAGER, organizationId, { roleId, role: 'admin' })
+    await signIn(page, MANAGER, PASSWORD)
+
+    await invite(page, ADMIN_INVITEE, 'Admin')
+    await expect(page.getByText('Only the owner can invite admins')).toBeVisible({
+      timeout: 30_000,
+    })
+    expect(await invitationTokenFor(ADMIN_INVITEE, organizationId), 'nothing was sent').toBeNull()
+
+    await invite(page, MEMBER_INVITEE)
+    await expect(page.getByText(MEMBER_INVITEE).first()).toBeVisible({ timeout: 30_000 })
+    expect(await invitationTokenFor(MEMBER_INVITEE, organizationId)).toBeTruthy()
+  })
+})

+ 278 - 0
e2e/specs/security/files.spec.ts

@@ -0,0 +1,278 @@
+import { existsSync } from 'node:fs'
+import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
+import path from 'node:path'
+import { expect, type Page, test } from '@playwright/test'
+import {
+  deleteServiceAttachments,
+  insertServiceAttachment,
+  ownerOrganizationId,
+  serviceAttachmentsNamed,
+} from '../../support/db'
+import { pdfContent, TINY_PNG } from '../../support/pdf'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * A stored file URL is data somebody typed at some point.
+ *
+ * Every file a workshop uploads is kept as a URL on a record, and that URL is
+ * later turned into a path on disk and read, copied or unlinked. The audit
+ * found it joined onto the upload folder with no containment check, so a
+ * record carrying `../../.env` read the server's own files into a PDF. Three
+ * layers stand in the way now: the file routes refuse a path with a dot pair,
+ * the actions refuse to store a URL that is not one of this workshop's own
+ * uploads, and the path resolver refuses to leave the folder for whatever is
+ * stored already.
+ *
+ * And an SVG is a document with scripts in it: uploaded as a logo it ran for
+ * every visitor on the app's origin. Uploads are decoded and re-encoded now,
+ * and a stored SVG is offered as a download inside a sandbox.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+/** A real picture of some size, so drawing it is visible in the PDF's bytes. */
+const LOGO = path.join('public', 'torqvoice_app_logo.png')
+
+let organizationId = ''
+let jobUrl = ''
+let jobId = ''
+/** The share token of the job's invoice, for the public file route. */
+let shareToken = ''
+
+async function workshopCopy(page: Page) {
+  const response = await page.request.get(`/api/protected/services/${jobId}/pdf`, {
+    timeout: 60_000,
+  })
+  expect(response.status(), 'the workshop can always get its invoice').toBe(200)
+  const body = await response.body()
+  return { bytes: body.length, ...(await pdfContent(body)) }
+}
+
+/** Where the app writes uploads, when the suite shares a disk with it. */
+function uploadDir(...segments: string[]): string {
+  return path.join('data', 'uploads', organizationId, ...segments)
+}
+
+test.beforeAll(async ({ browser }) => {
+  organizationId = await ownerOrganizationId()
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  const vehicleUrl = await seededVehicleUrl(page)
+  jobUrl = await newWorkOrder(page, vehicleUrl, `E2E file safety ${stamp}`)
+  jobId = jobUrl.split('/').pop() ?? ''
+  await addPart(page, { name: `E2E gasket ${stamp}`, quantity: 1, unitPrice: 100 })
+  await saveWorkOrder(page)
+  shareToken = new URL(await shareLink(page)).pathname.split('/').pop() ?? ''
+  await page.close()
+})
+
+test.describe('the file routes', () => {
+  test('refuse a path that climbs out of the upload folder', async ({ page }) => {
+    // Encoded, because a browser would fold a literal `..` away before the
+    // request left it; a client that wants the traversal does not.
+    const climbs = [
+      '..%2F..%2F..%2F..%2Fpackage.json',
+      '%2E%2E%2F%2E%2E%2Fpackage.json',
+      '..%5C..%5Cpackage.json',
+    ]
+    for (const climb of climbs) {
+      for (const url of [
+        `/api/protected/files/${organizationId}/services/${climb}`,
+        `/api/public/files/${shareToken}/services/${climb}`,
+      ]) {
+        const response = await page.request.get(url)
+        expect([400, 404], `${url} is refused`).toContain(response.status())
+        expect(await response.text(), 'and nothing of the file came back').not.toContain(
+          '"scripts"'
+        )
+      }
+    }
+  })
+})
+
+test.describe('a file URL on a record', () => {
+  test('is not stored unless it is one of this workshop’s own uploads', async ({ browser }) => {
+    // The client uploads the file, then hands the answer's URL to the action
+    // that puts it on the job. Here the answer is rewritten on its way back,
+    // which is what a client that wanted to would do.
+    const forged = [
+      { url: `/api/protected/files/${organizationId}/services/../../../../package.json` },
+      { url: '/api/files/../../.env' },
+      { url: `/api/protected/files/not-this-workshop/services/${stamp}.txt` },
+      { url: `https://example.com/${stamp}.txt` },
+    ]
+    const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+    await page.goto(jobUrl)
+
+    for (const [i, { url }] of forged.entries()) {
+      const name = `e2e-forged-${i}-${stamp}.txt`
+      await page.route('**/api/protected/upload/service-files', async (route) => {
+        const response = await route.fetch()
+        const json = (await response.json()) as Record<string, unknown>
+        await route.fulfill({ response, json: { ...json, url } })
+      })
+
+      await expect(async () => {
+        await page.getByRole('button', { name: /^Documents/ }).click()
+        await expect(page.locator('input[type="file"]').first()).toBeAttached({ timeout: 2_000 })
+      }).toPass({ timeout: 30_000 })
+      await page
+        .locator('input[type="file"][accept=".pdf,.csv,.txt"]')
+        .setInputFiles({ name, mimeType: 'text/plain', buffer: Buffer.from('forged') })
+
+      await expect(
+        page.getByText(/not an upload of this workshop/i).first(),
+        `${url} is refused, and the page says why`
+      ).toBeVisible({ timeout: 30_000 })
+      await page.unroute('**/api/protected/upload/service-files')
+      expect(await serviceAttachmentsNamed(name), `${url} was not stored`).toBe(0)
+    }
+    await page.close()
+  })
+
+  test('that climbs out of the folder is listed on the invoice, never read', async ({ page }) => {
+    // Rows written straight to the database, as records from before the
+    // schema guard would be. The oracle is the printed document: a picture
+    // that is drawn gets a "Service Images" page of its own, one that is
+    // only listed adds its name to the invoice and nothing else. (Byte size
+    // would not do: a flat-colour logo deflates to a kilobyte once the
+    // renderer re-encodes it.) The picture goes up through the upload route
+    // rather than the images tab, which re-encodes what it is given.
+    const uploaded = await page.request.post('/api/protected/upload/service-files', {
+      multipart: {
+        file: {
+          name: `e2e-real-${stamp}.png`,
+          mimeType: 'image/png',
+          buffer: await readFile(LOGO),
+        },
+      },
+    })
+    expect(uploaded.status()).toBe(200)
+    const realFile = ((await uploaded.json()) as { url: string }).url.split('/').pop()
+    const bare = await workshopCopy(page)
+
+    const withRow = async (fileName: string, fileUrl: string) => {
+      const id = await insertServiceAttachment({
+        serviceRecordId: jobId,
+        fileName,
+        fileUrl,
+        fileType: 'image/png',
+      })
+      try {
+        return await workshopCopy(page)
+      } finally {
+        await deleteServiceAttachments([id])
+      }
+    }
+
+    // The control: the uploaded file, reached by climbing out of the folder
+    // and straight back in. It stays inside, so it is drawn, which proves the
+    // climb below starts where the app's upload folder is.
+    const control = await withRow(
+      `e2e-control-${stamp}.png`,
+      `/api/protected/files/${organizationId}/services/../../../../data/uploads/${organizationId}/services/${realFile}`
+    )
+    expect(control.pages, 'a path that stays inside the folder is drawn').toBe(bare.pages + 1)
+    expect(control.flat).toContain('Service Images')
+    expect(control.flat).toContain(`e2e-control-${stamp}.png`)
+
+    // The escape: the same climb, ending in a real picture outside the
+    // folder. Listed by name, and not one pixel of it in the document.
+    const escaped = await withRow(
+      `e2e-escape-${stamp}.png`,
+      `/api/protected/files/${organizationId}/services/../../../../${LOGO}`
+    )
+    expect(escaped.flat, 'the invoice still names the file').toContain(`e2e-escape-${stamp}.png`)
+    expect(escaped.pages, 'but did not draw it').toBe(bare.pages)
+    expect(escaped.flat).not.toContain('Service Images')
+  })
+})
+
+test.describe('an SVG', () => {
+  test('is not accepted as a logo or a portal background, whatever it is called', async ({
+    page,
+  }) => {
+    const svg = Buffer.from(
+      '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
+    )
+    for (const route of ['logo', 'portal-background']) {
+      const url = `/api/protected/upload/${route}`
+      const declared = await page.request.post(url, {
+        multipart: { file: { name: 'logo.svg', mimeType: 'image/svg+xml', buffer: svg } },
+      })
+      expect(declared.status(), `${route}: an SVG declared as one`).toBe(400)
+
+      // Declared as a PNG, which is what a client that wanted it stored would
+      // say. The bytes are decoded before anything is written, and these do
+      // not decode.
+      const disguised = await page.request.post(url, {
+        multipart: { file: { name: 'logo.png', mimeType: 'image/png', buffer: svg } },
+      })
+      expect(disguised.status(), `${route}: an SVG declared as a PNG`).toBe(400)
+    }
+  })
+
+  test('already on disk is a download inside a sandbox, never a page on the app’s origin', async ({
+    page,
+  }) => {
+    // Written straight into the upload folder, as a file from before the
+    // upload routes re-encoded would be. Only possible when the suite shares
+    // a disk with the server, which it does locally and on CI.
+    test.skip(!existsSync(uploadDir()), 'the suite does not share a disk with the app server')
+
+    const name = `e2e-${stamp}.svg`
+    await mkdir(uploadDir('logos'), { recursive: true })
+    await writeFile(
+      uploadDir('logos', name),
+      '<svg xmlns="http://www.w3.org/2000/svg"><script>document.title="owned"</script></svg>'
+    )
+    try {
+      for (const url of [
+        `/api/protected/files/${organizationId}/logos/${name}`,
+        `/api/public/files/${shareToken}/logos/${name}`,
+      ]) {
+        const response = await page.request.get(url)
+        expect(response.status(), `${url} is served`).toBe(200)
+        const headers = response.headers()
+        expect(headers['content-disposition'], `${url} is a download`).toBe('attachment')
+        expect(headers['content-security-policy'], `${url} is sandboxed`).toContain('sandbox')
+        expect(headers['x-content-type-options']).toBe('nosniff')
+      }
+    } finally {
+      await unlink(uploadDir('logos', name))
+    }
+  })
+
+  test('is refused where a picture is expected, and a picture is stored as what it is', async ({
+    page,
+  }) => {
+    // The stored file's extension is what the bytes turned out to be, not
+    // what the name said; a PNG called .svg is a .png on disk, and is served
+    // as one. The portal background is the route without a side effect on
+    // the workshop's current logo.
+    const response = await page.request.post('/api/protected/upload/portal-background', {
+      multipart: { file: { name: 'picture.svg', mimeType: 'image/png', buffer: TINY_PNG } },
+    })
+    expect(response.status()).toBe(200)
+    const { url } = (await response.json()) as { url: string }
+    expect(url).toMatch(
+      new RegExp(`^/api/protected/files/${organizationId}/portal/[0-9a-f-]+\\.png$`)
+    )
+
+    const served = await page.request.get(url)
+    expect(served.status()).toBe(200)
+    expect(served.headers()['content-type']).toBe('image/png')
+
+    // Tidied away when the suite shares a disk with the server; otherwise the
+    // stray background stays where every other spec's uploads do.
+    if (existsSync(uploadDir('portal'))) {
+      await unlink(uploadDir('portal', url.split('/').pop() ?? ''))
+    }
+  })
+})

+ 187 - 0
e2e/specs/security/payment-attribution.spec.ts

@@ -0,0 +1,187 @@
+import { expect, type Page, test } from '@playwright/test'
+import { forgetConnections, ownerOrganizationId, paymentsFor } from '../../support/db'
+import { settle } from '../../support/hydration'
+import {
+  clearPaymentSink,
+  connectVendor,
+  expectConnection,
+  paymentSink,
+  type SinkPayPalOrder,
+} from '../../support/payments'
+import {
+  addPart,
+  newWorkOrder,
+  saveWorkOrder,
+  seededVehicleUrl,
+  shareLink,
+} from '../../support/work-order'
+
+/**
+ * A payment settles the invoice it was made for, and no other.
+ *
+ * The return page and the vendor's notification both name an order and an
+ * invoice, and the audit found the app checked that the order was paid but
+ * never that it was created for that invoice. Paying one unit on your own
+ * invoice and posting the order against somebody else's marked theirs as
+ * paid, and the idempotency key then blocked the real payment. The vendor's
+ * own record of who the order was for is compared now, as Stripe's always
+ * was.
+ *
+ * Two invoices, one payment on the first, and the paid order pointed at the
+ * second through both doors.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const sinkUrl = process.env.E2E_PAYMENT_SINK ?? 'http://127.0.0.1:8026'
+
+let organizationId = ''
+/** The invoice that is paid, and the one the payment is pointed at. */
+let paidJobId = ''
+let paidInvoiceUrl = ''
+let otherJobId = ''
+let otherInvoiceUrl = ''
+/** The PayPal order paid on the first invoice. */
+let order: SinkPayPalOrder | undefined
+
+async function makeSharedInvoice(page: Page, title: string) {
+  const vehicleUrl = await seededVehicleUrl(page)
+  const jobUrl = await newWorkOrder(page, vehicleUrl, title)
+  await addPart(page, { name: `E2E belt ${stamp}`, quantity: 1, unitPrice: 800 })
+  await saveWorkOrder(page)
+  return { jobId: jobUrl.split('/').pop() ?? '', invoiceUrl: await shareLink(page) }
+}
+
+function shareParts(invoiceUrl: string) {
+  const [org, token] = new URL(invoiceUrl).pathname.split('/').slice(-2)
+  return { org, token }
+}
+
+test.beforeAll(async ({ browser }) => {
+  await clearPaymentSink()
+  await forgetConnections(['paypal'])
+  organizationId = await ownerOrganizationId()
+
+  const page = await browser.newPage({ storageState: 'e2e/.auth/owner.json' })
+  await connectVendor(page, 'paypal', {
+    clientId: `e2e-client-${stamp}`,
+    clientSecret: `e2e-secret-${stamp}`,
+  })
+  await expectConnection('paypal', 'active')
+  ;({ jobId: paidJobId, invoiceUrl: paidInvoiceUrl } = await makeSharedInvoice(
+    page,
+    `E2E paid invoice ${stamp}`
+  ))
+  ;({ jobId: otherJobId, invoiceUrl: otherInvoiceUrl } = await makeSharedInvoice(
+    page,
+    `E2E other invoice ${stamp}`
+  ))
+  await page.close()
+})
+
+test.afterAll(async () => {
+  // A connected vendor puts a pay button on every shared invoice.
+  await forgetConnections(['paypal'])
+})
+
+test.describe('a PayPal order', () => {
+  test('paid on one invoice is booked on that invoice', async ({ page }) => {
+    await page.goto(paidInvoiceUrl)
+    await settle(page)
+    await expect(async () => {
+      await page.getByRole('button', { name: 'Partial payment', exact: true }).click()
+      await expect(page.locator('#payAmount')).toBeVisible({ timeout: 2_000 })
+    }).toPass({ timeout: 30_000 })
+    await page.locator('#payAmount').fill('100')
+    await page.getByRole('button', { name: /with PayPal$/ }).click()
+    await expect(page.getByRole('heading', { name: /checkout/ })).toBeVisible({ timeout: 30_000 })
+    await page.getByRole('button', { name: 'Pay', exact: true }).click()
+    await expect(page.getByText('Payment received!')).toBeVisible({ timeout: 30_000 })
+
+    order = (await paymentSink()).paypal.find(
+      (o) => o.custom_id === `${paidJobId}:${organizationId}` && o.status === 'COMPLETED'
+    )
+    expect(order, 'PayPal holds a completed order for the first invoice').toBeTruthy()
+    expect((await paymentsFor(paidJobId)).map((p) => [p.provider, p.amount])).toEqual([
+      ['paypal', 100],
+    ])
+    expect(await paymentsFor(otherJobId), 'and nothing on the other').toEqual([])
+  })
+
+  test('is refused by another invoice’s return page', async ({ request }) => {
+    // The customer's browser, back from PayPal, with the other invoice's
+    // link and the paid order's id.
+    const { org, token } = shareParts(otherInvoiceUrl)
+    const response = await request.post(`/api/public/share/invoice/${org}/${token}/verify`, {
+      data: { provider: 'paypal', externalId: order?.id },
+    })
+    expect(response.status()).toBe(400)
+    expect(await response.json()).toEqual({ error: 'Payment does not belong to this invoice' })
+    expect(await paymentsFor(otherJobId), 'nothing was booked').toEqual([])
+  })
+
+  test('is refused by a notification that names another invoice', async ({ request }) => {
+    // An order already on the books is answered without another look, so the
+    // forgery has to be an order the app has not seen: created for the first
+    // invoice, approved and captured at the vendor, and never brought back
+    // to the app. That is what a notification that arrives first looks like.
+    const { org, token } = shareParts(paidInvoiceUrl)
+    const checkout = await request.post(`/api/public/share/invoice/${org}/${token}/checkout`, {
+      data: { provider: 'paypal', amount: 50 },
+    })
+    expect(checkout.status()).toBe(200)
+    const fresh = (await paymentSink()).paypal.find(
+      (o) =>
+        o.custom_id === `${paidJobId}:${organizationId}` && o.status === 'PAYER_ACTION_REQUIRED'
+    )
+    expect(fresh, 'PayPal holds the new order').toBeTruthy()
+    await fetch(`${sinkUrl}/pay/paypal/${fresh?.id}`, { method: 'POST', redirect: 'manual' })
+    const captured = await fetch(`${sinkUrl}/v2/checkout/orders/${fresh?.id}/capture`, {
+      method: 'POST',
+      headers: { authorization: 'Bearer E2E-ACCESS-TOKEN' },
+    })
+    expect(captured.status, 'the order is paid at the vendor').toBe(201)
+
+    // PayPal's notification carries the invoice in `custom_id`; here it is
+    // rewritten to the other invoice while the order stays the paid one.
+    const notify = (customId: string) =>
+      request.post('/api/webhooks/paypal', {
+        data: {
+          event_type: 'PAYMENT.CAPTURE.COMPLETED',
+          resource: {
+            id: `CAP-${fresh?.id}`,
+            custom_id: customId,
+            supplementary_data: { related_ids: { order_id: fresh?.id } },
+          },
+        },
+      })
+
+    const forged = await notify(`${otherJobId}:${organizationId}`)
+    expect(forged.status()).toBe(400)
+    expect(await forged.json()).toEqual({ error: 'Order does not belong to this record' })
+    expect(await paymentsFor(otherJobId), 'nothing was booked').toEqual([])
+
+    // The other invoice's return page is refused the same order.
+    const other = shareParts(otherInvoiceUrl)
+    const verify = await request.post(
+      `/api/public/share/invoice/${other.org}/${other.token}/verify`,
+      { data: { provider: 'paypal', externalId: fresh?.id } }
+    )
+    expect(verify.status()).toBe(400)
+    expect(await paymentsFor(otherJobId), 'still nothing').toEqual([])
+
+    // And the genuine notification books it where it belongs, once.
+    const genuine = await notify(`${paidJobId}:${organizationId}`)
+    expect(genuine.status()).toBe(200)
+    expect((await paymentsFor(paidJobId)).map((p) => [p.provider, p.amount])).toEqual([
+      ['paypal', 100],
+      ['paypal', 50],
+    ])
+  })
+
+  test('leaves the other invoice untouched', async () => {
+    expect((await paymentsFor(paidJobId)).length).toBe(2)
+    expect(await paymentsFor(otherJobId)).toEqual([])
+  })
+})

+ 111 - 0
e2e/specs/security/sms-webhook.spec.ts

@@ -0,0 +1,111 @@
+import { expect, test } from '@playwright/test'
+import {
+  deleteInboundSms,
+  forgetConnections,
+  inboundSmsCount,
+  insertConnection,
+  ownerOrganizationId,
+  userIdFor,
+} from '../../support/db'
+import { sealCredentials, twilioSignature, webhookSecretHash } from '../../support/webhooks'
+
+/**
+ * An inbound text message has to come from the vendor.
+ *
+ * The SMS webhooks authenticated on a secret in the URL and nothing else, and
+ * a URL is something a vendor's dashboard, a log line or a support ticket
+ * shows to people. Anyone who had seen it could post a message attributed to
+ * any customer. Twilio signs every delivery with the account's auth token,
+ * and the route checks that signature now; the secret in the URL only says
+ * which workshop the call is for.
+ *
+ * The connection is planted with sealed keys rather than connected through
+ * the page, because connecting tests the keys against Twilio.
+ */
+
+test.describe.configure({ mode: 'serial' })
+
+const stamp = Date.now()
+const AUTH_TOKEN = `e2e-twilio-token-${stamp}`
+const URL_SECRET = `e2e-url-secret-${stamp}`
+const FROM = '+15551230000'
+const BODY = `E2E inbound ${stamp}`
+
+const baseURL = process.env.E2E_BASE_URL ?? 'http://127.0.0.1:3100'
+const webhook = `${baseURL}/api/webhooks/sms/twilio?org_secret=${URL_SECRET}`
+
+let organizationId = ''
+
+/** What Twilio posts: the message as form fields. */
+function message(body = BODY): Record<string, string> {
+  return { From: FROM, To: '+15550009999', Body: body, MessageSid: `SM${stamp}` }
+}
+
+test.beforeAll(async () => {
+  organizationId = await ownerOrganizationId()
+  await forgetConnections(['twilio-sms'])
+  await insertConnection({
+    organizationId,
+    connectorId: 'twilio-sms',
+    credentials: sealCredentials({
+      accountSid: 'ACe2e',
+      authToken: AUTH_TOKEN,
+      webhookSecret: URL_SECRET,
+    }),
+    settings: { webhookSecretHash: webhookSecretHash(URL_SECRET) },
+    createdById: await userIdFor('demo@torqvoice.com'),
+  })
+})
+
+test.afterAll(async () => {
+  await forgetConnections(['twilio-sms'])
+  await deleteInboundSms(organizationId, BODY)
+})
+
+test.describe('a text message posted to the Twilio webhook', () => {
+  test('with the wrong URL secret is for nobody', async ({ request }) => {
+    const response = await request.post(`${baseURL}/api/webhooks/sms/twilio?org_secret=wrong`, {
+      form: message(),
+    })
+    expect(response.status()).toBe(403)
+    expect(await response.json()).toEqual({ error: 'Invalid org_secret' })
+  })
+
+  test('without Twilio’s signature is dropped', async ({ request }) => {
+    const response = await request.post(webhook, { form: message() })
+    expect(response.status()).toBe(403)
+    expect(await response.json()).toEqual({ error: 'Invalid signature' })
+    expect(await inboundSmsCount(organizationId, BODY), 'nothing was filed').toBe(0)
+  })
+
+  test('with a signature made with the wrong token is dropped', async ({ request }) => {
+    const response = await request.post(webhook, {
+      form: message(),
+      headers: { 'x-twilio-signature': twilioSignature('not-the-token', webhook, message()) },
+    })
+    expect(response.status()).toBe(403)
+    expect(await inboundSmsCount(organizationId, BODY), 'nothing was filed').toBe(0)
+  })
+
+  test('with a signature Twilio would make is received', async ({ request }) => {
+    const response = await request.post(webhook, {
+      form: message(),
+      headers: { 'x-twilio-signature': twilioSignature(AUTH_TOKEN, webhook, message()) },
+    })
+    expect(response.status()).toBe(200)
+    expect(response.headers()['content-type']).toContain('text/xml')
+    expect(await inboundSmsCount(organizationId, BODY), 'the message is filed once').toBe(1)
+  })
+
+  test('with a body that was changed after signing is dropped', async ({ request }) => {
+    // The signature covers every field, so a message cannot be altered in
+    // flight either.
+    const signed = message()
+    const response = await request.post(webhook, {
+      form: message(`${BODY} tampered`),
+      headers: { 'x-twilio-signature': twilioSignature(AUTH_TOKEN, webhook, signed) },
+    })
+    expect(response.status()).toBe(403)
+    expect(await inboundSmsCount(organizationId, `${BODY} tampered`)).toBe(0)
+  })
+})

+ 186 - 0
e2e/support/db.ts

@@ -696,3 +696,189 @@ export async function customerIdNamed(organizationId: string, name: string): Pro
     return id
   })
 }
+
+// ─── The security specs ──────────────────────────────────────────────────────
+
+/**
+ * A custom role carrying every action on every subject the app knows, and no
+ * admin standing. It is the sharpest test of "logged in is not allowed": a
+ * member with this role passes every `requiredPermissions` check there is,
+ * and the owner-only and admin-only actions have to refuse them anyway.
+ */
+export async function createRoleWithEveryPermission(
+  organizationId: string,
+  name: string
+): Promise<string> {
+  const subjects = [
+    'dashboard',
+    'vehicles',
+    'customers',
+    'work_orders',
+    'quotes',
+    'services',
+    'billing',
+    'inventory',
+    'labor_presets',
+    'inspections',
+    'tire_hotel',
+    'reports',
+    'settings',
+    'work_board',
+    'ai_assistant',
+    'time_tracking',
+  ]
+  const actions = ['create', 'read', 'update', 'delete', 'manage']
+  return withDb(async (db) => {
+    const role = await db.query<{ id: string }>(
+      `insert into roles (id, name, "isAdmin", "organizationId", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, false, $2, now(), now())
+       returning id`,
+      [name, organizationId]
+    )
+    const roleId = role.rows[0].id
+    for (const subject of subjects) {
+      for (const action of actions) {
+        await db.query(
+          `insert into permissions (id, action, subject, "roleId")
+           values (gen_random_uuid()::text, $1, $2, $3)`,
+          [action, subject, roleId]
+        )
+      }
+    }
+    return roleId
+  })
+}
+
+/** Gives a member a custom role, and a built-in standing (member or admin) beside it. */
+export async function setMembership(
+  email: string,
+  organizationId: string,
+  membership: { roleId: string | null; role: 'member' | 'admin' }
+): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `update organization_members m
+          set "roleId" = $3, role = $4
+         from users u
+        where u.id = m."userId" and u.email = $1 and m."organizationId" = $2`,
+      [email, organizationId, membership.roleId, membership.role]
+    )
+  )
+}
+
+/** The credential in a pending invitation, or null when there is none for the address. */
+export async function invitationTokenFor(
+  email: string,
+  organizationId: string
+): Promise<string | null> {
+  return withDb(async (db) => {
+    const result = await db.query<{ token: string }>(
+      `select token from team_invitations
+        where email = $1 and "organizationId" = $2 and status = 'pending'`,
+      [email, organizationId]
+    )
+    return result.rows[0]?.token ?? null
+  })
+}
+
+/** How much of the workshop there is, for a test that must find it all still there. */
+export async function contentCounts(organizationId: string): Promise<Record<string, number>> {
+  return withDb(async (db) => {
+    const counts: Record<string, number> = {}
+    for (const table of ['vehicles', 'customers', 'quotes', 'inventory_parts', 'notifications']) {
+      const result = await db.query<{ n: string }>(
+        `select count(*)::text as n from ${table} where "organizationId" = $1`,
+        [organizationId]
+      )
+      counts[table] = Number(result.rows[0].n)
+    }
+    return counts
+  })
+}
+
+/**
+ * A file row written straight to the job, bypassing the schema that guards
+ * the action: what a record carried before the guard existed, or what a
+ * restore could bring in. The path resolver is the last line for these.
+ */
+export async function insertServiceAttachment(row: {
+  serviceRecordId: string
+  fileName: string
+  fileUrl: string
+  fileType: string
+}): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into service_attachments
+         (id, "fileName", "fileUrl", "fileType", "fileSize", category, "includeInInvoice", "serviceRecordId")
+       values (gen_random_uuid()::text, $1, $2, $3, 1, 'image', true, $4)
+       returning id`,
+      [row.fileName, row.fileUrl, row.fileType, row.serviceRecordId]
+    )
+    return result.rows[0].id
+  })
+}
+
+export async function deleteServiceAttachments(ids: string[]): Promise<void> {
+  await withDb((db) =>
+    db.query(`delete from service_attachments where id = any($1::text[])`, [ids])
+  )
+}
+
+/** How many file rows carry a name, on any job. */
+export async function serviceAttachmentsNamed(fileName: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from service_attachments where "fileName" = $1`,
+      [fileName]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+/** A live connection to a vendor, planted with sealed keys; see `support/webhooks.ts`. */
+export async function insertConnection(row: {
+  organizationId: string
+  connectorId: string
+  credentials: string
+  settings: Record<string, unknown>
+  createdById: string
+}): Promise<string> {
+  return withDb(async (db) => {
+    const result = await db.query<{ id: string }>(
+      `insert into integration_connections
+         (id, "organizationId", "connectorId", status, credentials, settings, "createdById", "createdAt", "updatedAt")
+       values (gen_random_uuid()::text, $1, $2, 'active', $3, $4::jsonb, $5, now(), now())
+       returning id`,
+      [
+        row.organizationId,
+        row.connectorId,
+        row.credentials,
+        JSON.stringify(row.settings),
+        row.createdById,
+      ]
+    )
+    return result.rows[0].id
+  })
+}
+
+/** Inbound text messages with exactly this body, for a workshop. */
+export async function inboundSmsCount(organizationId: string, body: string): Promise<number> {
+  return withDb(async (db) => {
+    const result = await db.query<{ n: string }>(
+      `select count(*)::text as n from sms_messages
+        where "organizationId" = $1 and direction = 'inbound' and body = $2`,
+      [organizationId, body]
+    )
+    return Number(result.rows[0].n)
+  })
+}
+
+export async function deleteInboundSms(organizationId: string, body: string): Promise<void> {
+  await withDb((db) =>
+    db.query(
+      `delete from sms_messages where "organizationId" = $1 and direction = 'inbound' and body = $2`,
+      [organizationId, body]
+    )
+  )
+}

+ 50 - 0
e2e/support/webhooks.ts

@@ -0,0 +1,50 @@
+import { createCipheriv, createHash, createHmac, hkdfSync, randomBytes } from 'node:crypto'
+
+/**
+ * Standing in for a messaging vendor.
+ *
+ * A connection's keys are sealed before they reach the database, and the
+ * vault derives its key from `BETTER_AUTH_SECRET` when no dedicated one is
+ * set, which is how the suite's server runs. Sealing here, the same way,
+ * lets a spec plant a connection without a vendor to test the keys against;
+ * `src/features/integrations/Lib/vault.ts` is the original.
+ */
+
+/** The secret the app server signs sessions with; the config's fallback when unset. */
+const AUTH_SECRET = process.env.BETTER_AUTH_SECRET ?? 'k3Qb8vZ1hN7pXtR2yJm5Ls9CwD4gFa6UeH0iOoT+PbY='
+
+export function sealCredentials(value: Record<string, unknown>): string {
+  const key = Buffer.from(hkdfSync('sha256', AUTH_SECRET, 'torqvoice', 'integrations-vault', 32))
+  const iv = randomBytes(12)
+  const cipher = createCipheriv('aes-256-gcm', key, iv)
+  const encrypted = Buffer.concat([
+    cipher.update(Buffer.from(JSON.stringify(value), 'utf8')),
+    cipher.final(),
+  ])
+  return [
+    'v1',
+    iv.toString('base64url'),
+    cipher.getAuthTag().toString('base64url'),
+    encrypted.toString('base64url'),
+  ].join('.')
+}
+
+/** How the app files a connection's inbound URL secret, so the route can find the workshop. */
+export function webhookSecretHash(secret: string): string {
+  return createHash('sha256').update(secret).digest('hex')
+}
+
+/**
+ * What Twilio puts in `X-Twilio-Signature`: HMAC-SHA1 over the URL as
+ * registered in its console followed by every form field, sorted by name,
+ * keyed with the account's auth token.
+ */
+export function twilioSignature(
+  authToken: string,
+  url: string,
+  params: Record<string, string>
+): string {
+  let data = url
+  for (const key of Object.keys(params).sort()) data += key + params[key]
+  return createHmac('sha1', authToken).update(data, 'utf8').digest('base64')
+}