Skip to content

Commit 60c2333

Browse files
Merge pull request #59090 from nextcloud/backport/58226/stable33
[stable33] fix(sharing): Prevent empty password when checkbox is enabled
2 parents 27228a2 + 8bbf28c commit 60c2333

12 files changed

+319
-10
lines changed

apps/files_sharing/src/mixins/SharesMixin.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ export default {
180180
async set(enabled) {
181181
if (enabled) {
182182
this.passwordProtectedState = true
183-
this.$set(this.share, 'newPassword', await GeneratePassword(true))
183+
const generatedPassword = await GeneratePassword(true)
184+
if (!this.share.newPassword) {
185+
this.$set(this.share, 'newPassword', generatedPassword)
186+
}
184187
} else {
185188
this.passwordProtectedState = false
186189
this.$set(this.share, 'newPassword', '')
Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
/**
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import { beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
const mockGeneratePassword = vi.fn().mockResolvedValue('generated-password-123')
9+
10+
vi.mock('../services/ConfigService.ts', () => ({
11+
default: vi.fn().mockImplementation(() => ({
12+
enableLinkPasswordByDefault: false,
13+
enforcePasswordForPublicLink: false,
14+
isPublicUploadEnabled: true,
15+
isDefaultExpireDateEnabled: false,
16+
isDefaultInternalExpireDateEnabled: false,
17+
isDefaultRemoteExpireDateEnabled: false,
18+
defaultExpirationDate: null,
19+
defaultInternalExpirationDate: null,
20+
defaultRemoteExpirationDateString: null,
21+
isResharingAllowed: true,
22+
excludeReshareFromEdit: false,
23+
showFederatedSharesAsInternal: false,
24+
defaultPermissions: 31,
25+
})),
26+
}))
27+
28+
vi.mock('../utils/GeneratePassword.ts', () => ({
29+
default: (...args: unknown[]) => mockGeneratePassword(...args),
30+
}))
31+
32+
/**
33+
* Simulates the isPasswordProtected getter from SharesMixin.js
34+
*/
35+
function getIsPasswordProtected(state: {
36+
enforcePasswordForPublicLink: boolean
37+
passwordProtectedState: boolean | undefined
38+
newPassword: string | undefined
39+
password: string | undefined
40+
}): boolean {
41+
if (state.enforcePasswordForPublicLink) {
42+
return true
43+
}
44+
if (state.passwordProtectedState !== undefined) {
45+
return state.passwordProtectedState
46+
}
47+
return typeof state.newPassword === 'string'
48+
|| typeof state.password === 'string'
49+
}
50+
51+
/**
52+
* Simulates the isPasswordProtected setter from SharesMixin.js
53+
* Returns the resulting share state after the async operation completes.
54+
*/
55+
async function setIsPasswordProtected(
56+
enabled: boolean,
57+
share: { newPassword?: string },
58+
): Promise<{ passwordProtectedState: boolean, share: { newPassword?: string } }> {
59+
if (enabled) {
60+
const generatedPassword = await mockGeneratePassword(true)
61+
if (!share.newPassword) {
62+
share.newPassword = generatedPassword
63+
}
64+
return { passwordProtectedState: true, share }
65+
} else {
66+
share.newPassword = ''
67+
return { passwordProtectedState: false, share }
68+
}
69+
}
70+
71+
describe('SharingDetailsTab - Password State Management Logic', () => {
72+
beforeEach(() => {
73+
mockGeneratePassword.mockClear()
74+
mockGeneratePassword.mockResolvedValue('generated-password-123')
75+
})
76+
77+
describe('isPasswordProtected getter', () => {
78+
it('returns true when enforcePasswordForPublicLink is true regardless of other state', () => {
79+
expect(getIsPasswordProtected({
80+
enforcePasswordForPublicLink: true,
81+
passwordProtectedState: false,
82+
newPassword: undefined,
83+
password: undefined,
84+
})).toBe(true)
85+
})
86+
87+
it('returns true when passwordProtectedState is explicitly true', () => {
88+
expect(getIsPasswordProtected({
89+
enforcePasswordForPublicLink: false,
90+
passwordProtectedState: true,
91+
newPassword: undefined,
92+
password: undefined,
93+
})).toBe(true)
94+
})
95+
96+
it('returns false when passwordProtectedState is explicitly false', () => {
97+
expect(getIsPasswordProtected({
98+
enforcePasswordForPublicLink: false,
99+
passwordProtectedState: false,
100+
newPassword: 'some-password',
101+
password: undefined,
102+
})).toBe(false)
103+
})
104+
105+
it('falls back to inferring from newPassword when passwordProtectedState is undefined', () => {
106+
expect(getIsPasswordProtected({
107+
enforcePasswordForPublicLink: false,
108+
passwordProtectedState: undefined,
109+
newPassword: 'some-password',
110+
password: undefined,
111+
})).toBe(true)
112+
})
113+
114+
it('falls back to inferring from password when passwordProtectedState is undefined', () => {
115+
expect(getIsPasswordProtected({
116+
enforcePasswordForPublicLink: false,
117+
passwordProtectedState: undefined,
118+
newPassword: undefined,
119+
password: 'existing-password',
120+
})).toBe(true)
121+
})
122+
123+
it('returns false when passwordProtectedState is undefined and no passwords exist', () => {
124+
expect(getIsPasswordProtected({
125+
enforcePasswordForPublicLink: false,
126+
passwordProtectedState: undefined,
127+
newPassword: undefined,
128+
password: undefined,
129+
})).toBe(false)
130+
})
131+
132+
it('checkbox remains checked when passwordProtectedState is true even if password is cleared', () => {
133+
expect(getIsPasswordProtected({
134+
enforcePasswordForPublicLink: false,
135+
passwordProtectedState: true,
136+
newPassword: '',
137+
password: undefined,
138+
})).toBe(true)
139+
})
140+
})
141+
142+
describe('isPasswordProtected setter (race condition fix)', () => {
143+
it('generated password does NOT overwrite user-typed password', async () => {
144+
const share = { newPassword: 'user-typed-password' }
145+
const result = await setIsPasswordProtected(true, share)
146+
147+
expect(mockGeneratePassword).toHaveBeenCalledWith(true)
148+
expect(result.passwordProtectedState).toBe(true)
149+
expect(result.share.newPassword).toBe('user-typed-password')
150+
})
151+
152+
it('generated password IS applied when user has not typed anything', async () => {
153+
const share: { newPassword?: string } = {}
154+
const result = await setIsPasswordProtected(true, share)
155+
156+
expect(mockGeneratePassword).toHaveBeenCalledWith(true)
157+
expect(result.passwordProtectedState).toBe(true)
158+
expect(result.share.newPassword).toBe('generated-password-123')
159+
})
160+
161+
it('generated password IS applied when newPassword is empty string (user cleared input)', async () => {
162+
const share = { newPassword: '' }
163+
const result = await setIsPasswordProtected(true, share)
164+
165+
expect(result.share.newPassword).toBe('generated-password-123')
166+
})
167+
168+
it('disabling password clears newPassword and sets state to false', async () => {
169+
const share = { newPassword: 'some-password' }
170+
const result = await setIsPasswordProtected(false, share)
171+
172+
expect(result.passwordProtectedState).toBe(false)
173+
expect(result.share.newPassword).toBe('')
174+
})
175+
})
176+
177+
describe('initializeAttributes sets passwordProtectedState', () => {
178+
it('should set passwordProtectedState when enableLinkPasswordByDefault is true for new public share', () => {
179+
const config = { enableLinkPasswordByDefault: true, enforcePasswordForPublicLink: false }
180+
const isNewShare = true
181+
const isPublicShare = true
182+
let passwordProtectedState: boolean | undefined
183+
184+
if (isNewShare && (config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink) && isPublicShare) {
185+
passwordProtectedState = true
186+
}
187+
188+
expect(passwordProtectedState).toBe(true)
189+
})
190+
191+
it('should set passwordProtectedState when isPasswordEnforced is true for new public share', () => {
192+
const config = { enableLinkPasswordByDefault: false, enforcePasswordForPublicLink: true }
193+
const isNewShare = true
194+
const isPublicShare = true
195+
let passwordProtectedState: boolean | undefined
196+
197+
if (isNewShare && (config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink) && isPublicShare) {
198+
passwordProtectedState = true
199+
}
200+
201+
expect(passwordProtectedState).toBe(true)
202+
})
203+
204+
it('should not set passwordProtectedState for non-public shares', () => {
205+
const config = { enableLinkPasswordByDefault: true, enforcePasswordForPublicLink: false }
206+
const isNewShare = true
207+
const isPublicShare = false
208+
let passwordProtectedState: boolean | undefined
209+
210+
if (isNewShare && (config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink) && isPublicShare) {
211+
passwordProtectedState = true
212+
}
213+
214+
expect(passwordProtectedState).toBe(undefined)
215+
})
216+
217+
it('should not set passwordProtectedState for existing shares', () => {
218+
const config = { enableLinkPasswordByDefault: true, enforcePasswordForPublicLink: false }
219+
const isNewShare = false
220+
const isPublicShare = true
221+
let passwordProtectedState: boolean | undefined
222+
223+
if (isNewShare && (config.enableLinkPasswordByDefault || config.enforcePasswordForPublicLink) && isPublicShare) {
224+
passwordProtectedState = true
225+
}
226+
227+
expect(passwordProtectedState).toBe(undefined)
228+
})
229+
})
230+
231+
describe('saveShare validation blocks empty password', () => {
232+
const isValidShareAttribute = (attr: unknown) => {
233+
return typeof attr === 'string' && attr.length > 0
234+
}
235+
236+
it('should set passwordError when isPasswordProtected but newPassword is empty for new share', () => {
237+
const isPasswordProtected = true
238+
const isNewShare = true
239+
const newPassword = ''
240+
let passwordError = false
241+
242+
if (isPasswordProtected && isNewShare && !isValidShareAttribute(newPassword)) {
243+
passwordError = true
244+
}
245+
246+
expect(passwordError).toBe(true)
247+
})
248+
249+
it('should set passwordError when isPasswordProtected but newPassword is undefined for new share', () => {
250+
const isPasswordProtected = true
251+
const isNewShare = true
252+
const newPassword = undefined
253+
let passwordError = false
254+
255+
if (isPasswordProtected && isNewShare && !isValidShareAttribute(newPassword)) {
256+
passwordError = true
257+
}
258+
259+
expect(passwordError).toBe(true)
260+
})
261+
262+
it('should not set passwordError when password is valid for new share', () => {
263+
const isPasswordProtected = true
264+
const isNewShare = true
265+
const newPassword = 'valid-password-123'
266+
let passwordError = false
267+
268+
if (isPasswordProtected && isNewShare && !isValidShareAttribute(newPassword)) {
269+
passwordError = true
270+
}
271+
272+
expect(passwordError).toBe(false)
273+
})
274+
275+
it('should not set passwordError when isPasswordProtected is false', () => {
276+
const isPasswordProtected = false
277+
const isNewShare = true
278+
const newPassword = ''
279+
let passwordError = false
280+
281+
if (isPasswordProtected && isNewShare && !isValidShareAttribute(newPassword)) {
282+
passwordError = true
283+
}
284+
285+
expect(passwordError).toBe(false)
286+
})
287+
288+
it('should not validate password for existing shares', () => {
289+
const isPasswordProtected = true
290+
const isNewShare = false
291+
const newPassword = ''
292+
let passwordError = false
293+
294+
if (isPasswordProtected && isNewShare && !isValidShareAttribute(newPassword)) {
295+
passwordError = true
296+
}
297+
298+
expect(passwordError).toBe(false)
299+
})
300+
})
301+
})

apps/files_sharing/src/views/SharingDetailsTab.vue

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -974,7 +974,11 @@ export default {
974974
async initializeAttributes() {
975975
if (this.isNewShare) {
976976
if ((this.config.enableLinkPasswordByDefault || this.isPasswordEnforced) && this.isPublicShare) {
977-
this.$set(this.share, 'newPassword', await GeneratePassword(true))
977+
this.passwordProtectedState = true
978+
const generatedPassword = await GeneratePassword(true)
979+
if (!this.share.newPassword) {
980+
this.$set(this.share, 'newPassword', generatedPassword)
981+
}
978982
this.advancedSectionAccordionExpanded = true
979983
}
980984
/* Set default expiration dates if configured */
@@ -1087,8 +1091,9 @@ export default {
10871091
this.share.note = ''
10881092
}
10891093
if (this.isPasswordProtected) {
1090-
if (this.isPasswordEnforced && this.isNewShare && !this.isValidShareAttribute(this.share.newPassword)) {
1094+
if (this.isNewShare && !this.isValidShareAttribute(this.share.newPassword)) {
10911095
this.passwordError = true
1096+
return
10921097
}
10931098
} else {
10941099
this.share.password = ''

dist/2517-2517.js

Lines changed: 0 additions & 2 deletions
This file was deleted.

dist/2517-2517.js.map

Lines changed: 0 additions & 1 deletion
This file was deleted.

dist/2517-2517.js.map.license

Lines changed: 0 additions & 1 deletion
This file was deleted.

dist/9809-9809.js

Lines changed: 2 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/9809-9809.js.map

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/9809-9809.js.map.license

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
9809-9809.js.license

0 commit comments

Comments
 (0)