@remit/backend 0.0.123 → 0.0.125
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
AccountResponse,
|
|
3
|
+
AccountService as AccountServiceName,
|
|
3
4
|
FolderAppointment,
|
|
4
5
|
} from "@remit/api-openapi-types";
|
|
5
6
|
import type { AccountItem } from "@remit/data-ports";
|
|
6
7
|
import { BadRequestError, ConflictError } from "@remit/data-ports/errors";
|
|
7
|
-
import { AccountAuthType } from "@remit/domain-enums";
|
|
8
|
+
import { AccountAuthType, AccountService } from "@remit/domain-enums";
|
|
8
9
|
import type { AccountOverrides } from "./account-overrides.js";
|
|
9
10
|
import type { AccountSignature } from "./account-signature.js";
|
|
10
11
|
|
|
@@ -84,6 +85,41 @@ export const assertPasswordProvided = (
|
|
|
84
85
|
}
|
|
85
86
|
};
|
|
86
87
|
|
|
88
|
+
const MICROSOFT_GRANT_SERVICES: readonly AccountServiceName[] = [
|
|
89
|
+
AccountService.Mail,
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
export const resolveSyncedServices = (
|
|
93
|
+
authType: string,
|
|
94
|
+
requested: readonly AccountServiceName[],
|
|
95
|
+
): AccountServiceName[] => {
|
|
96
|
+
const services = Object.values(AccountService).filter((service) =>
|
|
97
|
+
requested.includes(service),
|
|
98
|
+
);
|
|
99
|
+
if (services.length === 0) {
|
|
100
|
+
throw new BadRequestError(
|
|
101
|
+
"syncedServices must name at least one service. To stop syncing an account entirely, delete it with DELETE /accounts/{accountId}.",
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
if (authType !== AccountAuthType.OauthMicrosoft) {
|
|
105
|
+
if (services.some((service) => service !== AccountService.Mail)) {
|
|
106
|
+
throw new BadRequestError(
|
|
107
|
+
`A password account syncs mail only, so syncedServices must be ["${AccountService.Mail}"].`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
return services;
|
|
111
|
+
}
|
|
112
|
+
const uncovered = services.filter(
|
|
113
|
+
(service) => !MICROSOFT_GRANT_SERVICES.includes(service),
|
|
114
|
+
);
|
|
115
|
+
if (uncovered.length > 0) {
|
|
116
|
+
throw new BadRequestError(
|
|
117
|
+
`This account's Microsoft consent does not cover ${uncovered.join(", ")}. Grant it through POST /accounts/oauth/microsoft/start.`,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return services;
|
|
121
|
+
};
|
|
122
|
+
|
|
87
123
|
// SECURITY: passwordHash, oauthRefreshTokenHash, and smtpPasswordHash are
|
|
88
124
|
// intentionally omitted — never expose token material in API responses.
|
|
89
125
|
// Display name, mute flag, and signatures live in per-account AccountSetting
|
|
@@ -103,6 +139,7 @@ export const toAccountResponse = (
|
|
|
103
139
|
username: account.username,
|
|
104
140
|
email: account.email,
|
|
105
141
|
authType: account.authType ?? AccountAuthType.Password,
|
|
142
|
+
syncedServices: account.syncedServices,
|
|
106
143
|
imapHost: account.imapHost,
|
|
107
144
|
imapPort: account.imapPort,
|
|
108
145
|
imapTls: account.imapTls,
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { afterEach, beforeEach, describe, it } from "node:test";
|
|
3
|
+
import type { AccountResponse } from "@remit/api-openapi-types";
|
|
4
|
+
import type { AccountItem } from "@remit/data-ports";
|
|
5
|
+
import {
|
|
6
|
+
AccountAuthType,
|
|
7
|
+
AccountService,
|
|
8
|
+
ConnectionState,
|
|
9
|
+
} from "@remit/domain-enums";
|
|
10
|
+
import {
|
|
11
|
+
AccountConfigRepo,
|
|
12
|
+
AccountRepo,
|
|
13
|
+
AccountSettingRepo,
|
|
14
|
+
ConfigImportRepo,
|
|
15
|
+
MailboxRepo,
|
|
16
|
+
} from "@remit/drizzle-service";
|
|
17
|
+
import { createShippedSqliteDb } from "@remit/drizzle-service/test-sqlite";
|
|
18
|
+
import type { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda";
|
|
19
|
+
import type { Context } from "openapi-backend";
|
|
20
|
+
import { deriveAccountConfigId } from "../auth.js";
|
|
21
|
+
import { handleError } from "../error.js";
|
|
22
|
+
import { formatResponse } from "../response.js";
|
|
23
|
+
import {
|
|
24
|
+
_resetForTest,
|
|
25
|
+
type RemitClient,
|
|
26
|
+
setClient,
|
|
27
|
+
} from "../service/data-client.js";
|
|
28
|
+
import { AccountDetailOperations, AccountOperations } from "./account.js";
|
|
29
|
+
import { ConfigOperations } from "./config.js";
|
|
30
|
+
|
|
31
|
+
const SUB = "cognito-sub-1180";
|
|
32
|
+
const ACCOUNT_CONFIG_ID = deriveAccountConfigId(SUB);
|
|
33
|
+
|
|
34
|
+
type Handler = (
|
|
35
|
+
context: Context,
|
|
36
|
+
event: APIGatewayProxyEvent,
|
|
37
|
+
) => Promise<unknown>;
|
|
38
|
+
|
|
39
|
+
const getConfig = ConfigOperations.ConfigOperations_getConfig as Handler;
|
|
40
|
+
const createAccount =
|
|
41
|
+
AccountOperations.AccountOperations_createAccount as Handler;
|
|
42
|
+
const updateAccount =
|
|
43
|
+
AccountDetailOperations.AccountDetailOperations_updateAccount as Handler;
|
|
44
|
+
|
|
45
|
+
const event = (body?: unknown): APIGatewayProxyEvent =>
|
|
46
|
+
({
|
|
47
|
+
body: body === undefined ? null : JSON.stringify(body),
|
|
48
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
49
|
+
}) as unknown as APIGatewayProxyEvent;
|
|
50
|
+
|
|
51
|
+
const context = (params: Record<string, string> = {}): Context =>
|
|
52
|
+
({ request: { params } }) as unknown as Context;
|
|
53
|
+
|
|
54
|
+
const respond = async (
|
|
55
|
+
run: () => Promise<unknown>,
|
|
56
|
+
): Promise<APIGatewayProxyResult> =>
|
|
57
|
+
run().then(
|
|
58
|
+
(body) => formatResponse(body as Record<string, unknown>),
|
|
59
|
+
(error: unknown) => handleError(error),
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const bodyOf = <T>(response: APIGatewayProxyResult): T =>
|
|
63
|
+
JSON.parse(response.body) as T;
|
|
64
|
+
|
|
65
|
+
let accounts: AccountRepo;
|
|
66
|
+
let close: () => void;
|
|
67
|
+
|
|
68
|
+
const seedAccount = (authType: AccountItem["authType"]) =>
|
|
69
|
+
accounts.create({
|
|
70
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
71
|
+
username: `${authType}@example.com`,
|
|
72
|
+
email: `${authType}@example.com`,
|
|
73
|
+
authType,
|
|
74
|
+
imapHost: "imap.example.com",
|
|
75
|
+
imapPort: 993,
|
|
76
|
+
imapTls: true,
|
|
77
|
+
imapStartTls: false,
|
|
78
|
+
isActive: true,
|
|
79
|
+
connectionState: ConnectionState.NotAuthenticated,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
const readServices = async (accountId: string): Promise<string[]> => {
|
|
83
|
+
const config = bodyOf<{ accounts: AccountResponse[] }>(
|
|
84
|
+
await respond(() => getConfig(context(), event())),
|
|
85
|
+
);
|
|
86
|
+
const account = config.accounts.find((item) => item.accountId === accountId);
|
|
87
|
+
assert.ok(account, `GET /config lists ${accountId}`);
|
|
88
|
+
return account.syncedServices;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const patchServices = (accountId: string, syncedServices: string[]) =>
|
|
92
|
+
respond(() =>
|
|
93
|
+
updateAccount(context({ accountId }), event({ syncedServices })),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
beforeEach(async () => {
|
|
97
|
+
const store = createShippedSqliteDb();
|
|
98
|
+
close = store.close;
|
|
99
|
+
accounts = new AccountRepo(store.db);
|
|
100
|
+
const accountConfig = new AccountConfigRepo(store.db);
|
|
101
|
+
await accountConfig.create({
|
|
102
|
+
accountConfigId: ACCOUNT_CONFIG_ID,
|
|
103
|
+
userId: SUB,
|
|
104
|
+
});
|
|
105
|
+
setClient({
|
|
106
|
+
accountConfig,
|
|
107
|
+
account: accounts,
|
|
108
|
+
accountSetting: new AccountSettingRepo(store.db),
|
|
109
|
+
mailbox: new MailboxRepo(store.db),
|
|
110
|
+
configImport: new ConfigImportRepo(store.db),
|
|
111
|
+
} as unknown as RemitClient);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
afterEach(() => {
|
|
115
|
+
_resetForTest();
|
|
116
|
+
close();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
describe("the services an account syncs", () => {
|
|
120
|
+
it("reads mail for an account that never chose", async () => {
|
|
121
|
+
const account = await seedAccount(AccountAuthType.Password);
|
|
122
|
+
|
|
123
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
124
|
+
AccountService.Mail,
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("writes a selection the account can serve and reads it back", async () => {
|
|
129
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
130
|
+
|
|
131
|
+
const response = await patchServices(account.accountId, [
|
|
132
|
+
AccountService.Mail,
|
|
133
|
+
AccountService.Mail,
|
|
134
|
+
]);
|
|
135
|
+
|
|
136
|
+
assert.equal(response.statusCode, 200);
|
|
137
|
+
assert.deepEqual(bodyOf<AccountResponse>(response).syncedServices, [
|
|
138
|
+
AccountService.Mail,
|
|
139
|
+
]);
|
|
140
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
141
|
+
AccountService.Mail,
|
|
142
|
+
]);
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("refuses an empty selection and points at deleting the account", async () => {
|
|
146
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
147
|
+
|
|
148
|
+
const response = await patchServices(account.accountId, []);
|
|
149
|
+
|
|
150
|
+
assert.equal(response.statusCode, 400);
|
|
151
|
+
assert.match(
|
|
152
|
+
bodyOf<{ message: string }>(response).message,
|
|
153
|
+
/DELETE \/accounts\/\{accountId\}/,
|
|
154
|
+
);
|
|
155
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
156
|
+
AccountService.Mail,
|
|
157
|
+
]);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it("refuses calendar on a password account", async () => {
|
|
161
|
+
const account = await seedAccount(AccountAuthType.Password);
|
|
162
|
+
|
|
163
|
+
const response = await patchServices(account.accountId, [
|
|
164
|
+
AccountService.Mail,
|
|
165
|
+
AccountService.Calendar,
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
assert.equal(response.statusCode, 400);
|
|
169
|
+
assert.match(
|
|
170
|
+
bodyOf<{ message: string }>(response).message,
|
|
171
|
+
/password account syncs mail only/,
|
|
172
|
+
);
|
|
173
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
174
|
+
AccountService.Mail,
|
|
175
|
+
]);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("refuses calendar the Microsoft consent never covered and names the consent route", async () => {
|
|
179
|
+
const account = await seedAccount(AccountAuthType.OauthMicrosoft);
|
|
180
|
+
|
|
181
|
+
const response = await patchServices(account.accountId, [
|
|
182
|
+
AccountService.Calendar,
|
|
183
|
+
]);
|
|
184
|
+
|
|
185
|
+
assert.equal(response.statusCode, 400);
|
|
186
|
+
assert.match(
|
|
187
|
+
bodyOf<{ message: string }>(response).message,
|
|
188
|
+
/POST \/accounts\/oauth\/microsoft\/start/,
|
|
189
|
+
);
|
|
190
|
+
assert.deepEqual(await readServices(account.accountId), [
|
|
191
|
+
AccountService.Mail,
|
|
192
|
+
]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("refuses to create a password account that syncs calendar", async () => {
|
|
196
|
+
const response = await respond(() =>
|
|
197
|
+
createAccount(
|
|
198
|
+
context(),
|
|
199
|
+
event({
|
|
200
|
+
email: "person@example.com",
|
|
201
|
+
password: "hunter2",
|
|
202
|
+
imapHost: "imap.example.com",
|
|
203
|
+
imapPort: 993,
|
|
204
|
+
imapTls: true,
|
|
205
|
+
imapStartTls: false,
|
|
206
|
+
syncedServices: [AccountService.Calendar],
|
|
207
|
+
}),
|
|
208
|
+
),
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
assert.equal(response.statusCode, 400);
|
|
212
|
+
assert.deepEqual(
|
|
213
|
+
await accounts.listAllByAccountConfig(ACCOUNT_CONFIG_ID),
|
|
214
|
+
[],
|
|
215
|
+
);
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/handlers/account.ts
CHANGED
|
@@ -8,7 +8,11 @@ import type {
|
|
|
8
8
|
UpdateAccountInput,
|
|
9
9
|
} from "@remit/api-openapi-types";
|
|
10
10
|
import type { AccountItem, IAccountRepository } from "@remit/data-ports";
|
|
11
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
AccountAuthType,
|
|
13
|
+
AccountService,
|
|
14
|
+
ConnectionState,
|
|
15
|
+
} from "@remit/domain-enums";
|
|
12
16
|
import { logger } from "@remit/logger-lambda";
|
|
13
17
|
import {
|
|
14
18
|
createMailOAuthService,
|
|
@@ -42,6 +46,7 @@ import {
|
|
|
42
46
|
assertNoDuplicateMailbox,
|
|
43
47
|
assertNotOAuthCreate,
|
|
44
48
|
assertPasswordProvided,
|
|
49
|
+
resolveSyncedServices,
|
|
45
50
|
toAccountResponse,
|
|
46
51
|
} from "./account-guards.js";
|
|
47
52
|
import {
|
|
@@ -203,6 +208,11 @@ export const AccountOperations: Record<
|
|
|
203
208
|
// Password accounts require a non-empty password
|
|
204
209
|
assertPasswordProvided(input.authType, input.password);
|
|
205
210
|
|
|
211
|
+
const syncedServices = resolveSyncedServices(
|
|
212
|
+
AccountAuthType.Password,
|
|
213
|
+
input.syncedServices ?? [AccountService.Mail],
|
|
214
|
+
);
|
|
215
|
+
|
|
206
216
|
const { account, accountConfig, accountSetting, mailbox, secrets } =
|
|
207
217
|
await getClient();
|
|
208
218
|
|
|
@@ -235,6 +245,7 @@ export const AccountOperations: Record<
|
|
|
235
245
|
email: input.email,
|
|
236
246
|
username: input.username ?? input.email,
|
|
237
247
|
authType: AccountAuthType.Password,
|
|
248
|
+
syncedServices,
|
|
238
249
|
passwordHash,
|
|
239
250
|
imapHost: input.imapHost,
|
|
240
251
|
imapPort: input.imapPort,
|
|
@@ -405,6 +416,13 @@ export const AccountDetailOperations: Record<
|
|
|
405
416
|
);
|
|
406
417
|
}
|
|
407
418
|
|
|
419
|
+
if (input.syncedServices !== undefined) {
|
|
420
|
+
updates.syncedServices = resolveSyncedServices(
|
|
421
|
+
existing.authType,
|
|
422
|
+
input.syncedServices,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
408
426
|
// Copy non-password fields
|
|
409
427
|
const fields = [
|
|
410
428
|
"imapHost",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { afterEach, describe, it } from "node:test";
|
|
3
3
|
import type { MailboxItem } from "@remit/data-ports";
|
|
4
|
+
import { BadRequestError } from "@remit/data-ports/errors";
|
|
4
5
|
import { MailboxSyncStatus } from "@remit/domain-enums";
|
|
5
6
|
import { UnconfirmedTrashMailboxError } from "@remit/mailbox-service";
|
|
6
7
|
import type { APIGatewayProxyEvent } from "aws-lambda";
|
|
@@ -11,7 +12,11 @@ import {
|
|
|
11
12
|
type RemitClient,
|
|
12
13
|
setClient,
|
|
13
14
|
} from "../service/data-client.js";
|
|
14
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
MailboxDetailOperations,
|
|
17
|
+
MailboxOperations,
|
|
18
|
+
TrashOperations,
|
|
19
|
+
} from "./mailbox.js";
|
|
15
20
|
|
|
16
21
|
const SUB = "cognito-sub-887";
|
|
17
22
|
const ACCOUNT_ID = "acc-887";
|
|
@@ -230,3 +235,46 @@ describe("MailboxDetailOperations_deleteMailbox — what it refuses (D4, D16)",
|
|
|
230
235
|
assert.deepEqual(deleted, ["mbx-1"]);
|
|
231
236
|
});
|
|
232
237
|
});
|
|
238
|
+
|
|
239
|
+
const createMailbox =
|
|
240
|
+
MailboxOperations.MailboxOperations_createMailbox as unknown as (
|
|
241
|
+
context: Context,
|
|
242
|
+
event: APIGatewayProxyEvent,
|
|
243
|
+
) => Promise<unknown>;
|
|
244
|
+
|
|
245
|
+
describe("MailboxOperations_createMailbox", () => {
|
|
246
|
+
afterEach(() => {
|
|
247
|
+
_resetForTest();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("refuses a path the account already has a folder at", async () => {
|
|
251
|
+
setClient({
|
|
252
|
+
account: {
|
|
253
|
+
get: async () => ({
|
|
254
|
+
accountId: ACCOUNT_ID,
|
|
255
|
+
accountConfigId: deriveAccountConfigId(SUB),
|
|
256
|
+
}),
|
|
257
|
+
},
|
|
258
|
+
mailboxQueue: {
|
|
259
|
+
createMailbox: async () => ({ outcome: "PathTaken" }),
|
|
260
|
+
},
|
|
261
|
+
} as unknown as RemitClient);
|
|
262
|
+
|
|
263
|
+
await assert.rejects(
|
|
264
|
+
createMailbox(
|
|
265
|
+
{
|
|
266
|
+
request: {
|
|
267
|
+
params: { accountId: ACCOUNT_ID },
|
|
268
|
+
requestBody: { namespaceType: "personal", fullPath: "Receipts" },
|
|
269
|
+
},
|
|
270
|
+
} as unknown as Context,
|
|
271
|
+
{
|
|
272
|
+
requestContext: { authorizer: { claims: { sub: SUB } } },
|
|
273
|
+
} as unknown as APIGatewayProxyEvent,
|
|
274
|
+
),
|
|
275
|
+
(error: unknown) =>
|
|
276
|
+
error instanceof BadRequestError &&
|
|
277
|
+
error.message === "A folder named “Receipts” is already there.",
|
|
278
|
+
);
|
|
279
|
+
});
|
|
280
|
+
});
|
package/src/handlers/mailbox.ts
CHANGED
|
@@ -402,7 +402,7 @@ export const MailboxOperations: Record<
|
|
|
402
402
|
const account = await client.account.get(accountId);
|
|
403
403
|
assertAccountOwnership(account, accountConfigId, "act");
|
|
404
404
|
|
|
405
|
-
const
|
|
405
|
+
const created = await client.mailboxQueue.createMailbox(
|
|
406
406
|
{
|
|
407
407
|
accountId,
|
|
408
408
|
namespaceType: namespaceType as "personal" | "other_users" | "shared",
|
|
@@ -423,8 +423,13 @@ export const MailboxOperations: Record<
|
|
|
423
423
|
accountId,
|
|
424
424
|
true,
|
|
425
425
|
);
|
|
426
|
+
if (created.outcome === "PathTaken") {
|
|
427
|
+
throw new BadRequestError(
|
|
428
|
+
`A folder named “${fullPath}” is already there.`,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
426
431
|
|
|
427
|
-
return toMailboxResponse(mailbox);
|
|
432
|
+
return toMailboxResponse(created.mailbox);
|
|
428
433
|
},
|
|
429
434
|
};
|
|
430
435
|
|