Skip to content

Commit f65190a

Browse files
authored
fix(send): stop init() destroying the default folder; harden container create & flaky E2E (#930) (#937)
* fix init failure and track errors * share link guard tests * update test utils
1 parent 9c0ec20 commit f65190a

8 files changed

Lines changed: 251 additions & 66 deletions

File tree

packages/send/backend/src/models/containers.ts

Lines changed: 42 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { ContainerType, PrismaClient } from '@prisma/client';
22
import {
3+
BaseError,
34
CONTAINER_NOT_CREATED,
45
CONTAINER_NOT_FOUND,
56
CONTAINER_NOT_UPDATED,
@@ -23,45 +24,47 @@ export async function createContainer(
2324
parentId: string | null,
2425
shareOnly: boolean
2526
) {
26-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27-
let query: Record<string, any> = {
28-
data: {},
29-
};
30-
const group = await fromPrismaV2(
31-
prisma.group.create,
32-
query,
33-
GROUP_NOT_CREATED
34-
);
35-
36-
query = {
37-
data: {
38-
groupId: group.id,
39-
userId: ownerId,
40-
permission: PermissionType.ADMIN, // Owner has full permissions
41-
},
42-
};
43-
await fromPrismaV2(prisma.membership.create, query, MEMBERSHIP_NOT_CREATED);
44-
45-
query = {
46-
data: {
47-
name,
48-
ownerId,
49-
groupId: group.id,
50-
type,
51-
shareOnly,
52-
createdAt: new Date(),
53-
updatedAt: new Date(),
54-
},
55-
};
56-
if (parentId) {
57-
query.data['parentId'] = parentId;
58-
}
59-
60-
return await fromPrismaV2(
61-
prisma.container.create,
62-
query,
63-
CONTAINER_NOT_CREATED
64-
);
27+
// Create the group, the owner's membership, and the container atomically.
28+
// Doing these as separate calls left a window where a partially-created
29+
// container (e.g. group without membership) could be observed, surfacing as
30+
// a spurious 403 / "No Group found". See issue #930.
31+
return prisma.$transaction(async (tx) => {
32+
const group = await tx.group.create({ data: {} }).catch((err) => {
33+
console.error(err);
34+
throw new BaseError(GROUP_NOT_CREATED);
35+
});
36+
37+
await tx.membership
38+
.create({
39+
data: {
40+
groupId: group.id,
41+
userId: ownerId,
42+
permission: PermissionType.ADMIN, // Owner has full permissions
43+
},
44+
})
45+
.catch((err) => {
46+
console.error(err);
47+
throw new BaseError(MEMBERSHIP_NOT_CREATED);
48+
});
49+
50+
return tx.container
51+
.create({
52+
data: {
53+
name,
54+
ownerId,
55+
groupId: group.id,
56+
type,
57+
shareOnly,
58+
createdAt: new Date(),
59+
updatedAt: new Date(),
60+
...(parentId ? { parentId } : {}),
61+
},
62+
})
63+
.catch((err) => {
64+
console.error(err);
65+
throw new BaseError(CONTAINER_NOT_CREATED);
66+
});
67+
});
6568
}
6669

6770
export async function getItemsInContainer(id: string) {

packages/send/e2e/pages/dev/myFiles.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@ import {
77
downloadFirstFile,
88
dragAndDropFile,
99
playwrightConfig,
10+
requireShareLink,
1011
saveClipboardItem,
1112
} from "../../utils/dev/testUtils";
1213

13-
const { password, timeout, shareLinks, fileLinks } = playwrightConfig;
14+
const { password, timeout, fileLinks } = playwrightConfig;
1415

1516
export async function upload_workflow({ page }: PlaywrightProps) {
1617
const { folderRowSelector, folderRowTestID, fileCountID, uploadButton, dropZone, tableCellID, passwordInput } =
@@ -150,13 +151,13 @@ export async function download_workflow({ page, context }: PlaywrightProps) {
150151

151152
// Regular window downloads
152153
let otherPage = await context.newPage();
153-
await otherPage.goto(shareLinks["folder-no-password"]!);
154+
await otherPage.goto(requireShareLink("folder-no-password"));
154155
await otherPage.waitForLoadState("networkidle");
155156
await downloadFirstFile(otherPage);
156157
await otherPage.close();
157158

158159
otherPage = await context.newPage();
159-
await otherPage.goto(shareLinks["folder-with-password"]!);
160+
await otherPage.goto(requireShareLink("folder-with-password"));
160161
await otherPage.waitForLoadState("networkidle");
161162
await otherPage.getByTestId(passwordInputID).fill(password);
162163
await otherPage.getByTestId(submitButtonID).click();
@@ -176,14 +177,14 @@ export async function download_workflow({ page, context }: PlaywrightProps) {
176177

177178
// Download share link (folder) without password
178179
otherPage = await incognitoContext.newPage();
179-
await otherPage.goto(shareLinks["folder-no-password"]!);
180+
await otherPage.goto(requireShareLink("folder-no-password"));
180181
await otherPage.waitForLoadState("networkidle");
181182
await downloadFirstFile(otherPage);
182183
await otherPage.close();
183184

184185
// Download share link (folder) with password
185186
otherPage = await incognitoContext.newPage();
186-
await otherPage.goto(shareLinks["folder-with-password"]!);
187+
await otherPage.goto(requireShareLink("folder-with-password"));
187188
await otherPage.waitForLoadState("networkidle");
188189
await otherPage.getByTestId(passwordInputID).fill(password);
189190
await otherPage.getByTestId(submitButtonID).click();
@@ -193,14 +194,14 @@ export async function download_workflow({ page, context }: PlaywrightProps) {
193194

194195
// Download individual file without password
195196
otherPage = await incognitoContext.newPage();
196-
await otherPage.goto(shareLinks["file-no-password"]!);
197+
await otherPage.goto(requireShareLink("file-no-password"));
197198
await otherPage.waitForLoadState("networkidle");
198199
await downloadFirstFile(otherPage);
199200
await otherPage.close();
200201

201202
// Download individual file with password
202203
otherPage = await incognitoContext.newPage();
203-
await otherPage.goto(shareLinks["file-with-password"]!);
204+
await otherPage.goto(requireShareLink("file-with-password"));
204205
await otherPage.waitForLoadState("networkidle");
205206
await otherPage.getByTestId(passwordInputID).fill(password);
206207
await otherPage.getByTestId(submitButtonID).click();
@@ -239,12 +240,12 @@ export async function delete_file({ page }: PlaywrightProps) {
239240

240241
// Check that the share links are no longer accessible
241242
// Folder no password link
242-
await page.goto(shareLinks["folder-no-password"]!);
243+
await page.goto(requireShareLink("folder-no-password"));
243244
await page.waitForLoadState("networkidle");
244245
expect(await page.getByTestId("not_found").textContent()).toContain("This link is no longer active");
245246

246247
// Folder with password link
247-
await page.goto(shareLinks["folder-with-password"]!);
248+
await page.goto(requireShareLink("folder-with-password"));
248249
await page.waitForLoadState("networkidle");
249250
await page.getByTestId(passwordInputID).fill(password);
250251
await page.getByTestId(submitButtonID).click();

packages/send/e2e/tests/desktop/dev/send.spec.ts

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,15 @@ import {
2525
} from "../../../pages/dev/myFiles"
2626

2727
import { oidc_login } from "../../../pages/dev/oidc"
28-
import { setup_browser } from "../../../utils/dev/testUtils"
28+
import {
29+
emptyState,
30+
emptystatePath,
31+
storageStatePath,
32+
} from "../../../utils/dev/paths"
33+
import { resetShareLinks, setup_browser } from "../../../utils/dev/testUtils"
34+
35+
// Re-export for backwards compatibility with anything importing these from the spec.
36+
export { emptystatePath, storageStatePath }
2937

3038
config.config({ path: path.resolve(__dirname, "./.env") });
3139

@@ -39,20 +47,6 @@ export const credentials = {
3947
TBPRO_PASSWORD: process.env.TBPRO_PASSWORD,
4048
};
4149

42-
export const storageStatePath = path.resolve(
43-
__dirname,
44-
"../../../data/lockboxstate.json"
45-
);
46-
47-
export const emptystatePath = path.resolve(
48-
__dirname,
49-
"../../../data/emptystate.json"
50-
);
51-
const emptyState = {
52-
cookies: [],
53-
origins: [],
54-
};
55-
5650
// Cleanup storage state after all tests
5751
test.afterAll(async () => {
5852
fs.writeFileSync(storageStatePath, JSON.stringify(emptyState));
@@ -107,6 +101,12 @@ test.describe("File workflows", {
107101
let page: Page;
108102
let context: BrowserContext;
109103

104+
// Start from a clean share-link map so a prior (possibly failed) run can't
105+
// leak stale/null links into the download + delete steps below (#930).
106+
test.beforeAll(() => {
107+
resetShareLinks();
108+
});
109+
110110
test.beforeEach(async () => {
111111
({ page, context } = await setup_browser());
112112
await page.goto("/send");
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
// Unit-level regression tests for the share-link state guards added for #930.
2+
// These exercise pure helpers (no browser/stack required) so they run fast in CI
3+
// alongside the dev-desktop suite and document the intended guard behaviour.
4+
5+
import { expect, test } from "@playwright/test";
6+
7+
import { PLAYWRIGHT_TAG_DEV_DESKTOP } from "../../../const/const";
8+
import {
9+
playwrightConfig,
10+
requireShareLink,
11+
resetShareLinks,
12+
} from "../../../utils/dev/testUtils";
13+
14+
test.describe("share-link guards (#930)", { tag: [PLAYWRIGHT_TAG_DEV_DESKTOP] }, () => {
15+
test("resetShareLinks clears every entry back to null", () => {
16+
// Simulate a prior run that populated some links.
17+
playwrightConfig.shareLinks["file-no-password"] = "https://example/share/abc";
18+
playwrightConfig.shareLinks["folder-with-password"] = "https://example/share/def";
19+
20+
resetShareLinks();
21+
22+
for (const value of Object.values(playwrightConfig.shareLinks)) {
23+
expect(value).toBeNull();
24+
}
25+
});
26+
27+
test("requireShareLink throws an actionable error when a link is missing", () => {
28+
resetShareLinks();
29+
30+
expect(() => requireShareLink("folder-no-password")).toThrow(
31+
/Missing share link for "folder-no-password"/
32+
);
33+
// Must NOT let a null reach page.goto (the cryptic "expected string, got object").
34+
expect(() => requireShareLink("folder-no-password")).not.toThrow(
35+
/expected string/
36+
);
37+
});
38+
39+
test("requireShareLink returns the populated link", () => {
40+
resetShareLinks();
41+
const link = "https://example/share/xyz";
42+
playwrightConfig.shareLinks["file-with-password"] = link;
43+
44+
expect(requireShareLink("file-with-password")).toBe(link);
45+
});
46+
});
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
// Storage-state file paths shared between the dev spec and test utils.
2+
// Extracted here to break a circular import (send.spec <-> testUtils) that made
3+
// the helpers unloadable outside the spec's own load order. See issue #930.
4+
import path from "path";
5+
6+
export const storageStatePath = path.resolve(
7+
__dirname,
8+
"../../data/lockboxstate.json"
9+
);
10+
11+
export const emptystatePath = path.resolve(
12+
__dirname,
13+
"../../data/emptystate.json"
14+
);
15+
16+
export const emptyState = {
17+
cookies: [],
18+
origins: [],
19+
};

packages/send/e2e/utils/dev/testUtils.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import { readFileSync } from "fs";
1010

1111
import { fileLocators } from "../../pages/dev/locators";
12-
import { emptystatePath, storageStatePath } from "../../tests/desktop/dev/send.spec";
12+
import { emptystatePath, storageStatePath } from "./paths";
1313
import path from "path";
1414

1515
const sharelinks = {
@@ -29,6 +29,33 @@ export const playwrightConfig = {
2929
fileLinks: [] as string[],
3030
};
3131

32+
/**
33+
* Reset the module-level `shareLinks` singleton back to its empty baseline.
34+
* Call this at suite start so a failed/aborted run can't leak stale (or null)
35+
* links into a later run and produce cryptic, misattributed failures (#930).
36+
*/
37+
export function resetShareLinks() {
38+
for (const key of Object.keys(playwrightConfig.shareLinks)) {
39+
playwrightConfig.shareLinks[key] = null;
40+
}
41+
}
42+
43+
/**
44+
* Read a share link that an earlier workflow step was supposed to populate.
45+
* Throws an actionable error instead of letting a `null` reach `page.goto()`,
46+
* where it surfaces as the opaque `goto: url: expected string, got object`.
47+
*/
48+
export function requireShareLink(key: string): string {
49+
const link = playwrightConfig.shareLinks[key];
50+
if (!link) {
51+
throw new Error(
52+
`Missing share link for "${key}" — an upstream test step did not populate it. ` +
53+
`This usually means an earlier workflow (share/upload) failed; check that failure first.`
54+
);
55+
}
56+
return link;
57+
}
58+
3259
export async function downloadFirstFile(page: Page) {
3360
const { downloadButton, confirmDownload } = fileLocators(page);
3461
await downloadButton.click();

packages/send/frontend/src/lib/init.ts

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import { INIT_ERRORS } from '@send-frontend/apps/send/const';
22
import { FolderStore } from '@send-frontend/apps/send/stores/folder-store.types';
3-
import { Keychain } from '@send-frontend/lib/keychain';
3+
import {
4+
Keychain,
5+
restoreKeysUsingLocalStorage,
6+
} from '@send-frontend/lib/keychain';
47
import { useFolderStore } from '@send-frontend/stores';
8+
import useApiStore from '@send-frontend/stores/api-store';
59
import { UserStoreType as UserStore } from '@send-frontend/stores/user-store';
610

711
/**
@@ -11,7 +15,7 @@ import { UserStoreType as UserStore } from '@send-frontend/stores/user-store';
1115
* @param {FolderStore} folderStore - Pinia store for managing folders.
1216
* @return {Promise<INIT_ERRORS>} - Returns Promise of 0 (success) or an error code typed by INIT_ERRORS.
1317
*/
14-
async function init(
18+
async function _init(
1519
userStore: UserStore,
1620
keychain: Keychain,
1721
folderStore: FolderStore | ReturnType<typeof useFolderStore>
@@ -27,6 +31,18 @@ async function init(
2731
return INIT_ERRORS.NO_KEYCHAIN;
2832
}
2933

34+
// Ensure container keys are restored from the server backup before deciding a
35+
// default folder is "orphaned". keychain.load() above only reads local storage;
36+
// a valid folder's key may live only in the server backup and would otherwise
37+
// appear missing here, causing us to destroy a perfectly good container.
38+
// This is idempotent and no-ops when no passphrase is available.
39+
try {
40+
const { api } = useApiStore();
41+
await restoreKeysUsingLocalStorage(keychain, api);
42+
} catch (error) {
43+
console.warn('init(): could not restore keys before folder check', error);
44+
}
45+
3046
await folderStore.sync();
3147
const defaultFolder = folderStore?.defaultFolder;
3248
const defaultFolderKeyIsMissing =
@@ -49,4 +65,23 @@ async function init(
4965
return INIT_ERRORS.NONE;
5066
}
5167

68+
// Single-flight guard: concurrent callers (e.g. the login flow and dbUserSetup
69+
// firing during the same rapid navigation) share one run instead of each
70+
// independently deciding to delete + recreate the default folder. See issue #930.
71+
let inFlight: Promise<INIT_ERRORS> | null = null;
72+
73+
function init(
74+
userStore: UserStore,
75+
keychain: Keychain,
76+
folderStore: FolderStore | ReturnType<typeof useFolderStore>
77+
): Promise<INIT_ERRORS> {
78+
if (inFlight) {
79+
return inFlight;
80+
}
81+
inFlight = _init(userStore, keychain, folderStore).finally(() => {
82+
inFlight = null;
83+
});
84+
return inFlight;
85+
}
86+
5287
export default init;

0 commit comments

Comments
 (0)