http-app.test.mjs 4.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import test from 'node:test';
  2. import assert from 'node:assert/strict';
  3. import { createServer } from 'node:http';
  4. import { mkdtemp, rm } from 'node:fs/promises';
  5. import { tmpdir } from 'node:os';
  6. import { join } from 'node:path';
  7. import { JobStore } from '../src/job-store.mjs';
  8. import { createHttpHandler } from '../src/http-app.mjs';
  9. async function fixture() {
  10. const directory = await mkdtemp(join(tmpdir(), 'yuban-server-test-'));
  11. const store = new JobStore(directory, 86_400_000);
  12. await store.init();
  13. const enqueued = [];
  14. const worker = {
  15. enqueue(id) {
  16. enqueued.push(id);
  17. },
  18. async cancel(id) {
  19. return store.update(id, { status: 'cancelled', stage: 'cancelled' });
  20. },
  21. };
  22. const config = {
  23. corsAllowedOrigins: [
  24. 'https://www.yuban.co',
  25. 'https://yuban.co',
  26. 'http://localhost:4200',
  27. 'http://127.0.0.1:4200',
  28. ],
  29. allowedAudioHosts: ['file.yuban.co'],
  30. jobRetentionMs: 86_400_000,
  31. maxActiveJobsPerUser: 2,
  32. maxRetainedJobsPerUser: 20,
  33. };
  34. const authenticator = {
  35. async authenticate() {
  36. return { ownerKey: 'owner-a', userId: 'user-a' };
  37. },
  38. };
  39. const server = createServer(
  40. createHttpHandler({
  41. config,
  42. store,
  43. worker,
  44. authenticator,
  45. validateAudioUrl: async value => new URL(value),
  46. }),
  47. );
  48. await new Promise(resolve => server.listen(0, '127.0.0.1', resolve));
  49. const address = server.address();
  50. return {
  51. store,
  52. enqueued,
  53. baseUrl: `http://127.0.0.1:${address.port}`,
  54. async close() {
  55. await new Promise(resolve => server.close(resolve));
  56. await rm(directory, { recursive: true, force: true });
  57. },
  58. };
  59. }
  60. test('健康检查不要求认证', async t => {
  61. const app = await fixture();
  62. t.after(() => app.close());
  63. const response = await fetch(`${app.baseUrl}/health`);
  64. assert.equal(response.status, 200);
  65. const body = await response.json();
  66. assert.equal(body.service, 'yuban-server');
  67. });
  68. test('仅允许配置的网页来源通过 CORS 预检', async t => {
  69. const app = await fixture();
  70. t.after(() => app.close());
  71. for (const origin of [
  72. 'https://www.yuban.co',
  73. 'https://yuban.co',
  74. 'http://localhost:4200',
  75. 'http://127.0.0.1:4200',
  76. ]) {
  77. const response = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  78. method: 'OPTIONS',
  79. headers: {
  80. Origin: origin,
  81. 'Access-Control-Request-Method': 'POST',
  82. 'Access-Control-Request-Headers': 'Authorization, Content-Type, Idempotency-Key',
  83. },
  84. });
  85. assert.equal(response.status, 204, origin);
  86. assert.equal(response.headers.get('access-control-allow-origin'), origin);
  87. assert.match(response.headers.get('vary') || '', /Origin/i);
  88. assert.match(response.headers.get('access-control-allow-methods') || '', /POST/);
  89. }
  90. const rejected = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  91. method: 'OPTIONS',
  92. headers: {
  93. Origin: 'https://untrusted.example.com',
  94. 'Access-Control-Request-Method': 'POST',
  95. },
  96. });
  97. assert.equal(rejected.status, 403);
  98. const body = await rejected.json();
  99. assert.equal(body.error.code, 'ORIGIN_NOT_ALLOWED');
  100. assert.equal(rejected.headers.has('access-control-allow-origin'), false);
  101. });
  102. test('创建任务立即返回 202,查询不会暴露音频地址或所有者', async t => {
  103. const app = await fixture();
  104. t.after(() => app.close());
  105. const created = await fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  106. method: 'POST',
  107. headers: {
  108. 'Content-Type': 'application/json',
  109. Origin: 'http://localhost:4200',
  110. 'Idempotency-Key': 'chat-voice-1',
  111. },
  112. body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
  113. });
  114. assert.equal(created.status, 202);
  115. const createdBody = await created.json();
  116. assert.equal(createdBody.job.status, 'queued');
  117. assert.equal(app.enqueued.length, 1);
  118. const response = await fetch(
  119. `${app.baseUrl}/recording-transcription/jobs/${createdBody.job.id}`,
  120. );
  121. const text = await response.text();
  122. assert.equal(response.status, 200);
  123. assert.equal(text.includes('file.yuban.co'), false);
  124. assert.equal(text.includes('owner-a'), false);
  125. });
  126. test('同一用户和幂等键复用已有任务', async t => {
  127. const app = await fixture();
  128. t.after(() => app.close());
  129. const create = () =>
  130. fetch(`${app.baseUrl}/recording-transcription/jobs`, {
  131. method: 'POST',
  132. headers: { 'Content-Type': 'application/json', 'Idempotency-Key': 'same-recording' },
  133. body: JSON.stringify({ audioUrl: 'https://file.yuban.co/test.mp3' }),
  134. });
  135. const first = await (await create()).json();
  136. const second = await (await create()).json();
  137. assert.equal(first.job.id, second.job.id);
  138. assert.equal(second.reused, true);
  139. assert.equal(app.enqueued.length, 1);
  140. });