mirror of
https://github.com/bitwarden/browser
synced 2026-02-21 11:54:02 +00:00
Merge branch 'main' into km/pm-27331
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@bitwarden/cli",
|
||||
"description": "A secure and free password manager for all of your devices.",
|
||||
"version": "2025.12.1",
|
||||
"version": "2026.1.0",
|
||||
"keywords": [
|
||||
"bitwarden",
|
||||
"password",
|
||||
@@ -64,12 +64,12 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@koa/multer": "4.0.0",
|
||||
"@koa/router": "14.0.0",
|
||||
"@koa/router": "15.2.0",
|
||||
"big-integer": "1.6.52",
|
||||
"browser-hrtime": "1.1.8",
|
||||
"chalk": "4.1.2",
|
||||
"commander": "14.0.0",
|
||||
"core-js": "3.47.0",
|
||||
"core-js": "3.48.0",
|
||||
"form-data": "4.0.4",
|
||||
"https-proxy-agent": "7.0.6",
|
||||
"inquirer": "8.2.6",
|
||||
@@ -81,7 +81,7 @@
|
||||
"lowdb": "1.0.0",
|
||||
"lunr": "2.3.9",
|
||||
"multer": "2.0.2",
|
||||
"node-fetch": "2.6.12",
|
||||
"node-fetch": "2.7.0",
|
||||
"node-forge": "1.3.2",
|
||||
"open": "11.0.0",
|
||||
"papaparse": "5.5.3",
|
||||
|
||||
250
apps/cli/src/admin-console/commands/confirm.command.spec.ts
Normal file
250
apps/cli/src/admin-console/commands/confirm.command.spec.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { of } from "rxjs";
|
||||
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserDetailsResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { OrgKey } from "@bitwarden/common/types/key";
|
||||
import { KeyService } from "@bitwarden/key-management";
|
||||
|
||||
import { Response } from "../../models/response";
|
||||
|
||||
import { ConfirmCommand } from "./confirm.command";
|
||||
|
||||
describe("ConfirmCommand", () => {
|
||||
let command: ConfirmCommand;
|
||||
let apiService: jest.Mocked<ApiService>;
|
||||
let keyService: jest.Mocked<KeyService>;
|
||||
let encryptService: jest.Mocked<EncryptService>;
|
||||
let organizationUserApiService: jest.Mocked<OrganizationUserApiService>;
|
||||
let accountService: jest.Mocked<AccountService>;
|
||||
let i18nService: jest.Mocked<I18nService>;
|
||||
|
||||
const userId = "test-user-id" as UserId;
|
||||
const organizationId = "bf61e571-fb70-4113-b305-b331004d0f19";
|
||||
const organizationUserId = "6aa431fa-7ea1-4852-907e-b36b0030a87d";
|
||||
const mockOrgKey = {} as OrgKey;
|
||||
const mockPublicKey = "mockPublicKey";
|
||||
|
||||
beforeEach(() => {
|
||||
apiService = mock<ApiService>();
|
||||
keyService = mock<KeyService>();
|
||||
encryptService = mock<EncryptService>();
|
||||
organizationUserApiService = mock<OrganizationUserApiService>();
|
||||
accountService = mock<AccountService>();
|
||||
i18nService = mock<I18nService>();
|
||||
|
||||
command = new ConfirmCommand(
|
||||
apiService,
|
||||
keyService,
|
||||
encryptService,
|
||||
organizationUserApiService,
|
||||
accountService,
|
||||
i18nService,
|
||||
);
|
||||
|
||||
// Default mocks
|
||||
accountService.activeAccount$ = of({ id: userId } as any);
|
||||
keyService.orgKeys$ = jest.fn().mockReturnValue(of({ [organizationId]: mockOrgKey }));
|
||||
i18nService.t.mockReturnValue("My Items");
|
||||
encryptService.encryptString.mockResolvedValue({ encryptedString: "encrypted" } as any);
|
||||
encryptService.encapsulateKeyUnsigned.mockResolvedValue({ encryptedString: "key" } as any);
|
||||
apiService.getUserPublicKey.mockResolvedValue({ publicKey: mockPublicKey } as any);
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockResolvedValue();
|
||||
});
|
||||
|
||||
describe("run", () => {
|
||||
it("should return bad request for unknown object", async () => {
|
||||
const response = await command.run("unknown-object", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("Unknown object.");
|
||||
});
|
||||
|
||||
it("should return bad request when organizationId is missing", async () => {
|
||||
const response = await command.run("org-member", organizationUserId, {});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toBe("--organizationid <organizationid> required.");
|
||||
});
|
||||
|
||||
it("should return bad request when id is not a GUID", async () => {
|
||||
const response = await command.run("org-member", "not-a-guid", {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("is not a GUID");
|
||||
});
|
||||
|
||||
it("should return bad request when organizationId is not a GUID", async () => {
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: "not-a-guid",
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("is not a GUID");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmOrganizationMember - status validation", () => {
|
||||
it("should reject user with Invited status", async () => {
|
||||
const invitedUser = {
|
||||
id: organizationUserId,
|
||||
userId: null,
|
||||
status: OrganizationUserStatusType.Invited,
|
||||
} as unknown as OrganizationUserDetailsResponse;
|
||||
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(invitedUser);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain(
|
||||
"User must accept the invitation before they can be confirmed.",
|
||||
);
|
||||
});
|
||||
|
||||
it("should reject user with Confirmed status", async () => {
|
||||
const confirmedUser = {
|
||||
id: organizationUserId,
|
||||
userId: userId,
|
||||
status: OrganizationUserStatusType.Confirmed,
|
||||
} as unknown as OrganizationUserDetailsResponse;
|
||||
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(confirmedUser);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("User is already confirmed.");
|
||||
});
|
||||
|
||||
it("should reject user with Revoked status", async () => {
|
||||
const revokedUser = {
|
||||
id: organizationUserId,
|
||||
userId: userId,
|
||||
status: OrganizationUserStatusType.Revoked,
|
||||
} as unknown as OrganizationUserDetailsResponse;
|
||||
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(revokedUser);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("User is revoked and cannot be confirmed.");
|
||||
});
|
||||
|
||||
it("should reject user with unexpected status", async () => {
|
||||
const invalidUser = {
|
||||
id: organizationUserId,
|
||||
userId: userId,
|
||||
status: 999 as OrganizationUserStatusType, // Invalid status
|
||||
} as unknown as OrganizationUserDetailsResponse;
|
||||
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(invalidUser);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("User is not in a valid state to be confirmed.");
|
||||
});
|
||||
|
||||
it("should successfully confirm user with Accepted status", async () => {
|
||||
const acceptedUser = {
|
||||
id: organizationUserId,
|
||||
userId: userId,
|
||||
status: OrganizationUserStatusType.Accepted,
|
||||
} as unknown as OrganizationUserDetailsResponse;
|
||||
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(acceptedUser);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(true);
|
||||
expect(apiService.getUserPublicKey).toHaveBeenCalledWith(userId);
|
||||
expect(organizationUserApiService.postOrganizationUserConfirm).toHaveBeenCalledWith(
|
||||
organizationId,
|
||||
organizationUserId.toLowerCase(),
|
||||
expect.objectContaining({
|
||||
key: "key",
|
||||
defaultUserCollectionName: "encrypted",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error handling", () => {
|
||||
it("should return error when organization key is not found", async () => {
|
||||
keyService.orgKeys$ = jest.fn().mockReturnValue(of({}));
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("No encryption key for this organization");
|
||||
});
|
||||
|
||||
it("should return error when organization user is not found", async () => {
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(null);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
expect(response.message).toContain("Member id does not exist for this organization");
|
||||
});
|
||||
|
||||
it("should return error when API call fails", async () => {
|
||||
const acceptedUser = {
|
||||
id: organizationUserId,
|
||||
userId: userId,
|
||||
status: OrganizationUserStatusType.Accepted,
|
||||
} as unknown as OrganizationUserDetailsResponse;
|
||||
|
||||
organizationUserApiService.getOrganizationUser.mockResolvedValue(acceptedUser);
|
||||
organizationUserApiService.postOrganizationUserConfirm.mockRejectedValue(
|
||||
new Error("API Error"),
|
||||
);
|
||||
|
||||
const response = await command.run("org-member", organizationUserId, {
|
||||
organizationid: organizationId,
|
||||
});
|
||||
|
||||
expect(response).toBeInstanceOf(Response);
|
||||
expect(response.success).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,8 +5,10 @@ import { firstValueFrom, map, switchMap } from "rxjs";
|
||||
import {
|
||||
OrganizationUserApiService,
|
||||
OrganizationUserConfirmRequest,
|
||||
OrganizationUserDetailsResponse,
|
||||
} from "@bitwarden/admin-console/common";
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationUserStatusType } from "@bitwarden/common/admin-console/enums";
|
||||
import { AccountService } from "@bitwarden/common/auth/abstractions/account.service";
|
||||
import { getUserId } from "@bitwarden/common/auth/services/account.service";
|
||||
import { EncryptService } from "@bitwarden/common/key-management/crypto/abstractions/encrypt.service";
|
||||
@@ -72,6 +74,9 @@ export class ConfirmCommand {
|
||||
if (orgUser == null) {
|
||||
throw new Error("Member id does not exist for this organization.");
|
||||
}
|
||||
|
||||
this.validateOrganizationUserStatus(orgUser);
|
||||
|
||||
const publicKeyResponse = await this.apiService.getUserPublicKey(orgUser.userId);
|
||||
const publicKey = Utils.fromB64ToArray(publicKeyResponse.publicKey);
|
||||
const key = await this.encryptService.encapsulateKeyUnsigned(orgKey, publicKey);
|
||||
@@ -94,6 +99,24 @@ export class ConfirmCommand {
|
||||
const encrypted = await this.encryptService.encryptString(defaultCollectionName, orgKey);
|
||||
return encrypted.encryptedString;
|
||||
}
|
||||
|
||||
private validateOrganizationUserStatus(orgUser: OrganizationUserDetailsResponse): void {
|
||||
if (orgUser.status === OrganizationUserStatusType.Invited) {
|
||||
throw new Error("User must accept the invitation before they can be confirmed.");
|
||||
}
|
||||
|
||||
if (orgUser.status === OrganizationUserStatusType.Confirmed) {
|
||||
throw new Error("User is already confirmed.");
|
||||
}
|
||||
|
||||
if (orgUser.status === OrganizationUserStatusType.Revoked) {
|
||||
throw new Error("User is revoked and cannot be confirmed.");
|
||||
}
|
||||
|
||||
if (orgUser.status !== OrganizationUserStatusType.Accepted) {
|
||||
throw new Error("User is not in a valid state to be confirmed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Options {
|
||||
|
||||
@@ -138,10 +138,8 @@ export class EditCommand {
|
||||
);
|
||||
}
|
||||
|
||||
const encCipher = await this.cipherService.encrypt(cipherView, activeUserId);
|
||||
try {
|
||||
const updatedCipher = await this.cipherService.updateWithServer(encCipher);
|
||||
const decCipher = await this.cipherService.decrypt(updatedCipher, activeUserId);
|
||||
const decCipher = await this.cipherService.updateWithServer(cipherView, activeUserId);
|
||||
const res = new CipherResponse(decCipher);
|
||||
return Response.success(res);
|
||||
} catch (e) {
|
||||
|
||||
@@ -46,7 +46,9 @@ export class RestoreCommand {
|
||||
return Response.notFound();
|
||||
}
|
||||
|
||||
if (cipher.archivedDate && isArchivedVaultEnabled) {
|
||||
// Determine if restoring from archive or trash
|
||||
// When a cipher is archived and deleted, restore from the trash first
|
||||
if (cipher.archivedDate && cipher.deletedDate == null && isArchivedVaultEnabled) {
|
||||
return this.restoreArchivedCipher(cipher, activeUserId);
|
||||
} else {
|
||||
return this.restoreDeletedCipher(cipher, activeUserId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import http from "node:http";
|
||||
import net from "node:net";
|
||||
|
||||
import * as koaRouter from "@koa/router";
|
||||
import { Router } from "@koa/router";
|
||||
import { OptionValues } from "commander";
|
||||
import * as koa from "koa";
|
||||
import * as koaBodyParser from "koa-bodyparser";
|
||||
@@ -29,7 +29,7 @@ export class ServeCommand {
|
||||
);
|
||||
|
||||
const server = new koa();
|
||||
const router = new koaRouter();
|
||||
const router = new Router();
|
||||
process.env.BW_SERVE = "true";
|
||||
process.env.BW_NOINTERACTION = "true";
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// FIXME: Update this file to be type safe and remove this and next line
|
||||
// @ts-strict-ignore
|
||||
import * as koaMulter from "@koa/multer";
|
||||
import * as koaRouter from "@koa/router";
|
||||
import { Router } from "@koa/router";
|
||||
import * as koa from "koa";
|
||||
import { firstValueFrom, map } from "rxjs";
|
||||
|
||||
@@ -218,7 +218,7 @@ export class OssServeConfigurator {
|
||||
);
|
||||
}
|
||||
|
||||
async configureRouter(router: koaRouter) {
|
||||
async configureRouter(router: Router) {
|
||||
router.get("/generate", async (ctx, next) => {
|
||||
const response = await this.generateCommand.run(ctx.request.query);
|
||||
this.processResponse(ctx.response, response);
|
||||
|
||||
@@ -18,5 +18,5 @@ export async function registerOssPrograms(serviceContainer: ServiceContainer) {
|
||||
await vaultProgram.register();
|
||||
|
||||
const sendProgram = new SendProgram(serviceContainer);
|
||||
sendProgram.register();
|
||||
await sendProgram.register();
|
||||
}
|
||||
|
||||
@@ -147,11 +147,13 @@ import { SendService } from "@bitwarden/common/tools/send/services/send.service"
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
import { CipherArchiveService } from "@bitwarden/common/vault/abstractions/cipher-archive.service";
|
||||
import { CipherEncryptionService } from "@bitwarden/common/vault/abstractions/cipher-encryption.service";
|
||||
import { CipherSdkService } from "@bitwarden/common/vault/abstractions/cipher-sdk.service";
|
||||
import { InternalFolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||
import {
|
||||
CipherAuthorizationService,
|
||||
DefaultCipherAuthorizationService,
|
||||
} from "@bitwarden/common/vault/services/cipher-authorization.service";
|
||||
import { DefaultCipherSdkService } from "@bitwarden/common/vault/services/cipher-sdk.service";
|
||||
import { CipherService } from "@bitwarden/common/vault/services/cipher.service";
|
||||
import { DefaultCipherArchiveService } from "@bitwarden/common/vault/services/default-cipher-archive.service";
|
||||
import { DefaultCipherEncryptionService } from "@bitwarden/common/vault/services/default-cipher-encryption.service";
|
||||
@@ -254,6 +256,7 @@ export class ServiceContainer {
|
||||
twoFactorApiService: TwoFactorApiService;
|
||||
hibpApiService: HibpApiService;
|
||||
environmentService: EnvironmentService;
|
||||
cipherSdkService: CipherSdkService;
|
||||
cipherService: CipherService;
|
||||
folderService: InternalFolderService;
|
||||
organizationUserApiService: OrganizationUserApiService;
|
||||
@@ -612,6 +615,7 @@ export class ServiceContainer {
|
||||
this.keyGenerationService,
|
||||
this.sendStateProvider,
|
||||
this.encryptService,
|
||||
this.configService,
|
||||
);
|
||||
|
||||
this.cipherFileUploadService = new CipherFileUploadService(
|
||||
@@ -797,6 +801,8 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
);
|
||||
|
||||
this.cipherSdkService = new DefaultCipherSdkService(this.sdkService, this.logService);
|
||||
|
||||
this.cipherService = new CipherService(
|
||||
this.keyService,
|
||||
this.domainSettingsService,
|
||||
@@ -812,6 +818,7 @@ export class ServiceContainer {
|
||||
this.logService,
|
||||
this.cipherEncryptionService,
|
||||
this.messagingService,
|
||||
this.cipherSdkService,
|
||||
);
|
||||
|
||||
this.cipherArchiveService = new DefaultCipherArchiveService(
|
||||
@@ -1061,7 +1068,6 @@ export class ServiceContainer {
|
||||
this.containerService.attachToGlobal(global);
|
||||
await this.i18nService.init();
|
||||
this.twoFactorService.init();
|
||||
this.encryptService.init(this.configService);
|
||||
|
||||
// If a user has a BW_SESSION key stored in their env (not process.env.BW_SESSION),
|
||||
// this should set the user key to unlock the vault on init.
|
||||
|
||||
386
apps/cli/src/tools/send/commands/create.command.spec.ts
Normal file
386
apps/cli/src/tools/send/commands/create.command.spec.ts
Normal file
@@ -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<SendService>();
|
||||
const environmentService = mock<EnvironmentService>();
|
||||
const sendApiService = mock<SendApiService>();
|
||||
const accountProfileService = mock<BillingAccountProfileStateService>();
|
||||
const accountService = mock<AccountService>();
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
|
||||
400
apps/cli/src/tools/send/commands/edit.command.spec.ts
Normal file
400
apps/cli/src/tools/send/commands/edit.command.spec.ts
Normal file
@@ -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<SendService>();
|
||||
const getCommand = mock<SendGetCommand>();
|
||||
const sendApiService = mock<SendApiService>();
|
||||
const accountProfileService = mock<BillingAccountProfileStateService>();
|
||||
const accountService = mock<AccountService>();
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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<string>;
|
||||
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);
|
||||
|
||||
@@ -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("<data>", "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 <email>",
|
||||
"optional emails to access this Send. Can also be specified in JSON.",
|
||||
)
|
||||
.argParser(parseEmail)
|
||||
.hideHelp(),
|
||||
).argParser(parseEmail),
|
||||
)
|
||||
.option("-a, --maxAccessCount <amount>", "The amount of max possible accesses.")
|
||||
.option("--hidden", "Hide <data> 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");
|
||||
|
||||
@@ -103,10 +103,11 @@ export class CreateCommand {
|
||||
return Response.error("Creating this item type is restricted by organizational policy.");
|
||||
}
|
||||
|
||||
const cipher = await this.cipherService.encrypt(CipherExport.toView(req), activeUserId);
|
||||
const newCipher = await this.cipherService.createWithServer(cipher);
|
||||
const decCipher = await this.cipherService.decrypt(newCipher, activeUserId);
|
||||
const res = new CipherResponse(decCipher);
|
||||
const newCipher = await this.cipherService.createWithServer(
|
||||
CipherExport.toView(req),
|
||||
activeUserId,
|
||||
);
|
||||
const res = new CipherResponse(newCipher);
|
||||
return Response.success(res);
|
||||
} catch (e) {
|
||||
return Response.error(e);
|
||||
|
||||
Reference in New Issue
Block a user