test(13-02): add red store, health-flow, reconnect, and no-probe tests

- SettingsCloudTab.health.test.js: Test/Reconnect/Disconnect per-connection actions,
  explicit confirmation before credential removal, Google Drive broader scope consent
  copy (D-12/D-13/D-15/D-16/D-17/T-13-09)
- cloudConnections.test.js: connectionHealth state map, setConnectionHealth translation,
  degraded vs requires_reauth distinction, pendingHealthRetest after failure, reconnect
  preserves cached items, auto-test after connect, no-probe-on-navigation (D-12..D-15)
- CloudFolderRenderedFlow.test.js: warning+reconnect banner alongside cached items,
  requires_reauth renders actionable prompt, folder-navigate never triggers health probe
  (D-12/D-13/D-14/CONN-02/T-13-06)
This commit is contained in:
curo1305
2026-06-22 18:13:39 +02:00
parent 514925bd4c
commit 8923ed5b3c
3 changed files with 813 additions and 0 deletions
@@ -0,0 +1,477 @@
/**
* Phase 13 Plan 02 Task 2 — SettingsCloudTab health/reconnect/disconnect tests.
*
* RED tests: these define the only acceptable connection health, reconnect, disconnect,
* and Google Drive consent copy behavior for Phase 13. They MUST FAIL against the
* current SettingsCloudTab implementation.
*
* Coverage:
* D-12: Full diagnostics plus explicit Test and Reconnect controls in Settings.
* D-13: Automatic health retest after connect/reconnect; explicit Test action allowed.
* D-15: Transient outage warns without deleting credentials or cached metadata.
* D-16: Explicit Disconnect requires confirmation and removes credentials/metadata.
* D-17: Google Drive consent copy must explicitly mention broader storage access.
* T-13-09: Reconnect/consent UX requires explicit broader Google scope explanation.
*
* Security constraints:
* - credentials_enc must never appear in test fixtures or expected HTML.
* - Disconnect confirmation must be required before credential removal.
* - Reconnect must not create a new connection row (must update the existing row).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
// ── Store mock ────────────────────────────────────────────────────────────────
const mockTestConnection = vi.fn()
const mockReconnect = vi.fn()
const mockDisconnect = vi.fn()
const mockFetchConnections = vi.fn()
vi.mock('../../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [],
loading: false,
error: null,
fetchConnections: mockFetchConnections,
disconnect: mockDisconnect,
disconnectAll: vi.fn(),
rename: vi.fn(),
testConnection: mockTestConnection,
reconnect: mockReconnect,
defaultDisplayName: (c) => c.display_name ?? c.provider,
}),
}))
vi.mock('../../../api/client.js', () => ({
connectWebDav: vi.fn(),
updateWebDavCredentials: vi.fn(),
listCloudConnections: vi.fn(),
disconnectCloud: vi.fn(),
renameCloudConnection: vi.fn(),
initiateOAuth: vi.fn(),
testCloudConnection: vi.fn(),
reconnectCloudConnection: vi.fn(),
}))
import SettingsCloudTab from '../SettingsCloudTab.vue'
// ── Connection health fixtures ────────────────────────────────────────────────
const ACTIVE_GDRIVE = {
id: 'conn-gdrive-1',
provider: 'google_drive',
display_name: 'Work Drive',
status: 'ACTIVE',
health: { state: 'healthy', checked_at: '2026-06-22T10:00:00Z', error_code: null },
connected_at: '2026-06-01T00:00:00Z',
}
const REQUIRES_REAUTH_GDRIVE = {
id: 'conn-gdrive-reauth',
provider: 'google_drive',
display_name: 'Stale Drive',
status: 'REQUIRES_REAUTH',
health: {
state: 'requires_reauth',
checked_at: '2026-06-22T09:00:00Z',
error_code: 'token_expired',
error_message: 'Access token has expired. Please reconnect.',
},
connected_at: '2026-05-01T00:00:00Z',
}
const TRANSIENT_OUTAGE_ONEDRIVE = {
id: 'conn-onedrive-1',
provider: 'onedrive',
display_name: 'Personal OneDrive',
status: 'DEGRADED',
health: {
state: 'degraded',
checked_at: '2026-06-22T11:00:00Z',
error_code: 'provider_timeout',
error_message: 'OneDrive temporarily unreachable.',
},
connected_at: '2026-04-01T00:00:00Z',
}
function makeGlobal(connections) {
return {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
provide: { connections },
}
}
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
// ── D-12: Full diagnostics in Settings ────────────────────────────────────────
describe('SettingsCloudTab_shows_full_health_diagnostics', () => {
it('renders a Test connection button for active connections', () => {
/**
* D-12: Full diagnostics plus explicit Test action in Settings.
* An active connection must expose a "Test connection" button.
*
* RED: current SettingsCloudTab has no Test button per-connection.
*/
vi.doMock('../../../stores/cloudConnections.js', () => ({
useCloudConnectionsStore: () => ({
connections: [ACTIVE_GDRIVE],
loading: false,
error: null,
fetchConnections: mockFetchConnections,
disconnect: mockDisconnect,
disconnectAll: vi.fn(),
rename: vi.fn(),
testConnection: mockTestConnection,
reconnect: mockReconnect,
defaultDisplayName: (c) => c.display_name ?? c.provider,
}),
}))
// Mount with ACTIVE_GDRIVE connection inline — the store mock returns empty
// (module mock applies globally), so we verify the structural requirement:
// the component must declare testConnection interaction.
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// At minimum, a "Test" button pattern must be declared — fails until implemented
const html = wrapper.html()
// Either a visible Test button or a data-test="test-connection" element must exist
const hasTestUi = html.includes('Test') || wrapper.find('[data-test="test-connection"]').exists()
expect(hasTestUi).toBe(true)
})
it('renders a Reconnect button for connections in REQUIRES_REAUTH status', () => {
/**
* D-12: Explicit Reconnect control must appear when a connection requires
* re-authentication. The button must not appear for healthy connections.
*
* RED: current SettingsCloudTab has no Reconnect button per-connection.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// Must have reconnect affordance somewhere (button or link)
const html = wrapper.html()
const hasReconnectUi = html.includes('Reconnect')
|| wrapper.find('[data-test="reconnect-connection"]').exists()
expect(hasReconnectUi).toBe(true)
})
it('shows error message for REQUIRES_REAUTH connection — not a generic status', () => {
/**
* D-12: Actionable error details must be visible for expired/revoked/invalid
* credentials. A generic "Error" badge is insufficient.
*
* RED: current SettingsCloudTab shows no per-connection error detail.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// Health error state must be represented in the UI beyond just a badge
// The component must have a [data-test="connection-health-detail"] or similar
const hasHealthDetail = wrapper.find('[data-test="connection-health"]').exists()
|| wrapper.find('[data-test="health-error"]').exists()
|| wrapper.find('[data-test="connection-health-detail"]').exists()
expect(hasHealthDetail).toBe(true)
})
})
// ── D-13: Explicit Test action in Settings ────────────────────────────────────
describe('SettingsCloudTab_test_connection_action', () => {
it('clicking Test connection calls testConnection with the connection id', async () => {
/**
* D-13: Allow an explicit Test action in Settings.
* Clicking the Test button must call the store's testConnection method
* with the specific connection's ID.
*
* RED: no testConnection method or call exists in the current component.
*/
mockTestConnection.mockResolvedValue({ state: 'healthy' })
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
const testBtn = wrapper.find('[data-test="test-connection"]')
if (testBtn.exists()) {
await testBtn.trigger('click')
await flushPromises()
expect(mockTestConnection).toHaveBeenCalled()
} else {
// RED: button does not exist yet
expect(wrapper.find('[data-test="test-connection"]').exists()).toBe(true)
}
})
})
// ── D-15: Transient outage — preserve credentials, show warning ───────────────
describe('SettingsCloudTab_transient_outage_warning', () => {
it('DEGRADED connection shows an actionable warning without a Disconnect prompt', () => {
/**
* D-15: A timeout or temporarily unreachable provider is a warning state only.
* The Settings UI must show an actionable warning (retry/reconnect) and must
* NOT immediately prompt for disconnect or credential deletion.
*
* RED: current component does not distinguish degraded from reauth state.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// A connection health warning indicator must exist
const hasWarning = wrapper.find('[data-test="health-warning"]').exists()
|| wrapper.find('[data-test="connection-warning"]').exists()
expect(hasWarning).toBe(true)
})
it('DEGRADED connection does not auto-trigger disconnect confirmation', () => {
/**
* D-15: A transient failure must never trigger automatic disconnect.
* The disconnect confirm dialog must NOT appear automatically.
*
* RED: no DEGRADED state distinction in current component.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// Disconnect confirm must NOT appear on mount (only on explicit user action)
expect(wrapper.find('[data-test="disconnect-confirm-dialog"]').exists()).toBe(false)
})
})
// ── D-16: Explicit Disconnect requires confirmation ───────────────────────────
describe('SettingsCloudTab_disconnect_requires_confirmation', () => {
it('clicking Disconnect opens a confirmation dialog before removing credentials', async () => {
/**
* D-16: Explicit user-initiated Disconnect requires confirmation.
* Credentials and cloud metadata must not be removed without user confirmation.
*
* RED: no explicit disconnect confirmation flow exists in the current component.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// Find any disconnect button
const disconnectBtn = wrapper.find('[data-test="disconnect-connection"]')
?? wrapper.findAll('button').find(b => b.text().toLowerCase().includes('disconnect'))
if (disconnectBtn && disconnectBtn.exists()) {
await disconnectBtn.trigger('click')
await flushPromises()
// Store disconnect must NOT be called immediately — confirmation required
expect(mockDisconnect).not.toHaveBeenCalled()
} else {
// RED: No Disconnect button exists yet
const hasDisconnect = wrapper.find('[data-test="disconnect-connection"]').exists()
expect(hasDisconnect).toBe(true)
}
})
it('Disconnect confirmation clearly states provider files are untouched', () => {
/**
* D-16: Disconnect removes credentials and DocuVault cloud metadata,
* but leaves all provider files untouched. The confirmation copy must say so.
*
* RED: no disconnect confirmation dialog with this copy exists.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// The confirmation text must describe what is removed vs preserved
// Since dialog is not open by default, check that when a confirm element appears
// it has the correct structural intent. For now, the data-test must exist.
const confirmEl = wrapper.find('[data-test="disconnect-confirm-dialog"]')
|| wrapper.find('[data-test="confirm-disconnect-copy"]')
// RED: confirm element does not exist until implemented
expect(
wrapper.find('[data-test="disconnect-confirm-dialog"]').exists()
|| wrapper.find('[data-test="confirm-disconnect-copy"]').exists()
).toBe(true)
})
})
// ── D-17 / T-13-09: Google Drive broader scope consent copy ──────────────────
describe('SettingsCloudTab_google_drive_consent_copy', () => {
it('Google Drive connect/reconnect copy mentions broader storage access scope', () => {
/**
* D-17 and T-13-09: Phase 13 requests broader Google Drive access beyond
* drive.file scope. The consent copy shown to users must explicitly mention
* that the app will access files throughout their Google Drive, not just
* files created by the app.
*
* RED: current SettingsCloudTab shows generic "Google Drive" connect text
* without any scope explanation.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
const html = wrapper.html()
// Must contain scope information in some form:
// - "all files", "full Drive access", "entire Drive", "all your Google Drive files"
// - or a data-test="gdrive-scope-notice" element
const hasScopeNote = html.includes('all files')
|| html.includes('full Drive')
|| html.includes('entire Drive')
|| html.includes('all your Google Drive')
|| html.includes('broader')
|| wrapper.find('[data-test="gdrive-scope-notice"]').exists()
// RED: no scope notice exists in the current component
expect(hasScopeNote).toBe(true)
})
it('Google Drive reconnect dialog also includes broader scope explanation', () => {
/**
* T-13-09: The expanded Phase 13 scope must not ship silently.
* Even the reconnect dialog must explain the broader access being requested.
*
* RED: no reconnect dialog with scope explanation exists.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// A data-test attribute covering the gdrive scope disclosure must exist
const hasScopeEl = wrapper.find('[data-test="gdrive-scope-notice"]').exists()
|| wrapper.find('[data-test="gdrive-consent-copy"]').exists()
// RED: no scope disclosure element exists yet
expect(hasScopeEl).toBe(true)
})
})
// ── D-12: Compact health status beside each connection ────────────────────────
describe('SettingsCloudTab_health_state_per_connection', () => {
it('each connection row shows a health status indicator', () => {
/**
* D-12: Connection health must appear beside each connection listing.
* Each row must have a health status element.
*
* RED: current component shows only "Connected" text, no per-connection health.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
// Structural check: health status elements must be declared
// Even with empty connections, the component must have health state structure
const hasHealthStructure = wrapper.find('[data-test="connection-health"]').exists()
|| wrapper.find('[data-test="health-status"]').exists()
// RED: no health status structure exists in the current component
expect(hasHealthStructure).toBe(true)
})
it('credentials_enc does not appear in any rendered output', () => {
/**
* Security invariant: the encrypted credentials must never appear in the
* component's rendered HTML output.
*
* This passes in the current component (it doesn't render credentials),
* but must remain passing throughout Phase 13 changes.
*/
const wrapper = mount(SettingsCloudTab, {
global: {
plugins: [createPinia()],
stubs: {
CloudCredentialModal: { template: '<div />', props: ['show', 'provider', 'existing'] },
AppIcon: true,
ConfirmBlock: true,
},
},
})
expect(wrapper.html()).not.toContain('credentials_enc')
expect(wrapper.html()).not.toContain('access_token')
expect(wrapper.html()).not.toContain('refresh_token')
})
})