Sessoes 1-6 acumuladas: hardening B2, defesa em camadas, +192 testes
Repositorio estava ha ~5 sessoes sem commit. Consolida tudo desde d088a89.
Ver commit.md na raiz para descricao completa por sessao.
# Numeros
- A# auditoria abertos: 0/30
- V# verificacoes abertos: 5/52 (todos adiados com plano)
- T# testes escritos: 10/10
- Vitest: 192/192
- SQL integration: 33/33
- E2E (Playwright, novo): 5/5
- Migrations: 17 (10 novas Sessao 6)
- Areas auditadas: 7 (+documentos com 10 V#)
# Highlights Sessao 6 (hoje)
- V#34/V#41 Opcao B2: tenant_features com plano + override (RPC SECURITY DEFINER, tela /saas/tenant-features)
- A#20 rev2 self-hosted: defesa em 5 camadas (honeypot + rate limit + math captcha condicional + paranoid mode + dashboard /saas/security)
- Documentos hardening (V#43-V#49): tenant scoping em storage policies (vazamento entre clinicas eliminado), RPC validate_share_token, signatures policy granular
- SaaS Twilio Config (/saas/twilio-config): UI editavel para SID/webhook/cotacao; AUTH_TOKEN permanece em env var
- T#9 + T#10: useAgendaEvents.spec.js + Playwright E2E (descobriu bug no front que foi corrigido)
# Sessoes anteriores (1-5) consolidadas
- Sessao 1: auth/router/session, normalizeRole extraido
- Sessao 2: agenda - composables/services consolidados
- Sessao 3: pacientes - tenant_id em todas queries
- Sessao 4: security review pagina publica - 14/15 vulnerabilidades corrigidas
- Sessao 5: SaaS - P0 (A#30: 7 tabelas com RLS off corrigidas)
# .gitignore ajustado
- supabase/* + !supabase/functions/ (mantem 10 edge functions, ignora .temp/migrations gerados pelo CLI)
- database-novo/backups/ (regeneravel via db.cjs backup)
- test-results/ + playwright-report/
- .claude/settings.local.json (config local com senha de dev removida do tracking)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* tenantFeaturesStore.spec.js
|
||||
*
|
||||
* Cobertura V#34/V#41 — Opção B2 (plano + override com exceção comercial).
|
||||
*
|
||||
* isEnabled DEVE:
|
||||
* - sem tenantId/key → false (regressão V#34: opt-out por padrão)
|
||||
* - override negativo → false sempre
|
||||
* - override positivo → true sempre (exceção comercial)
|
||||
* - sem override + plano permite → true
|
||||
* - sem override + plano não permite → false
|
||||
* - sem override + user entitlement permite → true (fallback solo therapist)
|
||||
*
|
||||
* setForTenant DEVE:
|
||||
* - chamar supabase.rpc('set_tenant_feature_exception', ...) com payload correto
|
||||
* - propagar erro
|
||||
* - atualizar cache local em sucesso
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
const rpcMock = vi.fn();
|
||||
const fromMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: {
|
||||
from: (...args) => fromMock(...args),
|
||||
rpc: (...args) => rpcMock(...args)
|
||||
}
|
||||
}));
|
||||
|
||||
// estado controlável do entitlementsStore
|
||||
const entState = {
|
||||
loadedForTenant: null,
|
||||
tenantFeatures: [],
|
||||
userFeatures: []
|
||||
};
|
||||
|
||||
vi.mock('@/stores/entitlementsStore', () => ({
|
||||
useEntitlementsStore: () => entState
|
||||
}));
|
||||
|
||||
const { useTenantFeaturesStore } = await import('../tenantFeaturesStore.js');
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
rpcMock.mockReset();
|
||||
fromMock.mockReset();
|
||||
entState.loadedForTenant = null;
|
||||
entState.tenantFeatures = [];
|
||||
entState.userFeatures = [];
|
||||
});
|
||||
|
||||
describe('isEnabled — guard rails', () => {
|
||||
it('retorna false sem tenantId nem loadedForTenantId', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
expect(s.isEnabled('patients')).toBe(false);
|
||||
});
|
||||
|
||||
it('retorna false sem key', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: true };
|
||||
s.loadedForTenantId = 't1';
|
||||
expect(s.isEnabled('')).toBe(false);
|
||||
expect(s.isEnabled(null, 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('regressão V#34: NÃO retorna true por padrão quando feature ausente', () => {
|
||||
// Antes (bug): sem entry em tenant_features → isEnabled = true
|
||||
// Agora (B2): sem entry + plano não permite → false
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {}; // sem entries
|
||||
s.loadedForTenantId = 't1';
|
||||
// entitlements vazio → não permite
|
||||
expect(s.isEnabled('patients', 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled — override explícito tem precedência', () => {
|
||||
it('override positivo → true mesmo se plano não permite (exceção comercial)', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { 'documents.signatures': true };
|
||||
s.loadedForTenantId = 't1';
|
||||
// entitlements vazio para esse tenant
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = [];
|
||||
expect(s.isEnabled('documents.signatures', 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('override negativo → false mesmo se plano permite (preferência cliente)', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: false };
|
||||
s.loadedForTenantId = 't1';
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = ['patients'];
|
||||
expect(s.isEnabled('patients', 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isEnabled — sem override → segue plano', () => {
|
||||
it('plano (tenant entitlement) permite → true', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = ['patients', 'agenda.view'];
|
||||
expect(s.isEnabled('patients', 't1')).toBe(true);
|
||||
expect(s.isEnabled('agenda.view', 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('plano não permite → false', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
entState.loadedForTenant = 't1';
|
||||
entState.tenantFeatures = ['patients'];
|
||||
expect(s.isEnabled('documents.signatures', 't1')).toBe(false);
|
||||
});
|
||||
|
||||
it('fallback user entitlement (solo therapist) → true', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
// tenant não tem subscription, mas user tem (therapist_pro)
|
||||
entState.loadedForTenant = null;
|
||||
entState.tenantFeatures = [];
|
||||
entState.userFeatures = ['patients.manage', 'documents.signatures'];
|
||||
expect(s.isEnabled('patients.manage', 't1')).toBe(true);
|
||||
expect(s.isEnabled('documents.signatures', 't1')).toBe(true);
|
||||
});
|
||||
|
||||
it('entitlements carregado para OUTRO tenant → ignora tenantFeatures (fail-safe)', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = {};
|
||||
s.loadedForTenantId = 't1';
|
||||
// ent carregado para t2, não t1 → não confiar em tenantFeatures
|
||||
entState.loadedForTenant = 't2';
|
||||
entState.tenantFeatures = ['patients'];
|
||||
// userFeatures vazio também
|
||||
expect(s.isEnabled('patients', 't1')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setForTenant — RPC obrigatória', () => {
|
||||
it('rejeita sem tenantId', async () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
await expect(s.setForTenant(null, 'patients', true)).rejects.toThrow(/tenantId/);
|
||||
});
|
||||
|
||||
it('rejeita sem key', async () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
await expect(s.setForTenant('t1', '', true)).rejects.toThrow(/feature_key/);
|
||||
});
|
||||
|
||||
it('chama RPC set_tenant_feature_exception com payload correto', async () => {
|
||||
rpcMock.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
|
||||
const s = useTenantFeaturesStore();
|
||||
await s.setForTenant('t1', 'patients', false, { reason: 'pref do cliente' });
|
||||
|
||||
expect(rpcMock).toHaveBeenCalledWith('set_tenant_feature_exception', {
|
||||
p_tenant_id: 't1',
|
||||
p_feature_key: 'patients',
|
||||
p_enabled: false,
|
||||
p_reason: 'pref do cliente'
|
||||
});
|
||||
});
|
||||
|
||||
it('aceita reason omitido (vira null)', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: null });
|
||||
const s = useTenantFeaturesStore();
|
||||
await s.setForTenant('t1', 'patients', true);
|
||||
expect(rpcMock).toHaveBeenCalledWith('set_tenant_feature_exception', {
|
||||
p_tenant_id: 't1',
|
||||
p_feature_key: 'patients',
|
||||
p_enabled: true,
|
||||
p_reason: null
|
||||
});
|
||||
});
|
||||
|
||||
it('propaga erro do RPC', async () => {
|
||||
rpcMock.mockResolvedValue({ data: null, error: new Error('Apenas saas_admin pode liberar feature fora do plano') });
|
||||
const s = useTenantFeaturesStore();
|
||||
await expect(s.setForTenant('t1', 'documents.signatures', true)).rejects.toThrow(/saas_admin/);
|
||||
expect(s.lastError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('atualiza cache local em sucesso', async () => {
|
||||
rpcMock.mockResolvedValue({ data: { ok: true }, error: null });
|
||||
const s = useTenantFeaturesStore();
|
||||
await s.setForTenant('t1', 'patients', false, { reason: 'test' });
|
||||
// cache local refletiu
|
||||
expect(s.featuresByTenant['t1']?.patients).toBe(false);
|
||||
expect(s.loadedForTenantId).toBe('t1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('invalidate — limpeza por tenant', () => {
|
||||
it('sem tenantId limpa tudo', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: true };
|
||||
s.featuresByTenant['t2'] = { rooms: false };
|
||||
s.loadedForTenantId = 't1';
|
||||
s.invalidate();
|
||||
expect(Object.keys(s.featuresByTenant).length).toBe(0);
|
||||
expect(s.loadedForTenantId).toBe(null);
|
||||
});
|
||||
|
||||
it('com tenantId limpa só esse', () => {
|
||||
const s = useTenantFeaturesStore();
|
||||
s.featuresByTenant['t1'] = { patients: true };
|
||||
s.featuresByTenant['t2'] = { rooms: false };
|
||||
s.loadedForTenantId = 't1';
|
||||
s.invalidate('t1');
|
||||
expect(s.featuresByTenant['t1']).toBeUndefined();
|
||||
expect(s.featuresByTenant['t2']).toEqual({ rooms: false });
|
||||
expect(s.loadedForTenantId).toBe(null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* tenantStore.spec.js — T#5
|
||||
*
|
||||
* Cobre os caminhos críticos do tenantStore:
|
||||
* - ensureLoaded singleflight (não dispara load duplicado)
|
||||
* - loadSessionAndTenant: usuário não logado, com memberships, com saved
|
||||
* tenant válido/inválido, fallback ao primeiro active
|
||||
* - setActiveTenant: tenant válido vs inválido (regressão V#5: tenant_id
|
||||
* de outro usuário não pode ser herdado)
|
||||
* - reset: limpa state e localStorage
|
||||
* - getters: tenantId / currentTenantId / tenant / hasActiveTenant
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
|
||||
// Stub mínimo de localStorage (vitest roda em env "node" sem jsdom).
|
||||
// Mantém Map em memória, mesmo contrato síncrono.
|
||||
const lsBacking = new Map();
|
||||
globalThis.localStorage = {
|
||||
getItem: (k) => (lsBacking.has(k) ? lsBacking.get(k) : null),
|
||||
setItem: (k, v) => lsBacking.set(k, String(v)),
|
||||
removeItem: (k) => lsBacking.delete(k),
|
||||
clear: () => lsBacking.clear(),
|
||||
key: (i) => Array.from(lsBacking.keys())[i] ?? null,
|
||||
get length() {
|
||||
return lsBacking.size;
|
||||
}
|
||||
};
|
||||
|
||||
const getSessionMock = vi.fn();
|
||||
const rpcMock = vi.fn();
|
||||
|
||||
vi.mock('@/lib/supabase/client', () => ({
|
||||
supabase: {
|
||||
auth: { getSession: (...a) => getSessionMock(...a) },
|
||||
rpc: (...a) => rpcMock(...a)
|
||||
}
|
||||
}));
|
||||
|
||||
vi.mock('@/support/supportLogger', () => ({
|
||||
logTenant: vi.fn(),
|
||||
logError: vi.fn()
|
||||
}));
|
||||
|
||||
const { useTenantStore } = await import('../tenantStore.js');
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
getSessionMock.mockReset();
|
||||
rpcMock.mockReset();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
const USER = { id: 'u-1', email: 'user@test.com' };
|
||||
const SESSION = { user: USER };
|
||||
|
||||
const memActive = (tenantId, role = 'tenant_admin', kind = 'clinic') => ({
|
||||
tenant_id: tenantId,
|
||||
role,
|
||||
kind,
|
||||
status: 'active'
|
||||
});
|
||||
|
||||
describe('loadSessionAndTenant — usuário não autenticado', () => {
|
||||
it('limpa estado quando não há sessão', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: null }, error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.loaded).toBe(true);
|
||||
expect(s.user).toBe(null);
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.activeRole).toBe(null);
|
||||
expect(s.memberships).toEqual([]);
|
||||
expect(s.needsTenantLink).toBe(false);
|
||||
// não chamou my_tenants sem user
|
||||
expect(rpcMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSessionAndTenant — usuário com memberships', () => {
|
||||
it('seleciona primeiro active quando não há saved', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe('t1');
|
||||
expect(s.activeRole).toBe('clinic_admin');
|
||||
expect(localStorage.getItem('tenant_id')).toBe('t1');
|
||||
expect(s.needsTenantLink).toBe(false);
|
||||
});
|
||||
|
||||
it('respeita saved tenant se pertencer ao usuário (membership active)', async () => {
|
||||
localStorage.setItem('tenant_id', 't2');
|
||||
localStorage.setItem('tenant', JSON.stringify({ id: 't2', role: 'clinic_admin' }));
|
||||
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe('t2');
|
||||
});
|
||||
|
||||
it('regressão V#5: descarta saved tenant que NÃO pertence ao usuário e cai no primeiro active', async () => {
|
||||
localStorage.setItem('tenant_id', 't-foreign');
|
||||
localStorage.setItem('tenant', JSON.stringify({ id: 't-foreign', role: 'clinic_admin' }));
|
||||
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe('t1');
|
||||
// localStorage foi atualizado pro tenant correto
|
||||
expect(localStorage.getItem('tenant_id')).toBe('t1');
|
||||
});
|
||||
|
||||
it('needsTenantLink=true quando usuário não tem nenhum membership active', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [{ ...memActive('t1'), status: 'pending' }], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.needsTenantLink).toBe(true);
|
||||
});
|
||||
|
||||
it('normaliza role via roleNormalizer (tenant_admin + kind=therapist → therapist)', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1', 'tenant_admin', 'therapist')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.activeRole).toBe('therapist');
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureLoaded — singleflight', () => {
|
||||
it('não recarrega se já loaded', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.ensureLoaded();
|
||||
await s.ensureLoaded();
|
||||
await s.ensureLoaded();
|
||||
|
||||
// só uma chamada de getSession
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('chamadas concorrentes compartilham a mesma promise', async () => {
|
||||
let resolveSession;
|
||||
const sessionPromise = new Promise((res) => {
|
||||
resolveSession = res;
|
||||
});
|
||||
getSessionMock.mockReturnValue(sessionPromise);
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
const p1 = s.ensureLoaded();
|
||||
const p2 = s.ensureLoaded();
|
||||
const p3 = s.ensureLoaded();
|
||||
|
||||
// resolve a única getSession
|
||||
resolveSession({ data: { session: SESSION }, error: null });
|
||||
await Promise.all([p1, p2, p3]);
|
||||
|
||||
expect(getSessionMock).toHaveBeenCalledTimes(1);
|
||||
expect(rpcMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadSessionAndTenant — erros', () => {
|
||||
it('erro em getSession seta error e marca loaded', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: null, error: new Error('session fail') });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.error).toBeTruthy();
|
||||
expect(s.loaded).toBe(true);
|
||||
});
|
||||
|
||||
it('erro em my_tenants seta error e mantém estado pré-existente', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: null, error: new Error('rpc fail') });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.error).toBeTruthy();
|
||||
expect(s.user).toEqual(USER); // user já tinha sido setado antes do throw
|
||||
expect(s.loaded).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveTenant — troca explícita', () => {
|
||||
it('aceita tenant válido (membership active)', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1'), memActive('t2', 'tenant_admin', 'therapist')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
s.setActiveTenant('t2');
|
||||
|
||||
expect(s.activeTenantId).toBe('t2');
|
||||
expect(s.activeRole).toBe('therapist');
|
||||
expect(localStorage.getItem('tenant_id')).toBe('t2');
|
||||
});
|
||||
|
||||
it('rejeita tenant que não está nos memberships (sem mudar nada → null)', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
s.setActiveTenant('t-foreign');
|
||||
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.activeRole).toBe(null);
|
||||
expect(s.needsTenantLink).toBe(true);
|
||||
expect(localStorage.getItem('tenant_id')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset — limpeza completa', () => {
|
||||
it('zera estado e localStorage', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
s.reset();
|
||||
|
||||
expect(s.user).toBe(null);
|
||||
expect(s.activeTenantId).toBe(null);
|
||||
expect(s.activeRole).toBe(null);
|
||||
expect(s.memberships).toEqual([]);
|
||||
expect(s.loaded).toBe(false);
|
||||
expect(localStorage.getItem('tenant_id')).toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getters', () => {
|
||||
it('tenantId / currentTenantId / role expõem activeTenantId / activeRole', async () => {
|
||||
getSessionMock.mockResolvedValue({ data: { session: SESSION }, error: null });
|
||||
rpcMock.mockResolvedValue({ data: [memActive('t1')], error: null });
|
||||
|
||||
const s = useTenantStore();
|
||||
await s.loadSessionAndTenant();
|
||||
|
||||
expect(s.tenantId).toBe('t1');
|
||||
expect(s.currentTenantId).toBe('t1');
|
||||
expect(s.role).toBe('clinic_admin');
|
||||
expect(s.tenant).toEqual({ id: 't1', role: 'clinic_admin' });
|
||||
expect(s.hasActiveTenant).toBe(true);
|
||||
});
|
||||
|
||||
it('tenant=null e hasActiveTenant=false sem activeTenantId', () => {
|
||||
const s = useTenantStore();
|
||||
expect(s.tenant).toBe(null);
|
||||
expect(s.hasActiveTenant).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -14,8 +14,21 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// entitlementsStore — features que o PLANO PERMITE (read-only, derivado).
|
||||
//
|
||||
// Separação V#41 (Opção B2):
|
||||
// entitlementsStore.has(key) = "o plano permite essa feature?"
|
||||
// tenantFeaturesStore.isEnabled(k) = "essa feature está ATIVA agora?"
|
||||
// (combina plano + override)
|
||||
//
|
||||
// Source: views v_tenant_entitlements (plano da clínica) e v_user_entitlements
|
||||
// (assinatura pessoal — solo therapist/supervisor). has() sem scope = união.
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { logError } from '@/support/supportLogger';
|
||||
|
||||
function normalizeKey(k) {
|
||||
return String(k || '').trim();
|
||||
@@ -131,11 +144,12 @@ export const useEntitlementsStore = defineStore('entitlements', {
|
||||
this.loadedForTenant = tenantId;
|
||||
this.tenantLoadedAt = Date.now();
|
||||
} catch (e) {
|
||||
// V#42: NÃO marcar como carregado em caso de erro — força próximo
|
||||
// request a tentar de novo em vez de mascarar com array vazio.
|
||||
this.tenantError = e;
|
||||
this.tenantRaw = [];
|
||||
this.tenantFeatures = [];
|
||||
this.loadedForTenant = tenantId;
|
||||
this.tenantLoadedAt = Date.now();
|
||||
logError('entitlementsStore.loadForTenant', `tenantId=${tenantId}`, e);
|
||||
} finally {
|
||||
this.tenantLoading = false;
|
||||
}
|
||||
@@ -171,11 +185,11 @@ export const useEntitlementsStore = defineStore('entitlements', {
|
||||
this.loadedForUser = userId;
|
||||
this.userLoadedAt = Date.now();
|
||||
} catch (e) {
|
||||
// V#42: mesmo tratamento de loadForTenant — não marca como carregado.
|
||||
this.userError = e;
|
||||
this.userRaw = [];
|
||||
this.userFeatures = [];
|
||||
this.loadedForUser = userId;
|
||||
this.userLoadedAt = Date.now();
|
||||
logError('entitlementsStore.loadForUser', `userId=${userId}`, e);
|
||||
} finally {
|
||||
this.userLoading = false;
|
||||
}
|
||||
|
||||
@@ -14,9 +14,28 @@
|
||||
| © 2026 — Todos os direitos reservados
|
||||
|--------------------------------------------------------------------------
|
||||
*/
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
// tenantFeaturesStore — overrides por tenant ("o que está ATIVO agora?")
|
||||
//
|
||||
// Funciona em conjunto com entitlementsStore ("o que o plano PERMITE?").
|
||||
// V#34/V#41 — Opção B2:
|
||||
// feature ATIVA quando:
|
||||
// plano permite AND nenhum override negativo, OU
|
||||
// override positivo (exceção comercial liberada por saas_admin via RPC)
|
||||
//
|
||||
// Toda escrita passa pela RPC set_tenant_feature_exception (autorização +
|
||||
// log + bypass controlado do trigger guard).
|
||||
// ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import { computed, ref } from 'vue';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { useEntitlementsStore } from '@/stores/entitlementsStore';
|
||||
|
||||
// V#18 — TTL configurável (default 5min). Força refetch se cache for mais
|
||||
// velho que TTL. Antes lastFetchedAt era registrado mas nunca verificado.
|
||||
const DEFAULT_TTL_MS = 5 * 60 * 1000;
|
||||
|
||||
export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
const loading = ref(false);
|
||||
@@ -26,6 +45,9 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
// Cache por tenant: { [tenantId]: { [feature_key]: boolean } }
|
||||
const featuresByTenant = ref({});
|
||||
|
||||
// V#18 — timestamps por tenant pra TTL granular
|
||||
const fetchedAtByTenant = ref({});
|
||||
|
||||
// Marca o último tenant buscado (útil pra debug)
|
||||
const loadedForTenantId = ref(null);
|
||||
|
||||
@@ -34,15 +56,43 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
return featuresByTenant.value?.[tenantId] || {};
|
||||
}
|
||||
|
||||
// 🔎 Se você passar tenantId, lê desse tenant; se não, tenta o último carregado
|
||||
// Modelo opt-out: se a feature não está configurada (key ausente do mapa), retorna true por padrão.
|
||||
// Só retorna false quando explicitamente desabilitada no banco.
|
||||
/**
|
||||
* isEnabled — feature está ATIVA para o tenant?
|
||||
*
|
||||
* Combina plano (entitlementsStore) + override (tenant_features):
|
||||
* - override negativo (false) → INATIVO sempre
|
||||
* - override positivo (true) → ATIVO sempre (exceção comercial)
|
||||
* - sem override → segue plano
|
||||
*
|
||||
* Pré-condição: entitlementsStore deve estar carregado para o mesmo
|
||||
* tenant (loadForTenant) ou para o user (loadForUser, fallback p/ solo
|
||||
* therapist). Se não estiver, retorna false (fail-safe).
|
||||
*/
|
||||
function isEnabled(key, tenantId = null) {
|
||||
const tid = tenantId || loadedForTenantId.value;
|
||||
if (!tid) return false;
|
||||
if (!tid || !key) return false;
|
||||
|
||||
const map = getTenantMap(tid);
|
||||
if (!(key in map)) return true; // não configurada = habilitada por padrão
|
||||
return !!map[key];
|
||||
|
||||
// Override explícito tem precedência
|
||||
if (key in map) {
|
||||
return !!map[key];
|
||||
}
|
||||
|
||||
// Sem override → consulta plano
|
||||
const ent = useEntitlementsStore();
|
||||
|
||||
// Tenant entitlements têm prioridade quando carregado para esse tenant
|
||||
if (ent.loadedForTenant === tid && Array.isArray(ent.tenantFeatures) && ent.tenantFeatures.includes(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Fallback: assinatura pessoal (solo therapist)
|
||||
if (Array.isArray(ent.userFeatures) && ent.userFeatures.includes(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function invalidate(tenantId = null) {
|
||||
@@ -50,20 +100,29 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
if (!tenantId) {
|
||||
loadedForTenantId.value = null;
|
||||
featuresByTenant.value = {};
|
||||
fetchedAtByTenant.value = {};
|
||||
return;
|
||||
}
|
||||
// invalida apenas um tenant
|
||||
const copy = { ...featuresByTenant.value };
|
||||
delete copy[tenantId];
|
||||
featuresByTenant.value = copy;
|
||||
const tCopy = { ...fetchedAtByTenant.value };
|
||||
delete tCopy[tenantId];
|
||||
fetchedAtByTenant.value = tCopy;
|
||||
if (loadedForTenantId.value === tenantId) loadedForTenantId.value = null;
|
||||
}
|
||||
|
||||
async function fetchForTenant(tenantId, { force = false } = {}) {
|
||||
async function fetchForTenant(tenantId, { force = false, maxAgeMs = DEFAULT_TTL_MS } = {}) {
|
||||
if (!tenantId) return;
|
||||
|
||||
// se já tem cache e não é force, não busca de novo
|
||||
if (!force && featuresByTenant.value?.[tenantId]) {
|
||||
// V#18 — usa TTL real: cache válido apenas se age < maxAgeMs
|
||||
const cached = featuresByTenant.value?.[tenantId];
|
||||
const fetchedAt = fetchedAtByTenant.value?.[tenantId] || 0;
|
||||
const age = Date.now() - fetchedAt;
|
||||
const cacheValid = !force && cached && (maxAgeMs === 0 || age < maxAgeMs);
|
||||
|
||||
if (cacheValid) {
|
||||
loadedForTenantId.value = tenantId;
|
||||
return;
|
||||
}
|
||||
@@ -83,6 +142,10 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: map
|
||||
};
|
||||
fetchedAtByTenant.value = {
|
||||
...fetchedAtByTenant.value,
|
||||
[tenantId]: Date.now()
|
||||
};
|
||||
|
||||
loadedForTenantId.value = tenantId;
|
||||
lastFetchedAt.value = new Date().toISOString();
|
||||
@@ -96,27 +159,39 @@ export const useTenantFeaturesStore = defineStore('tenantFeatures', () => {
|
||||
}
|
||||
}
|
||||
|
||||
async function setForTenant(tenantId, key, enabled) {
|
||||
/**
|
||||
* setForTenant — agora roteia tudo via RPC set_tenant_feature_exception.
|
||||
* Autorização e validação ficam no servidor (V#34 Opção B2):
|
||||
* - tenant_admin pode desligar feature do plano e religar
|
||||
* - saas_admin pode tudo + override positivo fora do plano (reason obrigatório)
|
||||
*/
|
||||
async function setForTenant(tenantId, key, enabled, { reason = null } = {}) {
|
||||
if (!tenantId) throw new Error('tenantId missing');
|
||||
if (!key) throw new Error('feature_key missing');
|
||||
|
||||
lastError.value = null;
|
||||
|
||||
const payload = { tenant_id: tenantId, feature_key: key, enabled: !!enabled };
|
||||
|
||||
const { error } = await supabase.from('tenant_features').upsert(payload, { onConflict: 'tenant_id,feature_key' });
|
||||
const { data, error } = await supabase.rpc('set_tenant_feature_exception', {
|
||||
p_tenant_id: tenantId,
|
||||
p_feature_key: key,
|
||||
p_enabled: !!enabled,
|
||||
p_reason: reason
|
||||
});
|
||||
|
||||
if (error) {
|
||||
lastError.value = error;
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Atualiza cache local do tenant (mesmo que ainda não tenha sido carregado)
|
||||
// Atualiza cache local do tenant
|
||||
const current = getTenantMap(tenantId);
|
||||
featuresByTenant.value = {
|
||||
...featuresByTenant.value,
|
||||
[tenantId]: { ...current, [key]: !!enabled }
|
||||
};
|
||||
loadedForTenantId.value = tenantId;
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// (opcional) útil pra debug rápido na tela
|
||||
|
||||
+23
-34
@@ -16,30 +16,12 @@
|
||||
*/
|
||||
import { defineStore } from 'pinia';
|
||||
import { supabase } from '@/lib/supabase/client';
|
||||
import { normalizeRole } from '@/utils/roleNormalizer';
|
||||
import { logTenant, logError } from '@/support/supportLogger';
|
||||
|
||||
/**
|
||||
* Normaliza o role de tenant levando em conta o kind do tenant.
|
||||
*
|
||||
* Regras:
|
||||
* tenant_admin / admin + kind = 'therapist' → 'therapist'
|
||||
* tenant_admin / admin + kind = clinic_* → 'clinic_admin'
|
||||
* tenant_admin / admin + kind desconhecido → 'clinic_admin' (padrão legado)
|
||||
* qualquer outro role → pass-through
|
||||
*/
|
||||
// wrapper que mantém o contrato original do tenantStore (retornar null quando role vazio)
|
||||
function normalizeTenantRole(role, kind) {
|
||||
const r = String(role || '').trim();
|
||||
if (!r) return null;
|
||||
|
||||
const isAdmin = r === 'tenant_admin' || r === 'admin';
|
||||
|
||||
if (isAdmin) {
|
||||
const k = String(kind || '').trim();
|
||||
if (k === 'therapist' || k === 'saas') return 'therapist';
|
||||
if (k === 'supervisor') return 'supervisor';
|
||||
return 'clinic_admin';
|
||||
}
|
||||
|
||||
return r;
|
||||
return normalizeRole(role, kind) || null;
|
||||
}
|
||||
|
||||
function readSavedTenant() {
|
||||
@@ -65,6 +47,10 @@ function clearPersistedTenant() {
|
||||
localStorage.removeItem('tenant');
|
||||
}
|
||||
|
||||
// Promise compartilhada: enquanto um loadSessionAndTenant está em voo, todos os
|
||||
// callers concorrentes aguardam a mesma promise (singleflight).
|
||||
let loadPromise = null;
|
||||
|
||||
export const useTenantStore = defineStore('tenant', {
|
||||
state: () => ({
|
||||
loading: false,
|
||||
@@ -88,22 +74,25 @@ export const useTenantStore = defineStore('tenant', {
|
||||
actions: {
|
||||
async ensureLoaded() {
|
||||
if (this.loaded) return;
|
||||
if (this.loading) {
|
||||
await new Promise((resolve) => {
|
||||
const t = setInterval(() => {
|
||||
if (!this.loading) {
|
||||
clearInterval(t);
|
||||
resolve();
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
if (loadPromise) {
|
||||
await loadPromise;
|
||||
return;
|
||||
}
|
||||
await this.loadSessionAndTenant();
|
||||
},
|
||||
|
||||
async loadSessionAndTenant() {
|
||||
if (this.loading) return;
|
||||
if (loadPromise) return loadPromise;
|
||||
|
||||
loadPromise = this._doLoadSessionAndTenant();
|
||||
try {
|
||||
await loadPromise;
|
||||
} finally {
|
||||
loadPromise = null;
|
||||
}
|
||||
},
|
||||
|
||||
async _doLoadSessionAndTenant() {
|
||||
this.loading = true;
|
||||
this.error = null;
|
||||
|
||||
@@ -139,7 +128,7 @@ export const useTenantStore = defineStore('tenant', {
|
||||
if (savedTenantId) {
|
||||
activeMembership = this.memberships.find((x) => x.tenant_id === savedTenantId && x.status === 'active');
|
||||
if (!activeMembership) {
|
||||
console.warn('[tenantStore] tenant salvo não pertence a este usuário, limpando.');
|
||||
logTenant('tenantStore', 'tenant salvo não pertence a este usuário, limpando', { savedTenantId });
|
||||
clearPersistedTenant();
|
||||
}
|
||||
}
|
||||
@@ -161,7 +150,7 @@ export const useTenantStore = defineStore('tenant', {
|
||||
this.needsTenantLink = !this.activeTenantId;
|
||||
this.loaded = true;
|
||||
} catch (e) {
|
||||
console.warn('[tenantStore] loadSessionAndTenant falhou:', e);
|
||||
logError('tenantStore', 'loadSessionAndTenant falhou', e);
|
||||
this.error = e;
|
||||
|
||||
if (!this.user) {
|
||||
|
||||
Reference in New Issue
Block a user