api-token-db.test.js 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216
  1. import assert from 'node:assert/strict';
  2. import { mkdtempSync } from 'node:fs';
  3. import { tmpdir } from 'node:os';
  4. import path from 'node:path';
  5. import { test } from 'node:test';
  6. import {
  7. authenticateApiToken,
  8. createApiToken,
  9. createDomain,
  10. createInboundMailbox,
  11. createInboundMessage,
  12. createUser,
  13. getAdminResourceInventory,
  14. getApiToken,
  15. getInboundMessage,
  16. initDatabase,
  17. listApiTokens,
  18. listInboundMailboxFolders,
  19. listInboundMailboxes,
  20. revokeApiToken,
  21. rotateApiToken,
  22. searchInboundMessages,
  23. updateUser
  24. } from '../src/db.js';
  25. test('API tokens are recoverable only for explicit owner views and rotate without weakening authentication', () => {
  26. const database = initDatabase(tempDataDir(), 'api-token-recovery-secret');
  27. const user = createUser({
  28. username: 'token-owner',
  29. email: 'token-owner@example.com',
  30. password: 'password123'
  31. });
  32. const expiresAt = new Date(Date.now() + 60 * 60 * 1000).toISOString();
  33. const created = createApiToken(user.id, 'Receive worker', {
  34. scopes: ['send', 'messages:read'],
  35. expiresAt,
  36. mailboxAccess: 'owner'
  37. });
  38. assert.match(created.token, /^mh_/);
  39. assert.equal(created.tokenRecoverable, true);
  40. assert.equal(created.mailboxAccess, 'owner');
  41. assert.deepEqual(created.mailboxIds, []);
  42. const defaultListItem = listApiTokens(user.id)[0];
  43. assert.equal('token' in defaultListItem, false);
  44. assert.equal(defaultListItem.tokenRecoverable, true);
  45. assert.equal(listApiTokens(user.id, { includeToken: true })[0].token, created.token);
  46. assert.equal('token' in authenticateApiToken(created.token).token, false);
  47. assert.equal('token' in getAdminResourceInventory().users[0].apiTokens[0], false);
  48. authenticateApiToken(created.token);
  49. assert.ok(getApiToken(created.id, user.id).lastUsedAt);
  50. database.prepare('UPDATE api_tokens SET token_secret = ? WHERE id = ?').run('', created.id);
  51. const historical = getApiToken(created.id, user.id, { includeToken: true });
  52. assert.equal(historical.tokenRecoverable, false);
  53. assert.equal('token' in historical, false);
  54. assert.equal(authenticateApiToken(created.token).user.id, user.id);
  55. const rotated = rotateApiToken(created.id, user.id);
  56. assert.match(rotated.token, /^mh_/);
  57. assert.notEqual(rotated.token, created.token);
  58. assert.deepEqual(rotated.scopes, ['send', 'messages:read']);
  59. assert.equal(rotated.mailboxAccess, 'owner');
  60. assert.equal(rotated.expiresAt, expiresAt);
  61. assert.equal(rotated.lastUsedAt, null);
  62. assert.equal(authenticateApiToken(created.token), null);
  63. assert.equal(authenticateApiToken(rotated.token).user.id, user.id);
  64. assert.equal(revokeApiToken(created.id, user.id), true);
  65. assert.throws(() => rotateApiToken(created.id, user.id), /已撤销/);
  66. });
  67. test('API token mailbox scopes validate ownership and keep message queries fail closed', () => {
  68. const database = initDatabase(tempDataDir(), 'api-token-mailbox-secret');
  69. const admin = createUser({
  70. username: 'scope-admin',
  71. email: 'scope-admin@example.com',
  72. password: 'password123',
  73. role: 'admin'
  74. });
  75. const alice = createUser({
  76. username: 'scope-alice',
  77. email: 'scope-alice@example.com',
  78. password: 'password123'
  79. });
  80. const bob = createUser({
  81. username: 'scope-bob',
  82. email: 'scope-bob@example.com',
  83. password: 'password123'
  84. });
  85. createDomain(alice.id, domainFixture('scope-alice.example'));
  86. createDomain(bob.id, domainFixture('scope-bob.example'));
  87. const aliceMailbox = createInboundMailbox(alice.id, { address: 'inbox@scope-alice.example' });
  88. const bobMailbox = createInboundMailbox(bob.id, { address: 'inbox@scope-bob.example' });
  89. const aliceMessage = createInboundMessage(aliceMailbox, messageFixture('Alice message'));
  90. const bobMessage = createInboundMessage(bobMailbox, messageFixture('Bob message'));
  91. assert.throws(
  92. () => createApiToken(alice.id, 'Empty selection', {
  93. scopes: ['messages:read'],
  94. mailboxAccess: 'selected',
  95. mailboxIds: []
  96. }),
  97. /至少一个/
  98. );
  99. assert.throws(
  100. () => createApiToken(alice.id, 'Cross-user selection', {
  101. scopes: ['messages:read'],
  102. mailboxAccess: 'selected',
  103. mailboxIds: [bobMailbox.id]
  104. }),
  105. /不存在或无权访问/
  106. );
  107. assert.throws(
  108. () => createApiToken(alice.id, 'All mailboxes', {
  109. scopes: ['messages:read'],
  110. mailboxAccess: 'all'
  111. }),
  112. /只有管理员/
  113. );
  114. const aliceSelected = createApiToken(alice.id, 'Alice selected', {
  115. scopes: ['messages:read'],
  116. mailboxAccess: 'selected',
  117. mailboxIds: [aliceMailbox.id, aliceMailbox.id]
  118. });
  119. assert.deepEqual(aliceSelected.mailboxIds, [aliceMailbox.id]);
  120. assert.equal(searchInboundMessages(alice.id, {}, { mailboxIds: aliceSelected.mailboxIds }).total, 1);
  121. assert.equal(
  122. searchInboundMessages(
  123. alice.id,
  124. { mailboxId: bobMailbox.id },
  125. { mailboxIds: aliceSelected.mailboxIds }
  126. ).total,
  127. 0
  128. );
  129. assert.equal(getInboundMessage(alice.id, bobMessage.id, { mailboxIds: aliceSelected.mailboxIds }), null);
  130. assert.equal(listInboundMailboxFolders(alice.id, bobMailbox.id, { mailboxIds: aliceSelected.mailboxIds }), null);
  131. const adminSelected = createApiToken(admin.id, 'Admin selected', {
  132. scopes: ['messages:read'],
  133. mailboxAccess: 'selected',
  134. mailboxIds: [bobMailbox.id]
  135. });
  136. const selectedAccess = { includeAllUsers: true, mailboxIds: adminSelected.mailboxIds };
  137. const selectedMessages = searchInboundMessages(admin.id, {}, selectedAccess);
  138. assert.equal(selectedMessages.total, 1);
  139. assert.equal(selectedMessages.messages[0].id, bobMessage.id);
  140. assert.equal(getInboundMessage(admin.id, aliceMessage.id, selectedAccess), null);
  141. assert.equal(getInboundMessage(admin.id, bobMessage.id, selectedAccess).subject, 'Bob message');
  142. assert.equal(listInboundMailboxFolders(admin.id, aliceMailbox.id, selectedAccess), null);
  143. assert.equal(
  144. listInboundMailboxFolders(admin.id, bobMailbox.id, selectedAccess)
  145. .find((folder) => folder.name === 'INBOX').messageCount,
  146. 1
  147. );
  148. const adminAll = createApiToken(admin.id, 'Admin all', {
  149. scopes: ['messages:read'],
  150. mailboxAccess: 'all'
  151. });
  152. assert.equal(adminAll.mailboxAccess, 'all');
  153. assert.equal(searchInboundMessages(admin.id, {}, { includeAllUsers: true }).total, 2);
  154. assert.equal(listInboundMailboxes(admin.id, { includeAllUsers: true }).length, 2);
  155. assert.throws(() => updateUser(admin.id, { role: 'user' }), /至少保留一个/);
  156. createUser({
  157. username: 'scope-backup-admin',
  158. email: 'scope-backup-admin@example.com',
  159. password: 'password123',
  160. role: 'admin'
  161. });
  162. assert.equal(updateUser(admin.id, { role: 'user' }).role, 'user');
  163. assert.equal(authenticateApiToken(adminAll.token).user.role, 'user');
  164. database
  165. .prepare('UPDATE api_tokens SET mailbox_access_json = ? WHERE id = ?')
  166. .run('{"mode":"selected","mailboxIds":"broken"}', aliceSelected.id);
  167. const corrupted = getApiToken(aliceSelected.id, alice.id);
  168. assert.equal(corrupted.mailboxAccess, 'selected');
  169. assert.deepEqual(corrupted.mailboxIds, []);
  170. assert.equal(searchInboundMessages(alice.id, {}, { mailboxIds: corrupted.mailboxIds }).total, 0);
  171. });
  172. function tempDataDir() {
  173. return mkdtempSync(path.join(tmpdir(), 'mailhub-api-token-db-'));
  174. }
  175. function domainFixture(domain) {
  176. return {
  177. domain,
  178. selector: 'mh',
  179. verificationToken: `verify-${domain}`,
  180. dkimPublic: 'public',
  181. dkimPrivate: 'private',
  182. senderHost: `mail.${domain}`,
  183. sendingIp: '192.0.2.44',
  184. spfExtra: '',
  185. dmarcPolicy: 'none',
  186. dmarcRua: ''
  187. };
  188. }
  189. function messageFixture(subject) {
  190. return {
  191. sender: 'sender@example.net',
  192. recipients: ['inbox@example.net'],
  193. subject,
  194. messageId: `<${subject.toLowerCase().replaceAll(' ', '-')}@example.net>`,
  195. rawMessage: `Subject: ${subject}\r\n\r\n${subject}`,
  196. textBody: subject
  197. };
  198. }