docs: 交付文档、维护指引与端到端回归
补齐 README、开发计划、维护交接与 E2E 用例,执行最终验收。
This commit is contained in:
1 parent
18732801b5
commit
6cd904d460
216 files changed
+37038
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import { test, expect, env, seedOrg, seedUsers, signInCookies, agentSocketOverWebSocket, agentSocketOverPolling } from '@e2e'
|
||||
|
||||
// The dashboard's live-chat socket signs in with the sign-in cookies, which
|
||||
// browsers send on every socket request whatever the page. Sockets get no CORS
|
||||
// check, so only our own pages may use them: a page on an org's website (an
|
||||
// unverified signup domain, allowed to call the API) must not get a socket
|
||||
// signed in as whoever is visiting it.
|
||||
|
||||
const DASHBOARD = env.frontendUrl
|
||||
const ORG_SITE = `https://${seedOrg.domain}`
|
||||
|
||||
test.describe('the dashboard live-chat socket', () => {
|
||||
test('signs in from the dashboard but never from an org\'s website @security', async ({ playwright }) => {
|
||||
const context = await playwright.request.newContext()
|
||||
const cookie = await signInCookies(context, seedUsers.admin.email, seedUsers.admin.password)
|
||||
await context.dispose()
|
||||
|
||||
expect(await agentSocketOverWebSocket(DASHBOARD, cookie)).toBe('signed in')
|
||||
expect(await agentSocketOverWebSocket(ORG_SITE, cookie)).toBe('refused')
|
||||
})
|
||||
|
||||
// Polling needs every request on one worker; the prod-like run has two
|
||||
test('a <script>-tag (JSONP) handshake carries the cookies but not the page\'s origin, so it never signs in @security @one-worker', async ({ playwright, request }) => {
|
||||
const context = await playwright.request.newContext()
|
||||
const cookie = await signInCookies(context, seedUsers.admin.email, seedUsers.admin.password)
|
||||
await context.dispose()
|
||||
|
||||
// The same flow does sign in when the handshake came from the dashboard
|
||||
expect(await agentSocketOverPolling(request, cookie, ORG_SITE, { Origin: DASHBOARD })).toBe('signed in')
|
||||
expect(await agentSocketOverPolling(request, cookie, ORG_SITE, {}, '&j=0')).toBe('refused')
|
||||
expect(await agentSocketOverPolling(request, cookie, ORG_SITE, { 'Sec-Fetch-Site': 'cross-site', 'Sec-Fetch-Dest': 'script' })).toBe('refused')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,38 @@
|
||||
import { test, expect, env, seedUsers, ApiClient } from '@e2e'
|
||||
|
||||
// What the backend itself promises about cookies and cross-origin access.
|
||||
// (Security headers such as HSTS and CSP come from the host's nginx, which is not
|
||||
// in this repo, so they are checked against a deployment, not here.)
|
||||
|
||||
test.describe('security basics', () => {
|
||||
test('sign-in tokens are HttpOnly, Secure cookies @security', async ({ playwright }) => {
|
||||
const context = await playwright.request.newContext()
|
||||
const response = await context.post(`${env.apiUrl}/users/login`, {
|
||||
form: { username: seedUsers.admin.email, password: seedUsers.admin.password },
|
||||
})
|
||||
expect(response.ok()).toBe(true)
|
||||
const cookies = response.headersArray().filter((h) => h.name.toLowerCase() === 'set-cookie').map((h) => h.value)
|
||||
for (const name of ['access_token', 'refresh_token']) {
|
||||
const cookie = cookies.find((c) => c.startsWith(`${name}=`))
|
||||
expect(cookie, `${name} cookie`).toBeDefined()
|
||||
expect(cookie).toMatch(/;\s*HttpOnly/i)
|
||||
expect(cookie).toMatch(/;\s*Secure/i)
|
||||
}
|
||||
await context.dispose()
|
||||
})
|
||||
|
||||
test('another website cannot call the API with a visitor\'s credentials @security', async ({ request }) => {
|
||||
const response = await new ApiClient(request).raw('OPTIONS', '/users/me', {
|
||||
headers: { Origin: 'https://evil.example', 'Access-Control-Request-Method': 'GET' },
|
||||
})
|
||||
expect(response.headers()['access-control-allow-origin']).toBeUndefined()
|
||||
})
|
||||
|
||||
test('the dashboard\'s own origin is allowed @security', async ({ request }) => {
|
||||
const response = await new ApiClient(request).raw('OPTIONS', '/users/me', {
|
||||
headers: { Origin: env.frontendUrl, 'Access-Control-Request-Method': 'GET' },
|
||||
})
|
||||
expect(response.headers()['access-control-allow-origin']).toBe(env.frontendUrl)
|
||||
expect(response.headers()['access-control-allow-credentials']).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,66 @@
|
||||
import { test, expect, env, orgDomain, setOrgDomain, ApiClient } from '@e2e'
|
||||
|
||||
// The org's website is what lets its site call the API. Changing it is org-wide,
|
||||
// so this runs in the serial `org` project and puts the website back. With
|
||||
// several backend workers the others learn of a change over Redis a moment
|
||||
// later, so every check polls.
|
||||
|
||||
const OLD_SITE = 'https://old-site.e2e.example'
|
||||
const NEW_SITE = 'https://new-site.e2e.example'
|
||||
|
||||
async function allowedOrigin(api: ApiClient, origin: string): Promise<string | undefined> {
|
||||
// A plain request, not a preflight: every CORS layer adds its header to these,
|
||||
// so a stale copy of the old origin list would still answer for the old site.
|
||||
const response = await api.raw('GET', '/organizations/setup-status', { headers: { Origin: origin } })
|
||||
return response.headers()['access-control-allow-origin']
|
||||
}
|
||||
|
||||
let website: string
|
||||
|
||||
test.beforeEach(async ({ adminApi, org }) => { website = await orgDomain(adminApi, org.orgId) })
|
||||
|
||||
test.afterEach(async ({ adminApi, org }) => { await setOrgDomain(adminApi, org.orgId, website) })
|
||||
|
||||
test.describe('organization website and API access', () => {
|
||||
test('changing the website moves API access to the new site and revokes the old one @security', async ({ adminApi, org, request }) => {
|
||||
const anonymous = new ApiClient(request)
|
||||
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(OLD_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, OLD_SITE)).toBe(OLD_SITE)
|
||||
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(NEW_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBe(NEW_SITE)
|
||||
await expect.poll(() => allowedOrigin(anonymous, OLD_SITE)).toBeUndefined()
|
||||
|
||||
// Saving the same website again, then switching back, still allows only the current site
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(NEW_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBe(NEW_SITE)
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(OLD_SITE).host)
|
||||
await expect.poll(() => allowedOrigin(anonymous, OLD_SITE)).toBe(OLD_SITE)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('organization website and sign-in cookies', () => {
|
||||
test('an org\'s website can call the API but never with a signed-in user\'s cookies @security', async ({ adminApi, org, request }) => {
|
||||
// Any signed-up domain lands in the CORS list, unverified: it must not be
|
||||
// able to read the dashboard's cookie-authenticated responses
|
||||
await setOrgDomain(adminApi, org.orgId, new URL(NEW_SITE).host)
|
||||
const anonymous = new ApiClient(request)
|
||||
await expect.poll(() => allowedOrigin(anonymous, NEW_SITE)).toBe(NEW_SITE)
|
||||
|
||||
const simple = await anonymous.raw('GET', '/users/me', { headers: { Origin: NEW_SITE, Cookie: 'access_token=x' } })
|
||||
expect(simple.headers()['access-control-allow-origin']).toBe(NEW_SITE)
|
||||
expect(simple.headers()['access-control-allow-credentials']).toBeUndefined()
|
||||
|
||||
const preflight = await anonymous.raw('OPTIONS', '/users/me', {
|
||||
headers: { Origin: NEW_SITE, 'Access-Control-Request-Method': 'GET' },
|
||||
})
|
||||
expect(preflight.headers()['access-control-allow-credentials']).toBeUndefined()
|
||||
|
||||
// and the rebuilt CORS layer still lets the dashboard use them
|
||||
const dashboard = await anonymous.raw('GET', '/users/me', { headers: { Origin: env.frontendUrl } })
|
||||
expect(dashboard.headers()['access-control-allow-origin']).toBe(env.frontendUrl)
|
||||
expect(dashboard.headers()['access-control-allow-credentials']).toBe('true')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user