d708a85d4c
* 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>
104 lines
3.6 KiB
TypeScript
104 lines
3.6 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
|
import {
|
|
buildAuthorizationUrl,
|
|
exchangeCodeForTokens,
|
|
refreshAccessToken,
|
|
getOAuthEnv,
|
|
} from '../google-oauth'
|
|
|
|
beforeEach(() => {
|
|
process.env.GOOGLE_CLIENT_ID = 'test-client-id'
|
|
process.env.GOOGLE_CLIENT_SECRET = 'test-client-secret'
|
|
vi.restoreAllMocks()
|
|
})
|
|
|
|
describe('getOAuthEnv', () => {
|
|
it('builds redirect URI from origin', () => {
|
|
const env = getOAuthEnv('https://app.example.com')
|
|
expect(env.redirectUri).toBe(
|
|
'https://app.example.com/api/extensions/ext/cloud-backup/oauth/callback'
|
|
)
|
|
expect(env.clientId).toBe('test-client-id')
|
|
})
|
|
|
|
it('throws when env vars missing', () => {
|
|
delete process.env.GOOGLE_CLIENT_ID
|
|
expect(() => getOAuthEnv('http://localhost:3000')).toThrow(/GOOGLE_CLIENT_ID/)
|
|
})
|
|
})
|
|
|
|
describe('buildAuthorizationUrl', () => {
|
|
it('includes scope, offline access, consent prompt, and state', () => {
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const url = buildAuthorizationUrl(env, 'abc123state')
|
|
const parsed = new URL(url)
|
|
expect(parsed.origin + parsed.pathname).toBe(
|
|
'https://accounts.google.com/o/oauth2/v2/auth'
|
|
)
|
|
expect(parsed.searchParams.get('access_type')).toBe('offline')
|
|
expect(parsed.searchParams.get('prompt')).toBe('consent')
|
|
expect(parsed.searchParams.get('state')).toBe('abc123state')
|
|
expect(parsed.searchParams.get('scope')).toContain(
|
|
'https://www.googleapis.com/auth/drive.file'
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('exchangeCodeForTokens', () => {
|
|
it('posts form-encoded body and parses token response', async () => {
|
|
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(
|
|
JSON.stringify({
|
|
access_token: 'at',
|
|
refresh_token: 'rt',
|
|
expires_in: 3600,
|
|
}),
|
|
{ status: 200, headers: { 'Content-Type': 'application/json' } }
|
|
)
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const result = await exchangeCodeForTokens(env, 'auth-code')
|
|
|
|
expect(result.refresh_token).toBe('rt')
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
const [url, init] = fetchMock.mock.calls[0]
|
|
expect(url).toBe('https://oauth2.googleapis.com/token')
|
|
expect((init as RequestInit).method).toBe('POST')
|
|
expect(String((init as RequestInit).body)).toContain('grant_type=authorization_code')
|
|
expect(String((init as RequestInit).body)).toContain('code=auth-code')
|
|
})
|
|
|
|
it('throws a clear error when no refresh_token is returned', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ access_token: 'at', expires_in: 3600 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/refresh token/i)
|
|
})
|
|
|
|
it('throws on non-OK response', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response('Bad Request', { status: 400 })
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
await expect(exchangeCodeForTokens(env, 'code')).rejects.toThrow(/400/)
|
|
})
|
|
})
|
|
|
|
describe('refreshAccessToken', () => {
|
|
it('returns a fresh access token', async () => {
|
|
vi.spyOn(globalThis, 'fetch').mockResolvedValue(
|
|
new Response(JSON.stringify({ access_token: 'new-at', expires_in: 3600 }), {
|
|
status: 200,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
})
|
|
)
|
|
const env = getOAuthEnv('http://localhost:3000')
|
|
const result = await refreshAccessToken(env, 'old-refresh')
|
|
expect(result.access_token).toBe('new-at')
|
|
})
|
|
})
|