* feat: cloud backup to Google Drive + full-archive all-scope Adds a cloud-backup extension that uploads a full-company backup ZIP to the user's own Google Drive via OAuth (drive.file scope only). Refresh tokens are AES-256-GCM encrypted before being stored in extension_data. The full-archive export gains a scope=all mode for whole-company backups (per-period SIE under sie/, per-period rapporter/ subfolders, flat dokument/ manifest tagged with fiscal_period_id). An 80 MB size guard short-circuits generation before the platform response limit. Also fixes a latent bug in lib/core/audit/audit-service.ts where the parameter was named userId while the query filtered by company_id; the audit-trail API route was passing user.id so audit queries returned empty unless user and company shared a UUID. Drive-by: scope the dashboard "fresh start" localStorage key per companyId so dismissing the setup checklist in one company no longer carries over to others. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: address review comments on cloud backup + archive export - Extend audit trail to_date to end-of-day so last-day entries aren't silently excluded from period-scoped archives. - Apply 413 size-limit guard regardless of include_documents, using the overhead-only figure when documents are excluded. - Use crypto.randomUUID() for Drive multipart boundary to eliminate any collision risk with ZIP payload bytes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: migrate legacy setup-gate localStorage keys on dashboard Users who previously dismissed the setup checklist via the old global erp_setup_fresh_start or erp_checklist_dismissed keys were re-gated after the switch to a company-scoped key. Fall back to the legacy keys on read and migrate them to the scoped key on first hit. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix: update customer email handling and anonymization rules in supportmail-to-ticket skill * test: update audit trail to_date expectation for end-of-day timestamp Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
110 lines
4.1 KiB
TypeScript
110 lines
4.1 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import { ensureFolder, uploadFile } from '../google-drive'
|
|
|
|
beforeEach(() => {
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('ensureFolder', () => {
|
|
it('returns existing folder when found', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({ files: [{ id: 'folder-1', name: 'gnubok' }] }),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
const folder = await ensureFolder('at', 'gnubok', null)
|
|
expect(folder.id).toBe('folder-1')
|
|
// Only the search call; no create needed.
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
const url = fetchMock.mock.calls[0][0] as string
|
|
expect(url).toContain('/files?q=')
|
|
expect(decodeURIComponent(url)).toContain(`name = 'gnubok'`)
|
|
expect(decodeURIComponent(url)).toContain(`'root' in parents`)
|
|
})
|
|
|
|
it('creates a new folder when none exists', async () => {
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ files: [] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
.mockResolvedValueOnce(
|
|
new Response(JSON.stringify({ id: 'new-id', name: 'gnubok' }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
const folder = await ensureFolder('at', 'gnubok', null)
|
|
expect(folder.id).toBe('new-id')
|
|
expect(fetchMock).toHaveBeenCalledTimes(2)
|
|
const createCall = fetchMock.mock.calls[1]
|
|
expect((createCall[1] as RequestInit).method).toBe('POST')
|
|
const body = JSON.parse(String((createCall[1] as RequestInit).body))
|
|
expect(body.mimeType).toBe('application/vnd.google-apps.folder')
|
|
expect(body.name).toBe('gnubok')
|
|
})
|
|
|
|
it('escapes single quotes in folder name and scopes to parent', async () => {
|
|
const fetchMock = vi
|
|
.spyOn(globalThis, 'fetch')
|
|
.mockResolvedValueOnce(
|
|
// findFolderByName → match so we never hit create.
|
|
new Response(JSON.stringify({ files: [{ id: 'x', name: "Kalle's" }] }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
await ensureFolder('at', "Kalle's", 'parent-id')
|
|
const searchUrl = decodeURIComponent(fetchMock.mock.calls[0][0] as string)
|
|
expect(searchUrl).toContain(`name = 'Kalle\\'s'`)
|
|
expect(searchUrl).toContain(`'parent-id' in parents`)
|
|
})
|
|
})
|
|
|
|
describe('uploadFile', () => {
|
|
it('posts multipart body with metadata + binary and returns parsed result', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
id: 'file-123',
|
|
name: 'arkiv.zip',
|
|
size: '2048',
|
|
webViewLink: 'https://drive.google.com/file/d/file-123/view',
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
|
|
const data = new Uint8Array([1, 2, 3, 4, 5]).buffer
|
|
const result = await uploadFile('access-tok', 'folder-1', 'arkiv.zip', data)
|
|
|
|
expect(result.id).toBe('file-123')
|
|
expect(result.size_bytes).toBe(2048)
|
|
expect(result.web_view_link).toContain('file-123')
|
|
|
|
const [url, init] = fetchMock.mock.calls[0]
|
|
expect(url).toContain('uploadType=multipart')
|
|
const contentType = (init as RequestInit).headers as Record<string, string>
|
|
expect(contentType.Authorization).toBe('Bearer access-tok')
|
|
expect(contentType['Content-Type']).toContain('multipart/related')
|
|
// Body must be a Buffer that contains the metadata JSON.
|
|
const body = (init as RequestInit).body as Buffer
|
|
expect(Buffer.isBuffer(body)).toBe(true)
|
|
expect(body.toString('utf8')).toContain('"name":"arkiv.zip"')
|
|
expect(body.toString('utf8')).toContain('"parents":["folder-1"]')
|
|
})
|
|
|
|
it('throws with Drive error body when upload fails', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response('quota exceeded', { status: 403 })
|
|
)
|
|
await expect(
|
|
uploadFile('at', 'folder', 'a.zip', new Uint8Array(1).buffer)
|
|
).rejects.toThrow(/403/)
|
|
})
|
|
})
|