Skip to content

Commit 32afa71

Browse files
fix(files): share upsert validation returns 400 not 500; disabling always succeeds
1 parent 2120a46 commit 32afa71

3 files changed

Lines changed: 72 additions & 25 deletions

File tree

apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,19 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({
1717
getWorkspaceFile: mockGetWorkspaceFile,
1818
}))
1919

20-
vi.mock('@/lib/public-shares/share-manager', () => ({
21-
getShareForResource: mockGetShareForResource,
22-
upsertFileShare: mockUpsertFileShare,
23-
}))
20+
vi.mock('@/lib/public-shares/share-manager', () => {
21+
class ShareValidationError extends Error {
22+
constructor(message: string) {
23+
super(message)
24+
this.name = 'ShareValidationError'
25+
}
26+
}
27+
return {
28+
getShareForResource: mockGetShareForResource,
29+
upsertFileShare: mockUpsertFileShare,
30+
ShareValidationError,
31+
}
32+
})
2433

2534
vi.mock('@/ee/access-control/utils/permission-check', () => {
2635
class PublicFileSharingNotAllowedError extends Error {
@@ -38,6 +47,7 @@ vi.mock('@sim/audit', () => auditMock)
3847
const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785'
3948
const FILE_ID = 'wf_abc'
4049

50+
import { ShareValidationError } from '@/lib/public-shares/share-manager'
4151
import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route'
4252

4353
const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) })
@@ -102,6 +112,15 @@ describe('share route', () => {
102112
expect(mockUpsertFileShare).not.toHaveBeenCalled()
103113
})
104114

115+
it('maps a ShareValidationError to 400, not 500', async () => {
116+
mockUpsertFileShare.mockRejectedValueOnce(
117+
new ShareValidationError('Password is required for password-protected shares')
118+
)
119+
const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params())
120+
expect(res.status).toBe(400)
121+
expect((await res.json()).error).toBe('Password is required for password-protected shares')
122+
})
123+
105124
it('returns 404 when the file is not in the workspace', async () => {
106125
mockGetWorkspaceFile.mockResolvedValueOnce(null)
107126
const res = await PUT(putRequest({ isActive: true }), params())

apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,11 @@ import { parseRequest } from '@/lib/api/server'
77
import { getSession } from '@/lib/auth'
88
import { generateRequestId } from '@/lib/core/utils/request'
99
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
10-
import { getShareForResource, upsertFileShare } from '@/lib/public-shares/share-manager'
10+
import {
11+
getShareForResource,
12+
ShareValidationError,
13+
upsertFileShare,
14+
} from '@/lib/public-shares/share-manager'
1115
import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace'
1216
import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils'
1317
import {
@@ -138,6 +142,9 @@ export const PUT = withRouteHandler(
138142

139143
return NextResponse.json({ share })
140144
} catch (error) {
145+
if (error instanceof ShareValidationError) {
146+
return NextResponse.json({ error: error.message }, { status: 400 })
147+
}
141148
logger.error(`[${requestId}] Error updating file share:`, error)
142149
return NextResponse.json(
143150
{ error: getErrorMessage(error, 'Failed to update share') },

apps/sim/lib/public-shares/share-manager.ts

Lines changed: 41 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import { getBaseUrl } from '@/lib/core/utils/urls'
1414

1515
const logger = createLogger('PublicShareManager')
1616

17+
/** Thrown when share auth config is invalid (e.g. enabling a password share with no password). Maps to a 400. */
18+
export class ShareValidationError extends Error {
19+
constructor(message: string) {
20+
super(message)
21+
this.name = 'ShareValidationError'
22+
}
23+
}
24+
1725
type ShareResourceType = z.infer<typeof shareResourceTypeSchema>
1826

1927
type PublicShareRow = typeof publicShare.$inferSelect
@@ -94,10 +102,11 @@ interface UpsertFileShareInput {
94102
* a fresh unguessable token; subsequent calls flip `isActive`/`authType` and keep
95103
* the token stable (so an existing link resolves again after re-enable).
96104
*
97-
* Auth handling (fail-fast): `password` requires a plaintext `password` unless the
98-
* share already has one (encrypted at rest via {@link encryptSecret}); `email`/`sso`
99-
* require a non-empty `allowedEmails` (provided or already stored); any other type
100-
* clears both the password and the allow-list.
105+
* Auth validation only applies when **enabling** (`isActive: true`): `password`
106+
* requires a plaintext `password` unless one is already stored (encrypted via
107+
* {@link encryptSecret}); `email`/`sso` require a non-empty `allowedEmails`.
108+
* Disabling (going Private) always succeeds and preserves the stored config so a
109+
* later re-enable restores it. Validation failures throw {@link ShareValidationError}.
101110
*/
102111
export async function upsertFileShare({
103112
workspaceId,
@@ -117,23 +126,35 @@ export async function upsertFileShare({
117126

118127
const finalAuthType: ShareAuthType =
119128
authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public'
120-
121-
let finalPassword: string | null = null
122-
let finalAllowedEmails: string[] = []
123-
if (finalAuthType === 'password') {
124-
if (password) {
125-
finalPassword = (await encryptSecret(password)).encrypted
126-
} else if (existing?.password) {
127-
finalPassword = existing.password
129+
const existingAllowedEmails = Array.isArray(existing?.allowedEmails)
130+
? (existing.allowedEmails as string[])
131+
: []
132+
133+
// Disabling preserves the stored config (and skips validation) so turning
134+
// sharing off always succeeds; only enabling validates the chosen auth mode.
135+
let finalPassword: string | null = existing?.password ?? null
136+
let finalAllowedEmails: string[] = existingAllowedEmails
137+
if (isActive) {
138+
if (finalAuthType === 'password') {
139+
if (password) {
140+
finalPassword = (await encryptSecret(password)).encrypted
141+
} else if (existing?.password) {
142+
finalPassword = existing.password
143+
} else {
144+
throw new ShareValidationError('Password is required for password-protected shares')
145+
}
146+
finalAllowedEmails = []
147+
} else if (finalAuthType === 'email' || finalAuthType === 'sso') {
148+
finalAllowedEmails = allowedEmails ?? existingAllowedEmails
149+
if (finalAllowedEmails.length === 0) {
150+
throw new ShareValidationError(
151+
'At least one allowed email is required for email/SSO shares'
152+
)
153+
}
154+
finalPassword = null
128155
} else {
129-
throw new Error('Password is required for password-protected shares')
130-
}
131-
} else if (finalAuthType === 'email' || finalAuthType === 'sso') {
132-
finalAllowedEmails =
133-
allowedEmails ??
134-
(Array.isArray(existing?.allowedEmails) ? (existing.allowedEmails as string[]) : [])
135-
if (finalAllowedEmails.length === 0) {
136-
throw new Error('At least one allowed email is required for email/SSO shares')
156+
finalPassword = null
157+
finalAllowedEmails = []
137158
}
138159
}
139160

0 commit comments

Comments
 (0)