diff --git a/apps/cli/src/register-oss-programs.ts b/apps/cli/src/register-oss-programs.ts index 71d7aaa0d52..f0b0475c808 100644 --- a/apps/cli/src/register-oss-programs.ts +++ b/apps/cli/src/register-oss-programs.ts @@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) { await vaultProgram.register(); const sendProgram = new SendProgram(serviceContainer); - sendProgram.register(); + await sendProgram.register(); } diff --git a/apps/cli/src/tools/send/commands/create.command.spec.ts b/apps/cli/src/tools/send/commands/create.command.spec.ts new file mode 100644 index 00000000000..d3702689812 --- /dev/null +++ b/apps/cli/src/tools/send/commands/create.command.spec.ts @@ -0,0 +1,386 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { UserId } from "@bitwarden/user-core"; + +import { SendCreateCommand } from "./create.command"; + +describe("SendCreateCommand", () => { + let command: SendCreateCommand; + + const sendService = mock(); + const environmentService = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + environmentService.environment$ = of({ + getWebVaultUrl: () => "https://vault.bitwarden.com", + } as any); + + command = new SendCreateCommand( + sendService, + environmentService, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + const futureDate = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); + + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.objectContaining({ + type: SendType.Text, + }), + null, + undefined, + ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith( + expect.any(Object), + null as any, + "testPassword123", + ); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + expect(sendService.encrypt).toHaveBeenCalledWith(expect.any(Object), null, undefined); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com", "another@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { + id: "send-id", + emails: "test@example.com,another@example.com", + authType: AuthType.Email, + } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com,another@example.com"); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["test@example.com"], + password: "jsonPassword123", + }; + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + password: "jsonPassword123", + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should use CLI value when JSON has different value of same type", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: ["json@example.com"], + }; + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + emails: [] as string[], + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: "", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is whitespace only", async () => { + const requestJson = { + type: SendType.Text, + text: { text: "test content", hidden: false }, + deletionDate: futureDate, + }; + + const cmdOptions = { + password: " ", + }; + + sendService.encrypt.mockResolvedValue([ + { id: "send-id", authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + sendService.getFromState.mockResolvedValue({ + decrypt: jest.fn().mockResolvedValue({}), + } as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/create.command.ts b/apps/cli/src/tools/send/commands/create.command.ts index 91e579c26c1..ad4ff9c4e18 100644 --- a/apps/cli/src/tools/send/commands/create.command.ts +++ b/apps/cli/src/tools/send/commands/create.command.ts @@ -11,6 +11,7 @@ import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abs import { EnvironmentService } from "@bitwarden/common/platform/abstractions/environment.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { NodeUtils } from "@bitwarden/node/node-utils"; @@ -18,7 +19,6 @@ import { Response } from "../../../models/response"; import { CliUtils } from "../../../utils"; import { SendTextResponse } from "../models/send-text.response"; import { SendResponse } from "../models/send.response"; - export class SendCreateCommand { constructor( private sendService: SendService, @@ -81,12 +81,24 @@ export class SendCreateCommand { const emails = req.emails ?? options.emails ?? undefined; const maxAccessCount = req.maxAccessCount ?? options.maxAccessCount; - if (emails !== undefined && password !== undefined) { + const hasEmails = emails != null && emails.length > 0; + const hasPassword = password != null && password.trim().length > 0; + + if (hasEmails && hasPassword) { return Response.badRequest("--password and --emails are mutually exclusive."); } req.key = null; req.maxAccessCount = maxAccessCount; + req.emails = emails; + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } const hasPremium$ = this.accountService.activeAccount$.pipe( switchMap(({ id }) => this.accountProfileService.hasPremiumFromAnySource$(id)), @@ -136,11 +148,6 @@ export class SendCreateCommand { const sendView = SendResponse.toView(req); const [encSend, fileData] = await this.sendService.encrypt(sendView, fileBuffer, password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - encSend.emails = emails && emails.join(","); - await this.sendApiService.save([encSend, fileData]); const newSend = await this.sendService.getFromState(encSend.id); const activeUserId = await firstValueFrom(this.accountService.activeAccount$.pipe(getUserId)); diff --git a/apps/cli/src/tools/send/commands/edit.command.spec.ts b/apps/cli/src/tools/send/commands/edit.command.spec.ts new file mode 100644 index 00000000000..5bac63d3821 --- /dev/null +++ b/apps/cli/src/tools/send/commands/edit.command.spec.ts @@ -0,0 +1,400 @@ +// FIXME: Update this file to be type safe and remove this and next line +// @ts-strict-ignore +import { mock } from "jest-mock-extended"; +import { of } from "rxjs"; + +import { AccountService } from "@bitwarden/common/auth/abstractions/account.service"; +import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; +import { mockAccountInfoWith } from "@bitwarden/common/spec"; +import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; +import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; +import { UserId } from "@bitwarden/user-core"; + +import { Response } from "../../../models/response"; +import { SendResponse } from "../models/send.response"; + +import { SendEditCommand } from "./edit.command"; +import { SendGetCommand } from "./get.command"; + +describe("SendEditCommand", () => { + let command: SendEditCommand; + + const sendService = mock(); + const getCommand = mock(); + const sendApiService = mock(); + const accountProfileService = mock(); + const accountService = mock(); + + const activeAccount = { + id: "user-id" as UserId, + ...mockAccountInfoWith({ + email: "user@example.com", + name: "User", + }), + }; + + const mockSendId = "send-123"; + const mockSendView = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + text: { text: "test content", hidden: false }, + deletionDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + } as SendView; + + const mockSend = { + id: mockSendId, + type: SendType.Text, + decrypt: jest.fn().mockResolvedValue(mockSendView), + }; + + const encodeRequest = (data: any) => Buffer.from(JSON.stringify(data)).toString("base64"); + + beforeEach(() => { + jest.clearAllMocks(); + + accountService.activeAccount$ = of(activeAccount); + accountProfileService.hasPremiumFromAnySource$.mockReturnValue(of(false)); + sendService.getFromState.mockResolvedValue(mockSend as any); + getCommand.run.mockResolvedValue(Response.success(new SendResponse(mockSendView)) as any); + + command = new SendEditCommand( + sendService, + getCommand, + sendApiService, + accountProfileService, + accountService, + ); + }); + + describe("authType inference", () => { + describe("with CLI flags", () => { + it("should set authType to Email when emails are provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "test@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("test@example.com"); + }); + + it("should set authType to Password when password is provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "testPassword123", + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should set authType to None when neither emails nor password provided", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = {}; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should return error when both emails and password provided via CLI", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["test@example.com"], + password: "testPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with JSON input", () => { + it("should set authType to Email when emails provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com", "another@example.com"], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + }); + + it("should set authType to Password when password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.Password } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Password); + }); + + it("should return error when both emails and password provided in JSON", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["test@example.com"], + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + }); + + describe("with mixed CLI and JSON input", () => { + it("should return error when CLI emails combined with JSON password", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "jsonPassword123", + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should return error when CLI password combined with JSON emails", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + password: "cliPassword123", + }; + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(false); + expect(response.message).toBe("--password and --emails are mutually exclusive."); + }); + + it("should prioritize CLI value when JSON has different value of same type", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: ["json@example.com"], + }; + const requestJson = encodeRequest(requestData); + + const cmdOptions = { + email: ["cli@example.com"], + }; + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, emails: "cli@example.com", authType: AuthType.Email } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, cmdOptions); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.Email); + expect(savedCall[0].emails).toBe("cli@example.com"); + }); + }); + + describe("edge cases", () => { + it("should set authType to None when emails array is empty", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + emails: [] as string[], + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should set authType to None when password is empty string", async () => { + const requestData = { + id: mockSendId, + type: SendType.Text, + name: "Test Send", + password: "", + }; + const requestJson = encodeRequest(requestData); + + sendService.encrypt.mockResolvedValue([ + { id: mockSendId, authType: AuthType.None } as any, + null as any, + ]); + sendApiService.save.mockResolvedValue(undefined as any); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(true); + const savedCall = sendApiService.save.mock.calls[0][0]; + expect(savedCall[0].authType).toBe(AuthType.None); + }); + + it("should handle send not found", async () => { + sendService.getFromState.mockResolvedValue(null); + + const requestData = { + id: "nonexistent-id", + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + }); + + it("should handle type mismatch", async () => { + const requestData = { + id: mockSendId, + type: SendType.File, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("Cannot change a Send's type"); + }); + }); + }); + + describe("validation", () => { + it("should return error when requestJson is empty", async () => { + // Set BW_SERVE to prevent readStdin call + process.env.BW_SERVE = "true"; + + const response = await command.run("", {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`requestJson` was not provided."); + + delete process.env.BW_SERVE; + }); + + it("should return error when id is not provided", async () => { + const requestData = { + type: SendType.Text, + name: "Test Send", + }; + const requestJson = encodeRequest(requestData); + + const response = await command.run(requestJson, {}); + + expect(response.success).toBe(false); + expect(response.message).toBe("`itemid` was not provided."); + }); + }); +}); diff --git a/apps/cli/src/tools/send/commands/edit.command.ts b/apps/cli/src/tools/send/commands/edit.command.ts index 2c6d41d66ac..0709a33b88f 100644 --- a/apps/cli/src/tools/send/commands/edit.command.ts +++ b/apps/cli/src/tools/send/commands/edit.command.ts @@ -7,6 +7,7 @@ import { getUserId } from "@bitwarden/common/auth/services/account.service"; import { BillingAccountProfileStateService } from "@bitwarden/common/billing/abstractions/account/billing-account-profile-state.service"; import { SendApiService } from "@bitwarden/common/tools/send/services/send-api.service.abstraction"; import { SendService } from "@bitwarden/common/tools/send/services/send.service.abstraction"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { Response } from "../../../models/response"; @@ -53,14 +54,30 @@ export class SendEditCommand { req.id = normalizedOptions.itemId || req.id; if (normalizedOptions.emails) { req.emails = normalizedOptions.emails; - req.password = undefined; - } else if (normalizedOptions.password) { - req.emails = undefined; + } + if (normalizedOptions.password) { req.password = normalizedOptions.password; - } else if (req.password && (typeof req.password !== "string" || req.password === "")) { + } + if (req.password && (typeof req.password !== "string" || req.password === "")) { req.password = undefined; } + // Infer authType based on emails/password (mutually exclusive) + const hasEmails = req.emails != null && req.emails.length > 0; + const hasPassword = req.password != null && req.password.trim() !== ""; + + if (hasEmails && hasPassword) { + return Response.badRequest("--password and --emails are mutually exclusive."); + } + + if (hasEmails) { + req.authType = AuthType.Email; + } else if (hasPassword) { + req.authType = AuthType.Password; + } else { + req.authType = AuthType.None; + } + if (!req.id) { return Response.error("`itemid` was not provided."); } @@ -90,10 +107,6 @@ export class SendEditCommand { try { const [encSend, encFileData] = await this.sendService.encrypt(sendView, null, req.password); - // Add dates from template - encSend.deletionDate = sendView.deletionDate; - encSend.expirationDate = sendView.expirationDate; - await this.sendApiService.save([encSend, encFileData]); } catch (e) { return Response.error(e); diff --git a/apps/cli/src/tools/send/models/send.response.ts b/apps/cli/src/tools/send/models/send.response.ts index b7655226be0..c8182cbfaf8 100644 --- a/apps/cli/src/tools/send/models/send.response.ts +++ b/apps/cli/src/tools/send/models/send.response.ts @@ -2,6 +2,7 @@ // @ts-strict-ignore import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendView } from "@bitwarden/common/tools/send/models/view/send.view"; +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; import { BaseResponse } from "../../../models/response/base.response"; @@ -54,6 +55,7 @@ export class SendResponse implements BaseResponse { view.emails = send.emails ?? []; view.disabled = send.disabled; view.hideEmail = send.hideEmail; + view.authType = send.authType; return view; } @@ -92,6 +94,7 @@ export class SendResponse implements BaseResponse { emails?: Array; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(o?: SendView, webVaultUrl?: string) { if (o == null) { @@ -116,8 +119,10 @@ export class SendResponse implements BaseResponse { this.deletionDate = o.deletionDate; this.expirationDate = o.expirationDate; this.passwordSet = o.password != null; + this.emails = o.emails ?? []; this.disabled = o.disabled; this.hideEmail = o.hideEmail; + this.authType = o.authType; if (o.type === SendType.Text && o.text != null) { this.text = new SendTextResponse(o.text); diff --git a/apps/cli/src/tools/send/send.program.ts b/apps/cli/src/tools/send/send.program.ts index 869d77a379c..a84b6c15ead 100644 --- a/apps/cli/src/tools/send/send.program.ts +++ b/apps/cli/src/tools/send/send.program.ts @@ -6,6 +6,7 @@ import * as path from "path"; import * as chalk from "chalk"; import { program, Command, Option, OptionValues } from "commander"; +import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum"; import { Utils } from "@bitwarden/common/platform/misc/utils"; import { SendType } from "@bitwarden/common/tools/send/types/send-type"; @@ -31,13 +32,16 @@ import { parseEmail } from "./util"; const writeLn = CliUtils.writeLn; export class SendProgram extends BaseProgram { - register() { - program.addCommand(this.sendCommand()); + async register() { + const emailAuthEnabled = await this.serviceContainer.configService.getFeatureFlag( + FeatureFlag.SendEmailOTP, + ); + program.addCommand(this.sendCommand(emailAuthEnabled)); // receive is accessible both at `bw receive` and `bw send receive` program.addCommand(this.receiveCommand()); } - private sendCommand(): Command { + private sendCommand(emailAuthEnabled: boolean): Command { return new Command("send") .argument("", "The data to Send. Specify as a filepath with the --file option") .description( @@ -59,9 +63,7 @@ export class SendProgram extends BaseProgram { new Option( "--email ", "optional emails to access this Send. Can also be specified in JSON.", - ) - .argParser(parseEmail) - .hideHelp(), + ).argParser(parseEmail), ) .option("-a, --maxAccessCount ", "The amount of max possible accesses.") .option("--hidden", "Hide in web by default. Valid only if --file is not set.") @@ -78,11 +80,18 @@ export class SendProgram extends BaseProgram { .addCommand(this.templateCommand()) .addCommand(this.getCommand()) .addCommand(this.receiveCommand()) - .addCommand(this.createCommand()) - .addCommand(this.editCommand()) + .addCommand(this.createCommand(emailAuthEnabled)) + .addCommand(this.editCommand(emailAuthEnabled)) .addCommand(this.removePasswordCommand()) .addCommand(this.deleteCommand()) .action(async (data: string, options: OptionValues) => { + if (options.email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const encodedJson = this.makeSendJson(data, options); let response: Response; @@ -199,7 +208,7 @@ export class SendProgram extends BaseProgram { }); } - private createCommand(): Command { + private createCommand(emailAuthEnabled: any): Command { return new Command("create") .argument("[encodedJson]", "JSON object to upload. Can also be piped in through stdin.") .description("create a Send") @@ -215,6 +224,14 @@ export class SendProgram extends BaseProgram { .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { // subcommands inherit flags from their parent; they cannot override them const { fullObject = false, email = undefined, password = undefined } = args.parent.opts(); + + if (email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const mergedOptions = { ...options, fullObject: fullObject, @@ -227,7 +244,7 @@ export class SendProgram extends BaseProgram { }); } - private editCommand(): Command { + private editCommand(emailAuthEnabled: any): Command { return new Command("edit") .argument( "[encodedJson]", @@ -243,6 +260,14 @@ export class SendProgram extends BaseProgram { }) .action(async (encodedJson: string, options: OptionValues, args: { parent: Command }) => { await this.exitIfLocked(); + const { email = undefined, password = undefined } = args.parent.opts(); + if (email) { + if (!emailAuthEnabled) { + this.processResponse(Response.error("The --email feature is not currently available.")); + return; + } + } + const getCmd = new SendGetCommand( this.serviceContainer.sendService, this.serviceContainer.environmentService, @@ -259,8 +284,6 @@ export class SendProgram extends BaseProgram { this.serviceContainer.accountService, ); - // subcommands inherit flags from their parent; they cannot override them - const { email = undefined, password = undefined } = args.parent.opts(); const mergedOptions = { ...options, email, @@ -328,6 +351,7 @@ export class SendProgram extends BaseProgram { file: sendFile, text: sendText, type: type, + emails: options.email ?? undefined, }); return Buffer.from(JSON.stringify(template), "utf8").toString("base64"); diff --git a/libs/common/src/tools/send/models/data/send.data.ts b/libs/common/src/tools/send/models/data/send.data.ts index 7eeb15f3ebe..b4317c48959 100644 --- a/libs/common/src/tools/send/models/data/send.data.ts +++ b/libs/common/src/tools/send/models/data/send.data.ts @@ -11,7 +11,6 @@ export class SendData { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileData; @@ -26,6 +25,7 @@ export class SendData { emails: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(response?: SendResponse) { if (response == null) { @@ -48,6 +48,7 @@ export class SendData { this.emails = response.emails; this.disabled = response.disable; this.hideEmail = response.hideEmail; + this.authType = response.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/domain/send.spec.ts b/libs/common/src/tools/send/models/domain/send.spec.ts index cd51390908e..94f61acdb46 100644 --- a/libs/common/src/tools/send/models/domain/send.spec.ts +++ b/libs/common/src/tools/send/models/domain/send.spec.ts @@ -1,6 +1,7 @@ import { mock } from "jest-mock-extended"; import { of } from "rxjs"; +import { Send } from "@bitwarden/common/tools/send/models/domain/send"; import { emptyGuid, UserId } from "@bitwarden/common/types/guid"; // This import has been flagged as unallowed for this class. It may be involved in a circular dependency loop. // eslint-disable-next-line no-restricted-imports @@ -15,7 +16,6 @@ import { AuthType } from "../../types/auth-type"; import { SendType } from "../../types/send-type"; import { SendData } from "../data/send.data"; -import { Send } from "./send"; import { SendText } from "./send-text"; describe("Send", () => { @@ -26,7 +26,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: "encName", notes: "encNotes", text: { @@ -44,6 +43,7 @@ describe("Send", () => { emails: null!, disabled: false, hideEmail: true, + authType: AuthType.None, }; mockContainerService(); @@ -81,7 +81,6 @@ describe("Send", () => { id: "id", accessId: "accessId", type: SendType.Text, - authType: AuthType.None, name: { encryptedString: "encName", encryptionType: 0 }, notes: { encryptedString: "encNotes", encryptionType: 0 }, text: { @@ -98,6 +97,7 @@ describe("Send", () => { emails: null!, disabled: false, hideEmail: true, + authType: AuthType.None, }); }); @@ -123,6 +123,7 @@ describe("Send", () => { send.password = "password"; send.disabled = false; send.hideEmail = true; + send.authType = AuthType.None; const encryptService = mock(); const keyService = mock(); @@ -150,7 +151,6 @@ describe("Send", () => { name: "name", notes: "notes", type: 0, - authType: 2, key: expect.anything(), cryptoKey: "cryptoKey", file: expect.anything(), @@ -163,6 +163,7 @@ describe("Send", () => { password: "password", disabled: false, hideEmail: true, + authType: AuthType.None, }); }); }); diff --git a/libs/common/src/tools/send/models/domain/send.ts b/libs/common/src/tools/send/models/domain/send.ts index 82c37a17528..ab112dfc4ad 100644 --- a/libs/common/src/tools/send/models/domain/send.ts +++ b/libs/common/src/tools/send/models/domain/send.ts @@ -20,7 +20,6 @@ export class Send extends Domain { id: string; accessId: string; type: SendType; - authType: AuthType; name: EncString; notes: EncString; file: SendFile; @@ -35,6 +34,7 @@ export class Send extends Domain { emails: string; disabled: boolean; hideEmail: boolean; + authType: AuthType; constructor(obj?: SendData) { super(); @@ -66,6 +66,7 @@ export class Send extends Domain { this.deletionDate = obj.deletionDate != null ? new Date(obj.deletionDate) : null; this.expirationDate = obj.expirationDate != null ? new Date(obj.expirationDate) : null; this.hideEmail = obj.hideEmail; + this.authType = obj.authType; switch (this.type) { case SendType.Text: diff --git a/libs/common/src/tools/send/models/response/send.response.ts b/libs/common/src/tools/send/models/response/send.response.ts index 7a7885d5ae1..a51b1e8ac7a 100644 --- a/libs/common/src/tools/send/models/response/send.response.ts +++ b/libs/common/src/tools/send/models/response/send.response.ts @@ -1,8 +1,9 @@ // FIXME: Update this file to be type safe and remove this and next line // @ts-strict-ignore +import { AuthType } from "@bitwarden/common/tools/send/types/auth-type"; +import { SendType } from "@bitwarden/common/tools/send/types/send-type"; + import { BaseResponse } from "../../../../models/response/base.response"; -import { AuthType } from "../../types/auth-type"; -import { SendType } from "../../types/send-type"; import { SendFileApi } from "../api/send-file.api"; import { SendTextApi } from "../api/send-text.api"; @@ -10,7 +11,6 @@ export class SendResponse extends BaseResponse { id: string; accessId: string; type: SendType; - authType: AuthType; name: string; notes: string; file: SendFileApi; @@ -25,6 +25,7 @@ export class SendResponse extends BaseResponse { emails: string; disable: boolean; hideEmail: boolean; + authType: AuthType; constructor(response: any) { super(response); @@ -44,6 +45,7 @@ export class SendResponse extends BaseResponse { this.emails = this.getResponseProperty("Emails"); this.disable = this.getResponseProperty("Disabled") || false; this.hideEmail = this.getResponseProperty("HideEmail") || false; + this.authType = this.getResponseProperty("AuthType"); const text = this.getResponseProperty("Text"); if (text != null) { diff --git a/libs/common/src/tools/send/models/view/send.view.ts b/libs/common/src/tools/send/models/view/send.view.ts index d07de6d8293..ac6f5943a09 100644 --- a/libs/common/src/tools/send/models/view/send.view.ts +++ b/libs/common/src/tools/send/models/view/send.view.ts @@ -19,7 +19,6 @@ export class SendView implements View { key: Uint8Array; cryptoKey: SymmetricCryptoKey; type: SendType = null; - authType: AuthType = null; text = new SendTextView(); file = new SendFileView(); maxAccessCount?: number = null; @@ -31,6 +30,7 @@ export class SendView implements View { emails: string[] = []; disabled = false; hideEmail = false; + authType: AuthType = null; constructor(s?: Send) { if (!s) { @@ -49,6 +49,8 @@ export class SendView implements View { this.disabled = s.disabled; this.password = s.password; this.hideEmail = s.hideEmail; + this.authType = s.authType; + this.emails = s.emails ? s.emails.split(",").map((e) => e.trim()) : []; } get urlB64Key(): string { diff --git a/libs/common/src/tools/send/services/send.service.ts b/libs/common/src/tools/send/services/send.service.ts index c274d90146e..d1961434e5c 100644 --- a/libs/common/src/tools/send/services/send.service.ts +++ b/libs/common/src/tools/send/services/send.service.ts @@ -127,6 +127,8 @@ export class SendService implements InternalSendServiceAbstraction { } } + send.authType = model.authType; + return [send, fileData]; }