kanbanRoutes.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import { mkdtempSync, rmSync } from 'node:fs'
  2. import { tmpdir } from 'node:os'
  3. import { join } from 'node:path'
  4. import request from 'supertest'
  5. import { afterEach, describe, expect, it } from 'vitest'
  6. import type { ApplicationRuntime } from '../../src/server/app.js'
  7. import type { ServerConfig } from '../../src/server/config.js'
  8. import {
  9. createAuthenticatedApplication,
  10. createUserAuthenticatedApplication,
  11. } from './authenticatedApplication.js'
  12. const HOST = '127.0.0.1:15300'
  13. const ORIGIN = `http://${HOST}`
  14. const roots: string[] = []
  15. const runtimes: ApplicationRuntime[] = []
  16. afterEach(() => {
  17. for (const runtime of runtimes.splice(0)) runtime.close()
  18. for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
  19. })
  20. interface UpstreamCall {
  21. path: string
  22. method: string
  23. search: string
  24. body?: unknown
  25. }
  26. function json(value: unknown, init: ResponseInit = {}): Response {
  27. return new Response(JSON.stringify(value), {
  28. ...init,
  29. headers: { 'content-type': 'application/json', ...init.headers },
  30. })
  31. }
  32. function testConfig(): ServerConfig {
  33. const home = mkdtempSync(join(tmpdir(), 'yaoyao-kanban-routes-'))
  34. roots.push(home)
  35. return {
  36. host: '127.0.0.1',
  37. port: 15300,
  38. upstream: new URL('http://127.0.0.1:9119'),
  39. allowedHosts: new Set(),
  40. home,
  41. mediaRoot: home,
  42. attachmentsRoot: home,
  43. imagesRoot: home,
  44. mediaOwner: 'tester',
  45. allowInsecureLan: false,
  46. insecureLan: false,
  47. production: false,
  48. }
  49. }
  50. function upstreamFixture(manifests: unknown[] = [{ name: 'kanban', version: '1.2.3' }]) {
  51. const calls: UpstreamCall[] = []
  52. const state: { manifests: unknown[]; kanbanBoardsStatus: number; taskDetail?: unknown; unauthorizedPaths: Set<string> } = {
  53. manifests,
  54. kanbanBoardsStatus: 200,
  55. unauthorizedPaths: new Set(),
  56. }
  57. const fetchImpl = (async (input: string | URL | Request, init?: RequestInit) => {
  58. const url = new URL(input instanceof Request ? input.url : String(input))
  59. if (url.pathname === '/api/status') return json({ auth_required: false })
  60. if (url.pathname === '/api/profiles') return json({ profiles: [{ name: 'default', is_default: true }] })
  61. if (url.pathname === '/api/plugins/yaoyao/profiles') return json({ profiles: [] })
  62. if (url.pathname === '/api/dashboard/plugins') return json(state.manifests)
  63. let requestBody: unknown
  64. if (init?.body !== undefined && init.body !== null) requestBody = JSON.parse(String(init.body))
  65. const call: UpstreamCall = {
  66. path: url.pathname,
  67. method: init?.method ?? 'GET',
  68. search: url.search,
  69. ...(requestBody === undefined ? {} : { body: requestBody }),
  70. }
  71. calls.push(call)
  72. if (state.unauthorizedPaths.has(url.pathname)) {
  73. return json({ detail: 'Unauthorized' }, {
  74. status: 401,
  75. headers: { 'set-cookie': 'upstream_secret=must-not-leak; Path=/; HttpOnly' },
  76. })
  77. }
  78. if (url.pathname === '/api/plugins/kanban/boards'
  79. && call.method === 'GET' && state.kanbanBoardsStatus !== 200) {
  80. return json({ detail: 'Kanban API unavailable' }, { status: state.kanbanBoardsStatus })
  81. }
  82. if (/^\/api\/plugins\/kanban\/tasks\/[^/]+$/.test(url.pathname)
  83. && call.method === 'GET' && state.taskDetail !== undefined) {
  84. return json(state.taskDetail)
  85. }
  86. return json(call)
  87. }) as typeof fetch
  88. return { calls, state, fetchImpl }
  89. }
  90. type Agent = ReturnType<typeof request.agent>
  91. type Method = 'GET' | 'POST' | 'PATCH' | 'DELETE'
  92. function apiRequest(agent: Agent, method: Method, path: string) {
  93. switch (method) {
  94. case 'GET': return agent.get(path)
  95. case 'POST': return agent.post(path)
  96. case 'PATCH': return agent.patch(path)
  97. case 'DELETE': return agent.delete(path)
  98. }
  99. }
  100. async function csrfToken(agent: Agent): Promise<string> {
  101. const response = await agent.get('/api/app/bootstrap').set('Host', HOST).expect(200)
  102. return response.body.csrfToken as string
  103. }
  104. describe('Kanban proxy routes', () => {
  105. it('maps the same explicit route and query allowlist for Web and native aliases', async () => {
  106. const fixture = upstreamFixture()
  107. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  108. runtimes.push(runtime)
  109. const agent = request.agent(runtime.app.callback())
  110. const csrf = await csrfToken(agent)
  111. fixture.calls.splice(0)
  112. const cases: Array<{
  113. method: Method
  114. suffix: string
  115. upstreamPath: string
  116. search?: string
  117. body?: Record<string, unknown>
  118. upstreamBody?: Record<string, unknown>
  119. }> = [
  120. { method: 'GET', suffix: '/boards?include_archived=true&escape=1', upstreamPath: '/api/plugins/kanban/boards', search: '?include_archived=true' },
  121. { method: 'POST', suffix: '/boards?escape=1', upstreamPath: '/api/plugins/kanban/boards', body: { slug: 'main', name: 'Main' }, upstreamBody: { slug: 'main', name: 'Main', switch: false } },
  122. { method: 'GET', suffix: '/board?board=main&include_archived=true&escape=1', upstreamPath: '/api/plugins/kanban/board', search: '?board=main&include_archived=true' },
  123. { method: 'GET', suffix: '/profiles?escape=1', upstreamPath: '/api/plugins/kanban/profiles' },
  124. { method: 'GET', suffix: '/tasks/t_01234567?board=main&run_state_name=running&run_state_type=status&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567', search: '?board=main&run_state_name=running&run_state_type=status' },
  125. { method: 'POST', suffix: '/tasks?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks', search: '?board=main', body: { title: 'Ship it' } },
  126. { method: 'PATCH', suffix: '/tasks/t_01234567?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567', search: '?board=main', body: { status: 'ready' } },
  127. { method: 'DELETE', suffix: '/tasks/t_01234567?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567', search: '?board=main', body: {} },
  128. { method: 'POST', suffix: '/tasks/t_01234567/comments?board=main&escape=1', upstreamPath: '/api/plugins/kanban/tasks/t_01234567/comments', search: '?board=main', body: { author: 'forged', body: 'Looks good' }, upstreamBody: { author: 'test-admin', body: 'Looks good' } },
  129. { method: 'POST', suffix: '/dispatch?board=main&dry_run=true&max=2&escape=1', upstreamPath: '/api/plugins/kanban/dispatch', search: '?board=main&dry_run=true&max=2', body: {} },
  130. ]
  131. for (const route of cases) {
  132. const responses: unknown[] = []
  133. for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
  134. let pending = apiRequest(agent, route.method, `${prefix}${route.suffix}`).set('Host', HOST)
  135. if (prefix.startsWith('/api/app/') && route.method !== 'GET') {
  136. pending = pending.set('Origin', ORIGIN).set('X-CSRF-Token', csrf)
  137. }
  138. if (route.body !== undefined) pending = pending.send(route.body)
  139. const response = await pending.expect(200)
  140. responses.push(response.body)
  141. expect(response.body).toEqual({
  142. path: route.upstreamPath,
  143. method: route.method,
  144. search: route.search ?? '',
  145. ...(route.body === undefined ? {} : { body: route.upstreamBody ?? route.body }),
  146. })
  147. }
  148. expect(responses[0]).toEqual(responses[1])
  149. }
  150. expect(fixture.calls).toHaveLength(cases.length * 2)
  151. })
  152. it('uses the authenticated boards API as availability truth and manifest only for metadata', async () => {
  153. const fixture = upstreamFixture()
  154. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  155. runtimes.push(runtime)
  156. const agent = request.agent(runtime.app.callback())
  157. const web = await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
  158. const native = await agent.get('/api/kanban/v1/status').set('Host', HOST).expect(200)
  159. expect(web.body).toEqual({ available: true, version: '1.2.3' })
  160. expect(native.body).toEqual(web.body)
  161. fixture.state.manifests = [{ name: 'yaoyao', version: '1.7.3' }]
  162. const hidden = await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
  163. expect(hidden.body).toEqual({ available: true })
  164. fixture.state.manifests = [{ name: 'kanban', version: '9.9.9', has_api: false }]
  165. fixture.state.kanbanBoardsStatus = 404
  166. const missing = await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
  167. expect(missing.body).toEqual({
  168. available: false,
  169. reason: '9119 未安装、未启用或未挂载 Kanban API',
  170. })
  171. })
  172. it('allows authenticated users to read but rejects every user mutation alias', async () => {
  173. const fixture = upstreamFixture()
  174. const runtime = createUserAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  175. runtimes.push(runtime)
  176. const agent = request.agent(runtime.app.callback())
  177. const csrf = await csrfToken(agent)
  178. fixture.calls.splice(0)
  179. await agent.get('/api/app/kanban/board?board=main').set('Host', HOST).expect(200)
  180. await agent.get('/api/kanban/v1/board?board=main').set('Host', HOST).expect(200)
  181. expect(fixture.calls).toHaveLength(2)
  182. const web = await agent.post('/api/app/kanban/tasks?board=main')
  183. .set('Host', HOST)
  184. .set('Origin', ORIGIN)
  185. .set('X-CSRF-Token', csrf)
  186. .send({ title: 'forbidden' })
  187. .expect(403)
  188. expect(web.body.code).toBe('admin_required')
  189. const native = await agent.patch('/api/kanban/v1/tasks/t_01234567?board=main')
  190. .set('Host', HOST)
  191. .send({ status: 'ready' })
  192. .expect(403)
  193. expect(native.body.code).toBe('admin_required')
  194. expect(fixture.calls).toHaveLength(2)
  195. })
  196. it('keeps CSRF on Web mutations while native admins can mutate without CSRF', async () => {
  197. const fixture = upstreamFixture()
  198. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  199. runtimes.push(runtime)
  200. const agent = request.agent(runtime.app.callback())
  201. const csrf = await csrfToken(agent)
  202. fixture.calls.splice(0)
  203. const rejected = await agent.post('/api/app/kanban/tasks?board=main')
  204. .set('Host', HOST)
  205. .set('Origin', ORIGIN)
  206. .send({ title: 'missing csrf' })
  207. .expect(403)
  208. expect(rejected.body.code).toBe('invalid_csrf')
  209. expect(fixture.calls).toHaveLength(0)
  210. await agent.post('/api/app/kanban/tasks?board=main')
  211. .set('Host', HOST)
  212. .set('Origin', ORIGIN)
  213. .set('X-CSRF-Token', csrf)
  214. .send({ title: 'web' })
  215. .expect(200)
  216. await agent.post('/api/kanban/v1/tasks?board=main')
  217. .set('Host', HOST)
  218. .send({ title: 'native' })
  219. .expect(200)
  220. expect(fixture.calls.map(call => call.body)).toEqual([{ title: 'web' }, { title: 'native' }])
  221. })
  222. it('forces create-board switch off and drops fields outside the stable alias contract', async () => {
  223. const fixture = upstreamFixture()
  224. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  225. runtimes.push(runtime)
  226. const agent = request.agent(runtime.app.callback())
  227. const csrf = await csrfToken(agent)
  228. fixture.calls.splice(0)
  229. const input = {
  230. slug: 'main', name: 'Main', description: 'Board', project_id: 'project-a',
  231. default_workdir: '/Users/forged/repository', icon: 'danger', color: '#f00',
  232. switch: true, delete: true, archive: '/tmp/escape.tar.gz',
  233. }
  234. await agent.post('/api/app/kanban/boards')
  235. .set('Host', HOST).set('Origin', ORIGIN).set('X-CSRF-Token', csrf).send(input).expect(200)
  236. await agent.post('/api/kanban/v1/boards').set('Host', HOST).send(input).expect(200)
  237. expect(fixture.calls.map(call => call.body)).toEqual([
  238. { slug: 'main', name: 'Main', description: 'Board', switch: false },
  239. { slug: 'main', name: 'Main', description: 'Board', switch: false },
  240. ])
  241. })
  242. it('derives comment audit authors from the authenticated admin for both aliases', async () => {
  243. const fixture = upstreamFixture()
  244. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  245. runtimes.push(runtime)
  246. const agent = request.agent(runtime.app.callback())
  247. const csrf = await csrfToken(agent)
  248. fixture.calls.splice(0)
  249. const forged = { body: '审计内容', author: 'forged-user', role: 'owner' }
  250. await agent.post('/api/app/kanban/tasks/t_01234567/comments?board=main')
  251. .set('Host', HOST).set('Origin', ORIGIN).set('X-CSRF-Token', csrf).send(forged).expect(200)
  252. await agent.post('/api/kanban/v1/tasks/t_01234567/comments?board=main')
  253. .set('Host', HOST).send(forged).expect(200)
  254. expect(fixture.calls.map(call => call.body)).toEqual([
  255. { body: '审计内容', author: 'test-admin' },
  256. { body: '审计内容', author: 'test-admin' },
  257. ])
  258. })
  259. it('removes unsupported attachment paths from task detail responses for both aliases', async () => {
  260. const fixture = upstreamFixture()
  261. fixture.state.taskDetail = {
  262. task: { id: 't_01234567', title: 'Private path check', status: 'todo' },
  263. comments: [], events: [], links: { parents: [], children: [] }, runs: [],
  264. attachments: [{ id: 1, filename: 'secret.txt', stored_path: '/Users/upstream/.hermes/kanban/attachments/secret.txt' }],
  265. }
  266. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  267. runtimes.push(runtime)
  268. const agent = request.agent(runtime.app.callback())
  269. for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
  270. const response = await agent.get(`${prefix}/tasks/t_01234567?board=main`).set('Host', HOST).expect(200)
  271. expect(response.body.attachments).toEqual([])
  272. expect(JSON.stringify(response.body)).not.toContain('/Users/upstream')
  273. expect(response.body.task).toMatchObject({ id: 't_01234567', status: 'todo' })
  274. }
  275. })
  276. it('maps server-owned upstream 401 responses without expiring the local 15300 session', async () => {
  277. const fixture = upstreamFixture()
  278. const config = testConfig()
  279. config.production = true
  280. config.upstreamUsername = 'service-admin'
  281. config.upstreamPassword = 'service-password'
  282. const runtime = createAuthenticatedApplication({ config, fetchImpl: (async (input, init) => {
  283. const path = new URL(String(input)).pathname
  284. if (path === '/api/status') return json({ auth_required: true })
  285. if (path === '/api/auth/providers') return json({}, { status: 503 })
  286. return fixture.fetchImpl(input, init)
  287. }) as typeof fetch })
  288. runtimes.push(runtime)
  289. const agent = request.agent(runtime.app.callback())
  290. fixture.state.unauthorizedPaths.add('/api/plugins/kanban/boards')
  291. for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
  292. const response = await agent.get(`${prefix}/status`).set('Host', HOST).expect(502)
  293. expect(response.body.code).toBe('upstream_auth_unavailable')
  294. expect(response.headers['set-cookie']).toBeUndefined()
  295. expect(JSON.stringify(response.body)).not.toContain('upstream_secret')
  296. }
  297. fixture.state.unauthorizedPaths.clear()
  298. await agent.get('/api/app/kanban/status').set('Host', HOST).expect(200)
  299. fixture.state.unauthorizedPaths.add('/api/plugins/kanban/tasks/t_01234567')
  300. for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
  301. const response = await agent.get(`${prefix}/tasks/t_01234567?board=main`).set('Host', HOST).expect(502)
  302. expect(response.body.code).toBe('upstream_auth_unavailable')
  303. expect(response.headers['set-cookie']).toBeUndefined()
  304. }
  305. })
  306. it('requires an explicit canonical board on every board-scoped endpoint', async () => {
  307. const fixture = upstreamFixture()
  308. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  309. runtimes.push(runtime)
  310. const agent = request.agent(runtime.app.callback())
  311. const csrf = await csrfToken(agent)
  312. fixture.calls.splice(0)
  313. const cases: Array<{ method: Method; suffix: string; body?: Record<string, unknown> }> = [
  314. { method: 'GET', suffix: '/board' },
  315. { method: 'GET', suffix: '/tasks/t_01234567' },
  316. { method: 'POST', suffix: '/tasks', body: { title: 'missing board' } },
  317. { method: 'PATCH', suffix: '/tasks/t_01234567', body: { status: 'ready' } },
  318. { method: 'DELETE', suffix: '/tasks/t_01234567', body: {} },
  319. { method: 'POST', suffix: '/tasks/t_01234567/comments', body: { body: 'missing board' } },
  320. { method: 'POST', suffix: '/dispatch', body: {} },
  321. ]
  322. for (const prefix of ['/api/app/kanban', '/api/kanban/v1']) {
  323. for (const item of cases) {
  324. let pending = apiRequest(agent, item.method, `${prefix}${item.suffix}`).set('Host', HOST)
  325. if (prefix.startsWith('/api/app/') && item.method !== 'GET') {
  326. pending = pending.set('Origin', ORIGIN).set('X-CSRF-Token', csrf)
  327. }
  328. if (item.body) pending = pending.send(item.body)
  329. const response = await pending.expect(400)
  330. expect(response.body.code).toBe('kanban_board_required')
  331. }
  332. }
  333. const invalid = await agent.get('/api/app/kanban/board?board=..%2Fescape').set('Host', HOST).expect(400)
  334. expect(invalid.body.code).toBe('invalid_kanban_board')
  335. const duplicate = await agent.get('/api/kanban/v1/board?board=main&board=other').set('Host', HOST).expect(400)
  336. expect(duplicate.body.code).toBe('invalid_kanban_board')
  337. expect(fixture.calls).toHaveLength(0)
  338. })
  339. it('does not expose switch, bulk, attachments, or arbitrary nested plugin paths', async () => {
  340. const fixture = upstreamFixture()
  341. const runtime = createAuthenticatedApplication({ config: testConfig(), fetchImpl: fixture.fetchImpl })
  342. runtimes.push(runtime)
  343. const agent = request.agent(runtime.app.callback())
  344. const csrf = await csrfToken(agent)
  345. fixture.calls.splice(0)
  346. await agent.post('/api/app/kanban/boards/main/switch')
  347. .set('Host', HOST).set('Origin', ORIGIN).set('X-CSRF-Token', csrf).send({}).expect(404)
  348. await agent.post('/api/kanban/v1/tasks/bulk').set('Host', HOST).send({ ids: [] }).expect(404)
  349. await agent.post('/api/kanban/v1/tasks/t_01234567/attachments').set('Host', HOST).send({}).expect(404)
  350. await agent.get('/api/kanban/v1/orchestration').set('Host', HOST).expect(404)
  351. expect(fixture.calls).toHaveLength(0)
  352. })
  353. })