Files
accounted/extensions/general/cloud-backup/lib/__tests__/dropbox-provider.test.ts
T
Mattsson fbd4b992f5 Add/db and speed (#1243)
* fix(privacy): make privacy policy page dark mode friendly

Replace the hardcoded light gradient background with bg-background and
add dark:prose-invert to the prose blocks so body text is readable on
dark cards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* feat(cloud-backup): sync archives to Dropbox alongside Google Drive

Introduce a CloudStorageProvider interface so performSync builds the
archive set once and talks to storage only through it. Google Drive
keeps its existing behaviour; Dropbox is a second implementation, so
the compliance-relevant half (fingerprints, per-year layout, size
fallback, progressive persistence) cannot drift between targets.

Dropbox uses App folder access, matching the drive.file scope's "only
what the app created" guarantee. Uploads are single-shot under 8 MB and
chunked upload sessions above, every write verified against Dropbox's
content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so
Swedish file names survive the Dropbox-API-Arg header.

Each provider owns its extension_data keys, schedule, failure counter
and alert throttle, so a dead Dropbox token cannot pause a healthy
Drive backup. The google_drive_* keys and the /oauth/callback path are
untouched: both are wire format for already-connected companies.

isConfigured() gates /connect only. A deployment that loses its OAuth
credentials must not trap users with a connection they cannot remove
or a schedule they cannot switch off.

Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row
renders disabled without them. No migration: state is extension_data
JSON throughout.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix: remove merge-conflict markers committed in DECISIONS.md

The merge that brought main into this branch staged DECISIONS.md while
it still carried conflict markers, so cdc3a513 shipped an unresolved
hunk (compliance swarm ISO 27001 A.8.32).

DECISIONS.md is an append-only log, so both sides are kept: main's
systemdokumentation entry followed by this branch's Dropbox entries.
No decision was dropped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-27 16:49:24 +02:00

180 lines
5.5 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
vi.mock('../dropbox-client', async (importOriginal) => {
const actual = await importOriginal<typeof import('../dropbox-client')>()
return { ...actual, uploadDropboxFile: vi.fn() }
})
import { dropboxProvider } from '../dropbox-provider'
import { uploadDropboxFile } from '../dropbox-client'
import type { CloudConnection } from '../../types'
const mockUpload = vi.mocked(uploadDropboxFile)
function makeConnection(overrides: Partial<CloudConnection> = {}): CloudConnection {
return {
refresh_token_encrypted: 'enc',
account_email: 'user@example.com',
connected_at: '2026-01-01T00:00:00.000Z',
root_folder_id: null,
company_folder_id: null,
...overrides,
}
}
const ORIGINAL_APP_FOLDER = process.env.DROPBOX_APP_FOLDER_NAME
beforeEach(() => {
vi.clearAllMocks()
delete process.env.DROPBOX_APP_FOLDER_NAME
})
afterEach(() => {
if (ORIGINAL_APP_FOLDER === undefined) delete process.env.DROPBOX_APP_FOLDER_NAME
else process.env.DROPBOX_APP_FOLDER_NAME = ORIGINAL_APP_FOLDER
})
describe('dropboxProvider storage keys', () => {
it('owns keys distinct from Google Drive so the two never collide', () => {
expect(dropboxProvider.keys).toEqual({
connection: 'dropbox_connection',
lastSync: 'dropbox_last_sync',
schedule: 'dropbox_schedule',
})
})
it('uses its own OAuth callback path', () => {
expect(dropboxProvider.callbackPath).toBe('/oauth/dropbox/callback')
})
})
describe('dropboxProvider.prepareTarget', () => {
it('derives the company folder path without calling Dropbox', async () => {
const { target, connectionPatch } = await dropboxProvider.prepareTarget({
accessToken: 'token',
connection: makeConnection(),
companyLabel: 'Testbolag AB (556000-0000)',
})
expect(target.folderId).toBe('/Testbolag AB (556000-0000)')
expect(connectionPatch).toEqual({
company_folder_path: '/Testbolag AB (556000-0000)',
})
})
it('reports no change when the stored path already matches', async () => {
const { connectionPatch } = await dropboxProvider.prepareTarget({
accessToken: 'token',
connection: makeConnection({ company_folder_path: '/Testbolag AB (556000-0000)' }),
companyLabel: 'Testbolag AB (556000-0000)',
})
expect(connectionPatch).toBeNull()
})
it('sanitises a company name that would produce an invalid path', async () => {
const { target } = await dropboxProvider.prepareTarget({
accessToken: 'token',
connection: makeConnection(),
companyLabel: 'Bolaget AB / Filial (556000-0000)',
})
// A single leading slash: the label must not be able to add path segments.
expect(target.folderId).toBe('/Bolaget AB - Filial (556000-0000)')
expect(target.folderId.slice(1)).not.toContain('/')
})
it('links to the Apps root when the app folder name is unknown', async () => {
const { target } = await dropboxProvider.prepareTarget({
accessToken: 'token',
connection: makeConnection(),
companyLabel: 'Testbolag AB',
})
expect(target.webViewLink).toBe('https://www.dropbox.com/home/Apps')
})
it('deep-links into the company folder when the app folder name is configured', async () => {
process.env.DROPBOX_APP_FOLDER_NAME = 'Accounted'
const { target } = await dropboxProvider.prepareTarget({
accessToken: 'token',
connection: makeConnection(),
companyLabel: 'Testbolag AB',
})
expect(target.webViewLink).toBe(
'https://www.dropbox.com/home/Apps/Accounted/Testbolag%20AB'
)
})
})
describe('dropboxProvider.putFile', () => {
it('writes under the company folder and returns the path as the handle', async () => {
mockUpload.mockResolvedValue({
path: '/Testbolag AB/Arkiv 2024.zip',
name: 'Arkiv 2024.zip',
size_bytes: 42,
})
const result = await dropboxProvider.putFile({
accessToken: 'token',
target: {
folderId: '/Testbolag AB',
webViewLink: 'https://www.dropbox.com/home/Apps',
},
name: 'Arkiv 2024.zip',
data: new ArrayBuffer(42),
contentType: 'application/zip',
})
expect(mockUpload).toHaveBeenCalledWith(
'token',
'/Testbolag AB/Arkiv 2024.zip',
expect.any(ArrayBuffer)
)
expect(result).toEqual({
id: '/Testbolag AB/Arkiv 2024.zip',
name: 'Arkiv 2024.zip',
size_bytes: 42,
})
})
it('overwrites by path and ignores the previous handle', async () => {
mockUpload.mockResolvedValue({
path: '/Bolag/Grunddata.zip',
name: 'Grunddata.zip',
size_bytes: 8,
})
await dropboxProvider.putFile({
accessToken: 'token',
target: { folderId: '/Bolag', webViewLink: 'https://www.dropbox.com/home/Apps' },
name: 'Grunddata.zip',
previousId: '/somewhere/else/Grunddata.zip',
data: new ArrayBuffer(8),
contentType: 'application/zip',
})
expect(mockUpload.mock.calls[0][1]).toBe('/Bolag/Grunddata.zip')
})
it('keeps Swedish file names intact: the header encoder handles them', async () => {
mockUpload.mockResolvedValue({
path: '/Bolag/LÄSMIG.txt',
name: 'LÄSMIG.txt',
size_bytes: 11,
})
await dropboxProvider.putFile({
accessToken: 'token',
target: { folderId: '/Bolag', webViewLink: 'https://www.dropbox.com/home/Apps' },
name: 'LÄSMIG.txt',
data: new ArrayBuffer(11),
contentType: 'text/plain',
})
expect(mockUpload.mock.calls[0][1]).toBe('/Bolag/LÄSMIG.txt')
})
})