Files
accounted/extensions/general/cloud-backup/__tests__/provider-routes.test.ts
T
MattssonandClaude Opus 5 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

292 lines
10 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { cloudBackupExtension } from '../index'
import type { ExtensionContext } from '@/lib/extensions/types'
import type { CloudBackupStatus } from '../types'
const BASE = 'https://test.local/api/extensions/ext/cloud-backup'
function findRoute(method: string, path: string) {
const route = cloudBackupExtension.apiRoutes?.find(
(r) => r.method === method && r.path === path
)
expect(route, `${method} ${path} must be registered`).toBeDefined()
return route!
}
function makeRequest(path: string, method = 'POST', body?: unknown): Request {
return new Request(`${BASE}${path}`, {
method,
headers: body ? { 'Content-Type': 'application/json' } : undefined,
body: body ? JSON.stringify(body) : undefined,
})
}
/** Settings backed by a map, so a test can assert which keys were touched. */
function makeContext(seed: Record<string, unknown> = {}) {
const store = new Map<string, unknown>(Object.entries(seed))
const set = vi.fn().mockImplementation(async (key: string, value: unknown) => {
store.set(key, value)
})
const clear = vi.fn().mockImplementation(async (key: string) => {
store.delete(key)
})
const ctx = {
userId: 'user-1',
companyId: 'company-1',
extensionId: 'cloud-backup',
requestId: 'req_test',
supabase: {},
emit: vi.fn().mockResolvedValue(undefined),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
settings: {
get: vi.fn().mockImplementation(async (key: string) => store.get(key) ?? null),
set,
clear,
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as ExtensionContext
return { ctx, store, set, clear }
}
function makeConnection(overrides: Record<string, unknown> = {}) {
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 ENV_KEYS = [
'GOOGLE_CLIENT_ID',
'GOOGLE_CLIENT_SECRET',
'DROPBOX_APP_KEY',
'DROPBOX_APP_SECRET',
// The OAuth state parameter is encrypted with a key derived from this.
'SUPABASE_SERVICE_ROLE_KEY',
] as const
const originalEnv: Record<string, string | undefined> = {}
beforeEach(() => {
vi.clearAllMocks()
for (const key of ENV_KEYS) originalEnv[key] = process.env[key]
process.env.GOOGLE_CLIENT_ID = 'google-id'
process.env.GOOGLE_CLIENT_SECRET = 'google-secret'
process.env.DROPBOX_APP_KEY = 'dropbox-key'
process.env.DROPBOX_APP_SECRET = 'dropbox-secret'
process.env.SUPABASE_SERVICE_ROLE_KEY = 'service-role-key'
})
afterEach(() => {
for (const key of ENV_KEYS) {
if (originalEnv[key] === undefined) delete process.env[key]
else process.env[key] = originalEnv[key]
}
})
describe('provider routing', () => {
it('rejects an unknown provider rather than defaulting to a destination', async () => {
const route = findRoute('POST', '/connect')
const { ctx } = makeContext()
const res = await route.handler(makeRequest('/connect?provider=onedrive'), ctx)
expect(res.status).toBe(400)
await expect(res.json()).resolves.toEqual({ error: 'unknown_provider' })
})
it('treats a missing provider as Google Drive, as pre-Dropbox clients meant', async () => {
const route = findRoute('POST', '/connect')
const { ctx } = makeContext()
const res = await route.handler(makeRequest('/connect'), ctx)
expect(res.status).toBe(200)
const { url } = (await res.json()) as { url: string }
expect(url).toContain('accounts.google.com')
})
it('sends the user to Dropbox with an app-folder scope set', async () => {
const route = findRoute('POST', '/connect')
const { ctx } = makeContext()
const res = await route.handler(makeRequest('/connect?provider=dropbox'), ctx)
expect(res.status).toBe(200)
const { url } = (await res.json()) as { url: string }
const parsed = new URL(url)
expect(parsed.origin + parsed.pathname).toBe('https://www.dropbox.com/oauth2/authorize')
// Without offline access Dropbox issues no refresh token and the nightly
// cron dies a few hours after connecting.
expect(parsed.searchParams.get('token_access_type')).toBe('offline')
expect(parsed.searchParams.get('scope')).toBe(
'files.content.write files.content.read account_info.read'
)
expect(parsed.searchParams.get('redirect_uri')).toBe(
'https://test.local/api/extensions/ext/cloud-backup/oauth/dropbox/callback'
)
expect(parsed.searchParams.get('state')).toBeTruthy()
})
it('refuses to start a flow the deployment has no credentials for', async () => {
delete process.env.DROPBOX_APP_KEY
delete process.env.DROPBOX_APP_SECRET
const route = findRoute('POST', '/connect')
const { ctx } = makeContext()
const res = await route.handler(makeRequest('/connect?provider=dropbox'), ctx)
expect(res.status).toBe(400)
await expect(res.json()).resolves.toEqual({ error: 'provider_not_configured' })
})
it('registers a separate OAuth callback per provider', () => {
expect(findRoute('GET', '/oauth/callback')).toBeDefined()
expect(findRoute('GET', '/oauth/dropbox/callback')).toBeDefined()
})
})
describe('GET /status', () => {
it('reports every provider independently', async () => {
const route = findRoute('GET', '/status')
const { ctx } = makeContext({
google_drive_connection: makeConnection({ account_email: 'a@example.com' }),
dropbox_connection: makeConnection({
account_email: 'b@example.com',
status: 'needs_reauth',
}),
})
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
const { data } = (await res.json()) as { data: CloudBackupStatus }
expect(data.providers.map((p) => p.provider)).toEqual(['google_drive', 'dropbox'])
const google = data.providers[0]
const dropbox = data.providers[1]
expect(google).toMatchObject({
connected: true,
needs_reauth: false,
account_email: 'a@example.com',
})
expect(dropbox).toMatchObject({
connected: true,
needs_reauth: true,
account_email: 'b@example.com',
})
})
it('flags a provider the deployment cannot use so the UI can disable the row', async () => {
delete process.env.DROPBOX_APP_KEY
delete process.env.DROPBOX_APP_SECRET
const route = findRoute('GET', '/status')
const { ctx } = makeContext()
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
const { data } = (await res.json()) as { data: CloudBackupStatus }
expect(data.providers.find((p) => p.provider === 'google_drive')?.configured).toBe(true)
expect(data.providers.find((p) => p.provider === 'dropbox')?.configured).toBe(false)
})
it('keeps mirroring Google Drive at the top level for pre-Dropbox clients', async () => {
const route = findRoute('GET', '/status')
const { ctx } = makeContext({
google_drive_connection: makeConnection({ account_email: 'a@example.com' }),
})
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
const { data } = (await res.json()) as { data: CloudBackupStatus }
expect(data.connected).toBe(true)
expect(data.account_email).toBe('a@example.com')
})
it('does not report a Dropbox connection as a Google one at the top level', async () => {
const route = findRoute('GET', '/status')
const { ctx } = makeContext({ dropbox_connection: makeConnection() })
const res = await route.handler(makeRequest('/status', 'GET'), ctx)
const { data } = (await res.json()) as { data: CloudBackupStatus }
// The legacy fields describe Google Drive alone: a dashboard banner reading
// them must not claim the Drive backup is healthy because Dropbox is.
expect(data.connected).toBe(false)
expect(data.account_email).toBeNull()
})
})
describe('POST /disconnect', () => {
it('clears only the targeted provider records', async () => {
const route = findRoute('POST', '/disconnect')
const { ctx, store } = makeContext({
google_drive_connection: makeConnection(),
google_drive_last_sync: { at: 'x', folder_id: 'f' },
google_drive_schedule: { enabled: true },
dropbox_connection: makeConnection(),
dropbox_last_sync: { at: 'y', folder_id: '/g' },
dropbox_schedule: { enabled: true },
})
const res = await route.handler(makeRequest('/disconnect?provider=dropbox'), ctx)
expect(res.status).toBe(200)
expect([...store.keys()].sort()).toEqual([
'google_drive_connection',
'google_drive_last_sync',
'google_drive_schedule',
])
})
it('still works when the deployment lost its credentials', async () => {
// A user must never be trapped with a connection they cannot remove.
delete process.env.DROPBOX_APP_KEY
delete process.env.DROPBOX_APP_SECRET
const route = findRoute('POST', '/disconnect')
const { ctx, store } = makeContext({ dropbox_connection: makeConnection() })
const res = await route.handler(makeRequest('/disconnect?provider=dropbox'), ctx)
expect(res.status).toBe(200)
expect(store.has('dropbox_connection')).toBe(false)
})
})
describe('schedule routes are per provider', () => {
it('writes the schedule under the targeted provider key', async () => {
const route = findRoute('PUT', '/schedule')
const { ctx, store } = makeContext()
const res = await route.handler(
makeRequest('/schedule?provider=dropbox', 'PUT', { enabled: true, hour_local: 4 }),
ctx
)
expect(res.status).toBe(200)
expect(store.has('dropbox_schedule')).toBe(true)
expect(store.has('google_drive_schedule')).toBe(false)
})
it('reads each provider schedule separately', async () => {
const route = findRoute('GET', '/schedule')
const { ctx } = makeContext({
google_drive_schedule: { enabled: true, hour_utc: 3, hour_local: 5 },
dropbox_schedule: { enabled: false, hour_utc: 9, hour_local: 11 },
})
const google = await route.handler(makeRequest('/schedule', 'GET'), ctx)
const dropbox = await route.handler(
makeRequest('/schedule?provider=dropbox', 'GET'),
ctx
)
await expect(google.json()).resolves.toMatchObject({
data: { enabled: true, hour_local: 5 },
})
await expect(dropbox.json()).resolves.toMatchObject({
data: { enabled: false, hour_local: 11 },
})
})
})